Next.js + TypeScriptで作るTODOアプリの設計思想
〜App Router時代の小規模アプリ設計を例に〜
はじめに
Next.js 13以降のApp Routerは、UIとデータ処理をサーバーサイドで密接に連携できる構造を提供しています。
今回は最もシンプルで普遍的な題材、TODOアプリを例に、Next.js + TypeScriptによるモダンな設計方法を紹介します。
ディレクトリ構成
App Routerでは、ディレクトリ構造がそのままルーティング構造になります。
小規模アプリであれば、以下のような構成が分かりやすく保守もしやすいです。
src/
app/
layout.tsx
page.tsx
todos/
page.tsx
new/
page.tsx
components/
TodoItem.tsx
TodoForm.tsx
server/
actions/
todoActions.ts
lib/
db.ts
validators/
todoSchema.ts
TypeScript + Zodで型安全なTODO定義
// src/lib/validators/todoSchema.ts
import { z } from 'zod';
export const todoSchema = z.object({
id: z.string().uuid().optional(),
title: z.string().min(1, 'タイトルを入力してください'),
completed: z.boolean().default(false),
});
export type Todo = z.infer<typeof todoSchema>;
Zodを使うことで、型定義とバリデーションを1箇所で統一できます。
UIとサーバー間の型のズレを防ぎ、開発効率も上がります。
Server ActionsでCRUDを実装する
App Routerでは、API Routesを作らずにサーバー関数を直接呼び出せます。
// src/server/actions/todoActions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { todoSchema, type Todo } from '@/lib/validators/todoSchema';
let todos: Todo[] = []; // 仮のDB
export async function addTodo(data: unknown) {
const result = todoSchema.pick({ title: true }).safeParse(data);
if (!result.success) throw new Error('Invalid data');
const newTodo: Todo = { id: crypto.randomUUID(), title: result.data.title, completed: false };
todos.push(newTodo);
revalidatePath('/todos');
}
export async function toggleTodo(id: string) {
todos = todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
);
revalidatePath('/todos');
}
export async function getTodos() {
return todos;
}
Server Actionsは型付きの非同期関数として実装でき、呼び出し元からは普通の関数のように扱えます。
クライアントコンポーネントで登録フォームを実装
// src/components/TodoForm.tsx
'use client';
import { addTodo } from '@/server/actions/todoActions';
import { useState } from 'react';
export default function TodoForm() {
const [title, setTitle] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
await addTodo({ title });
setTitle('');
}
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="やることを入力"
className="border px-2 py-1 rounded w-full"
/>
<button type="submit" className="bg-blue-600 text-white px-3 py-1 rounded">
追加
</button>
</form>
);
}
Server Actionを直接呼び出せるため、fetchやAPI URLを記述する必要がありません。
クライアントから関数呼び出しだけで完結します。
サーバーコンポーネントで一覧を描画
// src/app/todos/page.tsx
import TodoForm from '@/components/TodoForm';
import { getTodos, toggleTodo } from '@/server/actions/todoActions';
export default async function TodoPage() {
const todos = await getTodos();
return (
<div className="max-w-md mx-auto p-4">
<h1 className="text-xl font-bold mb-4">TODO一覧</h1>
<TodoForm />
<ul className="mt-4 space-y-2">
{todos.map((todo) => (
<li key={todo.id} className="flex items-center gap-2">
<form action={toggleTodo.bind(null, todo.id)} className="flex items-center gap-2">
<input type="checkbox" defaultChecked={todo.completed} />
<button type="submit" className="underline">
{todo.completed ? '元に戻す' : '完了'}
</button>
</form>
<span className={todo.completed ? 'line-through text-gray-500' : ''}>
{todo.title}
</span>
</li>
))}
</ul>
</div>
);
}
サーバーコンポーネントではgetTodosを直接呼び出してDBアクセス可能です。
さらに、form actionにServer Actionを渡せば、自動でサーバー側処理が実行されます。
まとめ
Next.js App Routerでは、フロントとバックの境界が極端に薄くなり、
API経由のfetchコードをほぼ書かずにアプリを構築できます。
TypeScriptとZodを組み合わせることで、
型安全・冗長性のないシンプルなCRUD設計が実現できます。
次に読むとよい記事(続編予定)
- Next.js Server Actionsでフォーム送信を型安全に処理する方法
- Zod × React Hook Formでリアルタイムバリデーションを実装する
- Suspenseと
useを使ったデータフェッチの最適化