データベース連携のToDoアプリ改修

2025-08-31

はじめに

これまで学んできたすべての技術要素を統合し、完全なフルスタックTODOアプリケーションを完成させましょう。今回は、Reactフロントエンド、Expressバックエンド、MySQLデータベース、Prisma ORM、JWT認証、CORS設定をすべて組み合わせた本格的なアプリケーションを構築します。

プロジェクト構造の最終構成

完成したアプリケーションの構造は以下のようになります:

todo-app/
├── backend/                 # Expressバックエンド
│   ├── prisma/
│   │   └── schema.prisma    # データベーススキーマ
│   ├── src/
│   │   ├── config/          # 設定ファイル
│   │   ├── middleware/      # カスタムミドルウェア
│   │   ├── models/          # データモデル(必要に応じて)
│   │   ├── routes/          # APIルート
│   │   ├── utils/           # ユーティリティ関数
│   │   └── lib/             # ライブラリ設定
│   ├── .env                 # 環境変数
│   └── server.js            # メインサーバーファイル
├── frontend/                # Reactフロントエンド
│   ├── public/
│   ├── src/
│   │   ├── api/             # API通信モジュール
│   │   ├── components/      # Reactコンポーネント
│   │   ├── contexts/        # React Context
│   │   ├── hooks/           # カスタムフック
│   │   └── utils/           # ユーティリティ関数
│   ├── .env                 # 環境変数
│   └── package.json
└── docker-compose.yml       # Docker設定(オプション)

バックエンドの最終実装

1. データベーススキーマの完成版

// backend/prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String
  name      String?
  todos     Todo[]
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("users")
}

model Todo {
  id        Int      @id @default(autoincrement())
  title     String
  completed Boolean  @default(false)
  userId    Int
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("todos")
}

model RefreshToken {
  id        Int      @id @default(autoincrement())
  token     String   @unique
  userId    Int
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime
  createdAt DateTime @default(now()) @map("created_at")

  @@map("refresh_tokens")
}

2. 環境変数ファイルの設定

# backend/.env
# データベース接続
DATABASE_URL="mysql://app_user:password@localhost:3306/todo_app"

# JWT設定
JWT_SECRET="your-super-secret-jwt-key-for-access-token-min-32-chars"
JWT_REFRESH_SECRET="your-super-secret-jwt-key-for-refresh-token-min-32-chars"
JWT_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d"

# アプリケーション設定
NODE_ENV="development"
PORT=5000 CLIENT_URL=”http://localhost:3000″ # その他 BCRYPT_SALT_ROUNDS=12

3. 主要なユーティリティ関数

// backend/src/utils/password.js
const bcrypt = require('bcryptjs');

const hashPassword = async (password) => {
  const saltRounds = parseInt(process.env.BCRYPT_SALT_ROUNDS) || 12;
  return await bcrypt.hash(password, saltRounds);
};

const verifyPassword = async (password, hashedPassword) => {
  return await bcrypt.compare(password, hashedPassword);
};

module.exports = {
  hashPassword,
  verifyPassword
};
// backend/src/utils/jwt.js
const jwt = require('jsonwebtoken');
const prisma = require('../lib/prisma');

const generateAccessToken = (userId) => {
  return jwt.sign(
    { userId },
    process.env.JWT_SECRET,
    { expiresIn: process.env.JWT_EXPIRES_IN }
  );
};

const generateRefreshToken = (userId) => {
  return jwt.sign(
    { userId },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN }
  );
};

const verifyAccessToken = (token) => {
  try {
    return jwt.verify(token, process.env.JWT_SECRET);
  } catch (error) {
    throw new Error('無効または期限切れのアクセストークンです');
  }
};

const verifyRefreshToken = (token) => {
  try {
    return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
  } catch (error) {
    throw new Error('無効または期限切れのリフレッシュトークンです');
  }
};

const storeRefreshToken = async (userId, token, expiresAt) => {
  return await prisma.refreshToken.create({
    data: {
      token,
      userId,
      expiresAt
    }
  });
};

const revokeRefreshToken = async (token) => {
  return await prisma.refreshToken.deleteMany({
    where: { token }
  });
};

const extractTokenFromHeader = (authHeader) => {
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return null;
  }
  return authHeader.substring(7);
};

module.exports = {
  generateAccessToken,
  generateRefreshToken,
  verifyAccessToken,
  verifyRefreshToken,
  storeRefreshToken,
  revokeRefreshToken,
  extractTokenFromHeader
};

4. 認証ミドルウェアの完成版

// backend/src/middleware/auth.js
const { verifyAccessToken, extractTokenFromHeader } = require('../utils/jwt');
const prisma = require('../lib/prisma');

const authenticateUser = async (req, res, next) => {
  try {
    const authHeader = req.headers.authorization;
    const token = extractTokenFromHeader(authHeader);

    if (!token) {
      return res.status(401).json({ error: '認証トークンが必要です' });
    }

    const decoded = verifyAccessToken(token);
    const user = await prisma.user.findUnique({
      where: { id: decoded.userId },
      select: { 
        id: true, 
        email: true, 
        name: true,
        createdAt: true
      }
    });

    if (!user) {
      return res.status(401).json({ error: 'ユーザーが見つかりません' });
    }

    req.user = user;
    next();
  } catch (error) {
    return res.status(401).json({ error: error.message });
  }
};

// オプションの認証(認証されていないユーザーも通す)
const optionalAuth = async (req, res, next) => {
  try {
    const authHeader = req.headers.authorization;
    const token = extractTokenFromHeader(authHeader);

    if (token) {
      const decoded = verifyAccessToken(token);
      const user = await prisma.user.findUnique({
        where: { id: decoded.userId },
        select: { 
          id: true, 
          email: true, 
          name: true,
          createdAt: true
        }
      });

      if (user) {
        req.user = user;
      }
    }

    next();
  } catch (error) {
    // 認証エラーがあっても続行(オプション認証のため)
    next();
  }
};

module.exports = {
  authenticateUser,
  optionalAuth
};

5. TODOルートの完成版

// backend/src/routes/todos.js
const express = require('express');
const { authenticateUser } = require('../middleware/auth');
const prisma = require('../lib/prisma');

const router = express.Router();

// すべてのルートを認証で保護
router.use(authenticateUser);

// TODO一覧の取得(ページネーション付き)
router.get('/', async (req, res) => {
  try {
    const { page = 1, limit = 10, completed } = req.query;
    const skip = (parseInt(page) - 1) * parseInt(limit);

    const whereClause = { userId: req.user.id };
    if (completed !== undefined) {
      whereClause.completed = completed === 'true';
    }

    const [todos, total] = await Promise.all([
      prisma.todo.findMany({
        where: whereClause,
        orderBy: { createdAt: 'desc' },
        skip,
        take: parseInt(limit),
        include: {
          user: {
            select: {
              id: true,
              name: true,
              email: true
            }
          }
        }
      }),
      prisma.todo.count({ where: whereClause })
    ]);

    res.json({
      todos,
      pagination: {
        page: parseInt(page),
        limit: parseInt(limit),
        total,
        pages: Math.ceil(total / parseInt(limit))
      }
    });
  } catch (error) {
    console.error('TODO一覧取得エラー:', error);
    res.status(500).json({ error: 'TODOの取得に失敗しました' });
  }
});

// 特定のTODOの取得
router.get('/:id', async (req, res) => {
  try {
    const id = parseInt(req.params.id);
    const todo = await prisma.todo.findFirst({
      where: { 
        id,
        userId: req.user.id 
      },
      include: {
        user: {
          select: {
            id: true,
            name: true,
            email: true
          }
        }
      }
    });

    if (!todo) {
      return res.status(404).json({ error: 'TODOが見つかりません' });
    }

    res.json(todo);
  } catch (error) {
    console.error('TODO取得エラー:', error);
    res.status(500).json({ error: 'TODOの取得に失敗しました' });
  }
});

// TODOの新規作成
router.post('/', async (req, res) => {
  try {
    const { title, completed = false } = req.body;

    if (!title || title.trim() === '') {
      return res.status(400).json({ error: 'タイトルは必須です' });
    }

    const todo = await prisma.todo.create({
      data: {
        title: title.trim(),
        completed,
        userId: req.user.id
      },
      include: {
        user: {
          select: {
            id: true,
            name: true,
            email: true
          }
        }
      }
    });

    res.status(201).json(todo);
  } catch (error) {
    console.error('TODO作成エラー:', error);
    res.status(500).json({ error: 'TODOの作成に失敗しました' });
  }
});

// TODOの更新
router.put('/:id', async (req, res) => {
  try {
    const id = parseInt(req.params.id);
    const { title, completed } = req.body;

    // TODOの存在確認と権限チェック
    const existingTodo = await prisma.todo.findFirst({
      where: { 
        id,
        userId: req.user.id 
      }
    });

    if (!existingTodo) {
      return res.status(404).json({ error: 'TODOが見つかりません' });
    }

    const todo = await prisma.todo.update({
      where: { id },
      data: {
        title: title !== undefined ? title : existingTodo.title,
        completed: completed !== undefined ? completed : existingTodo.completed
      },
      include: {
        user: {
          select: {
            id: true,
            name: true,
            email: true
          }
        }
      }
    });

    res.json(todo);
  } catch (error) {
    console.error('TODO更新エラー:', error);
    res.status(500).json({ error: 'TODOの更新に失敗しました' });
  }
});

// TODOの削除
router.delete('/:id', async (req, res) => {
  try {
    const id = parseInt(req.params.id);

    // TODOの存在確認と権限チェック
    const existingTodo = await prisma.todo.findFirst({
      where: { 
        id,
        userId: req.user.id 
      }
    });

    if (!existingTodo) {
      return res.status(404).json({ error: 'TODOが見つかりません' });
    }

    await prisma.todo.delete({
      where: { id }
    });

    res.status(204).send();
  } catch (error) {
    console.error('TODO削除エラー:', error);
    res.status(500).json({ error: 'TODOの削除に失敗しました' });
  }
});

// 複数TODOの一括操作
router.patch('/batch', async (req, res) => {
  try {
    const { ids, completed } = req.body;

    if (!Array.isArray(ids) || ids.length === 0) {
      return res.status(400).json({ error: '有効なIDリストが必要です' });
    }

    if (completed === undefined) {
      return res.status(400).json({ error: '完了状態を指定してください' });
    }

    const result = await prisma.todo.updateMany({
      where: {
        id: { in: ids },
        userId: req.user.id
      },
      data: { completed }
    });

    res.json({
      message: `${result.count}個のTODOを更新しました`,
      updatedCount: result.count
    });
  } catch (error) {
    console.error('一括更新エラー:', error);
    res.status(500).json({ error: '一括更新に失敗しました' });
  }
});

// 統計情報の取得
router.get('/stats/summary', async (req, res) => {
  try {
    const total = await prisma.todo.count({
      where: { userId: req.user.id }
    });

    const completed = await prisma.todo.count({
      where: { 
        userId: req.user.id,
        completed: true 
      }
    });

    const pending = await prisma.todo.count({
      where: { 
        userId: req.user.id,
        completed: false 
      }
    });

    // 最近の活動
    const recentActivity = await prisma.todo.findMany({
      where: { userId: req.user.id },
      orderBy: { updatedAt: 'desc' },
      take: 5,
      select: {
        id: true,
        title: true,
        completed: true,
        updatedAt: true
      }
    });

    res.json({
      total,
      completed,
      pending,
      completionRate: total > 0 ? Math.round((completed / total) * 100) : 0,
      recentActivity
    });
  } catch (error) {
    console.error('統計情報取得エラー:', error);
    res.status(500).json({ error: '統計情報の取得に失敗しました' });
  }
});

module.exports = router;

6. メインサーバーファイルの完成版

// backend/server.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
require('dotenv').config();

const corsOptions = require('./src/config/cors');
const authRoutes = require('./src/routes/auth');
const todoRoutes = require('./src/routes/todos');

const app = express();
const port = process.env.PORT || 5000;

// セキュリティミドルウェア
app.use(helmet());
app.use(cors(corsOptions));

// レート制限
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分
  max: 100, // 各IPから15分間に100リクエストまで
  message: 'リクエストが多すぎます。後ほどお試しください。'
});
app.use('/api/', limiter);

// ロギングミドルウェア
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
  next();
});

// ボディパーサー
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));

// ルート設定
app.use('/api/auth', authRoutes);
app.use('/api/todos', todoRoutes);

// ヘルスチェック
app.get('/api/health', (req, res) => {
  res.json({
    status: 'OK',
    timestamp: new Date().toISOString(),
    environment: process.env.NODE_ENV,
    version: '1.0.0'
  });
});

// 404ハンドリング
app.use('/api/*', (req, res) => {
  res.status(404).json({ error: 'エンドポイントが見つかりません' });
});

// グローバルエラーハンドリング
app.use((error, req, res, next) => {
  console.error('予期せぬエラー:', error);

  // Prismaエラーの処理
  if (error.code === 'P2002') {
    return res.status(409).json({ error: '重複するデータが存在します' });
  }

  if (error.code === 'P2025') {
    return res.status(404).json({ error: 'リソースが見つかりません' });
  }

  res.status(500).json({
    error: 'サーバー内部エラーが発生しました',
    ...(process.env.NODE_ENV === 'development' && { details: error.message })
  });
});

// グレースフルシャットダウン
process.on('SIGINT', async () => {
  console.log('アプリケーションをシャットダウンしています...');
  const { prisma } = require('./src/lib/prisma');
  await prisma.$disconnect();
  process.exit(0);
});

// サーバー起動
app.listen(port, () => {
  console.log(`🚀 サーバーが起動しました: http://localhost:${port}`);
  console.log(`📊 環境: ${process.env.NODE_ENV || 'development'}`);
  console.log(`🔗 CORS設定: ${process.env.CLIENT_URL}`);
});

フロントエンドの最終実装

1. メインAppコンポーネント

// frontend/src/App.js
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import Header from './components/Header';
import Login from './components/Login';
import Register from './components/Register';
import TodoList from './components/TodoList';
import Dashboard from './components/Dashboard';
import './App.css';

// 保護されたルートコンポーネント
const ProtectedRoute = ({ children }) => {
  const { isAuthenticated, loading } = useAuth();

  if (loading) {
    return <div className="loading">読み込み中...</div>;
  }

  return isAuthenticated ? children : <Navigate to="/login" />;
};

// ゲストルートコンポーネント
const GuestRoute = ({ children }) => {
  const { isAuthenticated, loading } = useAuth();

  if (loading) {
    return <div className="loading">読み込み中...</div>;
  }

  return !isAuthenticated ? children : <Navigate to="/dashboard" />;
};

function AppContent() {
  return (
    <div className="app">
      <Header />
      <main className="main-content">
        <Routes>
          <Route path="/" element={<Navigate to="/dashboard" />} />
          <Route path="/login" element={
            <GuestRoute>
              <Login />
            </GuestRoute>
          } />
          <Route path="/register" element={
            <GuestRoute>
              <Register />
            </GuestRoute>
          } />
          <Route path="/dashboard" element={
            <ProtectedRoute>
              <Dashboard />
            </ProtectedRoute>
          } />
          <Route path="/todos" element={
            <ProtectedRoute>
              <TodoList />
            </ProtectedRoute>
          } />
          <Route path="*" element={<div>ページが見つかりません</div>} />
        </Routes>
      </main>
    </div>
  );
}

function App() {
  return (
    <AuthProvider>
      <Router>
        <AppContent />
      </Router>
    </AuthProvider>
  );
}

export default App;

2. ダッシュボードコンポーネント

// frontend/src/components/Dashboard.js
import React from 'react';
import { Link } from 'react-router-dom';
import { useTodos } from '../hooks/useTodos';
import { useAuth } from '../contexts/AuthContext';

const Dashboard = () => {
  const { stats, loading, error } = useTodos();
  const { user } = useAuth();

  if (loading) {
    return <div className="loading">読み込み中...</div>;
  }

  if (error) {
    return <div className="error">エラー: {error}</div>;
  }

  return (
    <div className="dashboard">
      <div className="dashboard-header">
        <h1>ダッシュボード</h1>
        <p>ようこそ、{user?.name || user?.email}さん!</p>
      </div>

      {stats && (
        <div className="stats-grid">
          <div className="stat-card">
            <h3>総タスク数</h3>
            <p className="stat-number">{stats.total}</p>
          </div>
          <div className="stat-card">
            <h3>完了済み</h3>
            <p className="stat-number">{stats.completed}</p>
          </div>
          <div className="stat-card">
            <h3>未完了</h3>
            <p className="stat-number">{stats.pending}</p>
          </div>
          <div className="stat-card">
            <h3>達成率</h3>
            <p className="stat-number">{stats.completionRate}%</p>
          </div>
        </div>
      )}

      <div className="dashboard-actions">
        <Link to="/todos" className="action-button primary">
          TODOを管理する
        </Link>
        <button className="action-button secondary">
          統計を見る
        </button>
      </div>

      {stats?.recentActivity && stats.recentActivity.length > 0 && (
        <div className="recent-activity">
          <h2>最近の活動</h2>
          <ul>
            {stats.recentActivity.map(activity => (
              <li key={activity.id} className="activity-item">
                <span className="activity-title">{activity.title}</span>
                <span className={`activity-status ${activity.completed ? 'completed' : 'pending'}`}>
                  {activity.completed ? '完了' : '未完了'}
                </span>
                <span className="activity-time">
                  {new Date(activity.updatedAt).toLocaleDateString()}
                </span>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
};

export default Dashboard;

3. 高度なTODOリストコンポーネント

// frontend/src/components/TodoList.js
import React, { useState } from 'react';
import { useTodos } from '../hooks/useTodos';

const TodoList = () => {
  const { todos, loading, error, addTodo, toggleTodo, removeTodo, updateTodo } = useTodos();
  const [newTodoTitle, setNewTodoTitle] = useState('');
  const [filter, setFilter] = useState('all'); // all, active, completed
  const [editingId, setEditingId] = useState(null);
  const [editTitle, setEditTitle] = useState('');

  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true;
  });

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!newTodoTitle.trim()) return;

    try {
      await addTodo(newTodoTitle);
      setNewTodoTitle('');
    } catch (err) {
      // エラー処理はuseTodos内で行われます
    }
  };

  const handleEdit = (todo) => {
    setEditingId(todo.id);
    setEditTitle(todo.title);
  };

  const handleSaveEdit = async (id) => {
    if (!editTitle.trim()) {
      setEditingId(null);
      return;
    }

    try {
      await updateTodo(id, { title: editTitle });
      setEditingId(null);
    } catch (err) {
      console.error('編集の保存に失敗しました:', err);
    }
  };

  const handleCancelEdit = () => {
    setEditingId(null);
    setEditTitle('');
  };

  const handleToggleAll = async () => {
    const allCompleted = todos.every(todo => todo.completed);
    const updates = todos.map(todo => 
      toggleTodo(todo.id, !allCompleted)
    );
    await Promise.all(updates);
  };

  if (loading) {
    return <div className="loading">読み込み中...</div>;
  }

  if (error) {
    return <div className="error">エラー: {error}</div>;
  }

  return (
    <div className="todo-container">
      <div className="todo-header">
        <h1>TODO管理</h1>
        <div className="todo-controls">
          <button 
            onClick={handleToggleAll}
            className="toggle-all-btn"
            title="すべてのTODOをトグル"
          >
            ⬌
          </button>
          <div className="filter-buttons">
            <button 
              className={filter === 'all' ? 'active' : ''}
              onClick={() => setFilter('all')}
            >
              すべて
            </button>
            <button 
              className={filter === 'active' ? 'active' : ''}
              onClick={() => setFilter('active')}
            >
              未完了
            </button>
            <button 
              className={filter === 'completed' ? 'active' : ''}
              onClick={() => setFilter('completed')}
            >
              完了
            </button>
          </div>
        </div>
      </div>

      <form onSubmit={handleSubmit} className="todo-form">
        <input
          type="text"
          value={newTodoTitle}
          onChange={(e) => setNewTodoTitle(e.target.value)}
          placeholder="新しいTODOを入力..."
          className="todo-input"
        />
        <button type="submit" className="add-button">
          追加
        </button>
      </form>

      <div className="todo-stats">
        <span>表示中: {filteredTodos.length} / 全{todos.length}件</span>
        <span>完了: {todos.filter(t => t.completed).length}件</span>
      </div>

      <ul className="todo-list">
        {filteredTodos.map(todo => (
          <li key={todo.id} className={`todo-item ${todo.completed ? 'completed' : ''}`}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id, todo.completed)}
              className="todo-checkbox"
            />

            {editingId === todo.id ? (
              <div className="edit-form">
                <input
                  type="text"
                  value={editTitle}
                  onChange={(e) => setEditTitle(e.target.value)}
                  className="edit-input"
                  autoFocus
                />
                <button
                  onClick={() => handleSaveEdit(todo.id)}
                  className="save-btn"
                >
                  保存
                </button>
                <button
                  onClick={handleCancelEdit}
                  className="cancel-btn"
                >
                  取消
                </button>
              </div>
            ) : (
              <>
                <span 
                  className="todo-title"
                  onDoubleClick={() => handleEdit(todo)}
                >
                  {todo.title}
                </span>
                <div className="todo-actions">
                  <button
                    onClick={() => handleEdit(todo)}
                    className="edit-button"
                    title="編集"
                  >
                    ✏️
                  </button>
                  <button
                    onClick={() => removeTodo(todo.id)}
                    className="delete-button"
                    title="削除"
                  >
                    🗑️
                  </button>
                </div>
              </>
            )}
          </li>
        ))}
      </ul>

      {filteredTodos.length === 0 && (
        <div className="empty-state">
          {filter === 'all' ? 'TODOがありません' :
           filter === 'active' ? '未完了のTODOはありません' :
           '完了したTODOはありません'}
        </div>
      )}
    </div>
  );
};

export default TodoList;

データベースマイグレーションの実行

# バックエンドディレクトリで実行
npx prisma migrate dev --name init
npx prisma generate

アプリケーションの起動

バックエンドの起動

cd backend
npm install
npm run dev

フロントエンドの起動

cd frontend
npm install
npm start

完成したアプリケーションの機能

  1. ユーザー認証: 登録、ログイン、JWTトークン管理
  2. TODO管理: 作成、読み取り、更新、削除(CRUD操作)
  3. データ永続化: MySQLデータベースとの連携
  4. リアルタイムUI: Reactによる応答性の高いインターフェース
  5. エラーハンドリング: 適切なエラー表示とユーザーフレンドリーなメッセージ
  6. セキュリティ: CORS設定、ヘルメット、レート制限
  7. パフォーマンス: Prismaによる最適化されたデータベースクエリ

まとめ

これで、完全なフルスタックTODOアプリケーションが完成しました。このアプリケーションには、現代のWeb開発に必要なすべての要素が含まれています:

  • フロントエンド: React、カスタムフック、Context API
  • バックエンド: Express.js、ルーティング、ミドルウェア
  • データベース: MySQL、Prisma ORM、マイグレーション
  • 認証: JWT、パスワードハッシュ化
  • セキュリティ: CORS、ヘルメット、レート制限
  • 開発体験: 環境変数、ロギング、エラーハンドリング

このアプリケーションを基盤として、さらに機能を拡張したり、デプロイを学んだり、より高度な概念を追加していくことができます。お疲れ様でした!