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設計が実現できます。

次に読むとよい記事(続編予定)