実践プロジェクト レッスン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 を向上