<CodeLearn/>
実践プロジェクト レッスン2

フルスタックTODO

Next.js + Prisma + PostgreSQL で本格的なTODOアプリを作ろう

プロジェクト概要

フロントエンドとバックエンドを統合したフルスタックTODOアプリを作ります。 データベースにタスクを保存し、CRUD操作を実装します。

技術スタック

  • Next.js(App Router)
  • Prisma ORM
  • PostgreSQL
  • Tailwind CSS

実装する機能

  • タスクの追加・編集・削除
  • 完了/未完了の切り替え
  • フィルタリング(全部/未完了/完了)
  • Server Actions でのデータ操作

ステップ1: データベース設計(Prisma)

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

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

model Todo {
  id        String   @id @default(cuid())
  title     String
  completed Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

// マイグレーション実行
// npx prisma migrate dev --name init
// npx prisma generate
// lib/prisma.ts - Prismaクライアントのシングルトン
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma =
  globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

ステップ2: Server Actions でCRUD

// app/actions/todo.ts
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

// タスク追加
export async function addTodo(formData: FormData) {
  const title = formData.get("title") as string;
  if (!title?.trim()) return;

  await prisma.todo.create({
    data: { title: title.trim() },
  });

  revalidatePath("/todos");
}

// 完了/未完了の切り替え
export async function toggleTodo(id: string) {
  const todo = await prisma.todo.findUnique({ where: { id } });
  if (!todo) return;

  await prisma.todo.update({
    where: { id },
    data: { completed: !todo.completed },
  });

  revalidatePath("/todos");
}

// タスク削除
export async function deleteTodo(id: string) {
  await prisma.todo.delete({ where: { id } });
  revalidatePath("/todos");
}

// タスク編集
export async function updateTodo(id: string, formData: FormData) {
  const title = formData.get("title") as string;
  if (!title?.trim()) return;

  await prisma.todo.update({
    where: { id },
    data: { title: title.trim() },
  });

  revalidatePath("/todos");
}

ステップ3: UIコンポーネントの実装

// app/todos/page.tsx - メインページ(Server Component)
import { prisma } from "@/lib/prisma";
import { addTodo } from "@/app/actions/todo";
import { TodoItem } from "./todo-item";

export default async function TodosPage() {
  const todos = await prisma.todo.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <div className="max-w-lg mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">TODO リスト</h1>

      {/* タスク追加フォーム */}
      <form action={addTodo} className="flex gap-2 mb-6">
        <input
          type="text"
          name="title"
          placeholder="新しいタスクを入力..."
          className="flex-1 px-4 py-2 rounded-lg border"
          required
        />
        <button
          type="submit"
          className="px-4 py-2 bg-indigo-500 text-white rounded-lg"
        >
          追加
        </button>
      </form>

      {/* タスク一覧 */}
      <div className="space-y-2">
        {todos.map((todo) => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
      </div>

      <p className="text-sm text-gray-500 mt-4">
        {todos.filter(t => !t.completed).length} 件の未完了タスク
      </p>
    </div>
  );
}
// app/todos/todo-item.tsx - 個別タスク(Client Component)
"use client";

import { toggleTodo, deleteTodo } from "@/app/actions/todo";

export function TodoItem({ todo }) {
  return (
    <div className="flex items-center gap-3 p-3 rounded-lg border">
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => toggleTodo(todo.id)}
        className="w-5 h-5"
      />
      <span className={todo.completed ? "line-through text-gray-400 flex-1" : "flex-1"}>
        {todo.title}
      </span>
      <button
        onClick={() => deleteTodo(todo.id)}
        className="text-red-400 hover:text-red-300 text-sm"
      >
        削除
      </button>
    </div>
  );
}

ステップ4: フィルタリング機能

// URL パラメータでフィルタリング
// /todos?filter=all | active | completed

import { prisma } from "@/lib/prisma";

export default async function TodosPage({ searchParams }) {
  const filter = searchParams.filter || "all";

  const where = filter === "active"
    ? { completed: false }
    : filter === "completed"
    ? { completed: true }
    : {};

  const todos = await prisma.todo.findMany({
    where,
    orderBy: { createdAt: "desc" },
  });

  return (
    <div>
      {/* フィルタータブ */}
      <div className="flex gap-2 mb-4">
        <FilterLink href="/todos?filter=all" active={filter === "all"}>
          すべて
        </FilterLink>
        <FilterLink href="/todos?filter=active" active={filter === "active"}>
          未完了
        </FilterLink>
        <FilterLink href="/todos?filter=completed" active={filter === "completed"}>
          完了済み
        </FilterLink>
      </div>

      {/* タスク一覧 */}
      {todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
    </div>
  );
}

まとめ

  • Prisma でデータベーススキーマを定義し、マイグレーションで反映
  • Server Actions で安全にサーバー側のデータ操作を実行
  • Server Component でデータ取得、Client Component でインタラクション
  • revalidatePath で画面の再更新を実現
  • URL パラメータを使ったフィルタリングで UX を向上