はじめに
こんにちは!この記事では、Next.js 14のApp Routerを使ってTODOアプリを作成する方法を解説します。
近年、Next.jsはReactのフルスタックフレームワークとして広く採用されており、特にApp Router(v13以降)により、Server ComponentsとClient Componentsを使い分けた効率的な開発が可能になりました。本記事では、Prismaによるデータベース統合、Server Actionsによる状態管理、そしてStorybook統合によるコンポーネント駆動開発を学びます。
記事の目的・対象読者
目的
- Next.js App Routerの基本的な使い方を理解する
- Server ComponentsとClient Componentsの使い分けを学ぶ
- Prisma ORMによるデータベース統合を習得する
- Server Actionsを使った状態管理パターンを理解する
- URL-based State Managementの実装方法を学ぶ
- Storybook 7を使ったコンポーネント開発手法を習得する
- CSS Design Tokensによるデザインシステムの実装を理解する
対象読者
- React Hooksの基本を理解している方
- Next.jsに興味があるが実践経験が少ない方
- フルスタックアプリケーション開発に興味がある方
- コンポーネント駆動開発のベストプラクティスを学びたい方
Note: 基本的なReact Hooksやコンポーネント設計、共有UIコンポーネント(Button、Input、Checkbox)の実装については、「【React入門】HooksでシンプルなTODOアプリを作ろう」の記事をご参照ください。本記事では、Next.js固有の機能(Prisma、Server Actions、Server Components)に焦点を当てます。
ソースコード
Next.jsとReactの違い
ReactはUIを構築するためのライブラリであり、アプリ全体の構造(ルーティング・データ取得・SSRなど)は開発者が自由に選択する必要があります。一方、Next.jsはReactをベースに、 アプリケーション開発に必要な機能を統合したフレームワーク として設計されています。つまり、Reactが「部品を作るための道具」であるのに対し、Next.jsは「完成品を組み上げるための設計図と工場」を提供します。
| 項目 | React (Vite) | Next.js 14 |
|---|---|---|
| レンダリング | Client-side Only | Server/Client Hybrid |
| ルーティング | React Router等が必要 | ファイルベースルーティング標準搭載 |
| 最適化 | 手動設定が必要 | 自動最適化(画像、フォント、Bundle等) |
| SEO | CSRのため弱い | SSR/SSG対応で強い |
| ビルド | Vite等の設定必要 | Next.jsに統合済み |
| 開発環境 | 別途設定 | Fast Refresh標準搭載 |
今回のTodoアプリはSEOの重要性は低いですが、Next.jsの学習と、将来的な機能拡張(サーバーサイドデータフェッチ等)を見越して選択しています。
Next.js App Routerの基本
App Routerとは?
Next.js 13から導入された新しいルーティングシステムです。pages/ディレクトリの代わりにapp/ディレクトリを使います。
主要な特徴
- Server Components by default: デフォルトでServer Component(サーバー側でレンダリング)
- Layout システム: 複数ページで共有するレイアウトを効率的に定義
- Colocation: ルーティングとコンポーネントを同じディレクトリに配置可能
ディレクトリ構造(App Router)
プロジェクトルート/
├── prisma/ # Prismaデータベース設定
│ ├── schema.prisma # データベーススキーマ定義
│ ├── migrations/ # マイグレーションファイル
│ └── dev.db # 開発用SQLiteデータベース
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── layout.tsx # ルートレイアウト(全ページ共通)
│ │ ├── page.tsx # ホームページ(/)
│ │ ├── globals.css # グローバルスタイル
│ │ └── page.module.css # ページ固有スタイル
│ ├── components/ # 共有UI要素(Atoms)
│ │ ├── Button/
│ │ ├── Input/
│ │ └── Checkbox/
│ ├── features/ # 機能別モジュール
│ │ └── todos/
│ │ ├── components/ # Feature固有コンポーネント
│ │ ├── TodoApp/ # メインコンテナ(Server Component)
│ │ ├── actions.ts # Server Actions('use server')
│ │ └── types.ts # 型定義
│ ├── lib/
│ │ └── db.ts # Prisma Client Singleton
│ ├── generated/ # Prisma自動生成ファイル
│ │ └── prisma/
│ ├── types/ # グローバル型定義
│ ├── stories/ # Storybookストーリー
│ └── theme.css # デザインシステム(CSS変数)
├── .storybook/ # Storybook設定
│ ├── main.ts
│ └── preview.ts
├── next.config.js
├── prisma.config.ts
├── tsconfig.json
└── package.json
主要な追加ディレクトリ
| ディレクトリ | 役割 |
|---|---|
| prisma/ | データベーススキーマ定義とマイグレーション管理 |
| src/lib/ | Prisma Clientなどの共有ライブラリ |
| src/generated/prisma/ | Prisma型定義の自動生成先(手動編集禁止) |
| src/features/todos/actions.ts | Server Actions(データベース操作) |
layout.tsx - ルートレイアウト
App Routerでは、layout.tsxで全ページ共通のレイアウトを定義します。
import type { Metadata } from 'next';
import '../theme.css';
import './globals.css';
export const metadata: Metadata = {
title: 'To-Doリスト',
description: 'Next.js + TypeScript + Storybookで構築されたTodoアプリケーション',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>{children}</body>
</html>
);
}
Layoutの重要なポイント
- Metadataのエクスポート: SEOに必要なメタ情報を型安全に定義
- グローバルスタイルのインポート: theme.cssとglobals.cssを読み込み
-
HTML構造の完全制御:
<html>と<body>タグを明示的に記述
従来のpages routerでは_app.tsxと_document.tsxに分かれていた機能が、App Routerではlayout.tsxに統合されています。
page.tsx - ページコンポーネント
page.tsxがルート(/)のページとしてレンダリングされます。
import { TodoApp } from '@/features/todos/TodoApp/TodoApp';
import styles from './page.module.css';
export default function Home({
searchParams,
}: {
searchParams?: { filter?: string };
}) {
return (
<main className={styles.main}>
<TodoApp searchParams={searchParams} />
</main>
);
}
Pageの特徴
-
searchParamsの受け取り: URL検索パラメータ(
?filter=active)を自動的に受け取る - Server Component: ビルド時またはリクエスト時にサーバー側でレンダリング
-
パスエイリアス
@/: tsconfig.jsonで@/をsrc/にマッピング
Next.js 13+では、page.tsxにsearchParamsを渡すことで、URL状態を簡単に管理できます。この仕組みにより、?filter=activeのようなクエリパラメータをコンポーネントで直接利用可能です。
Server Components vs Client Components
Reactではすべてのコンポーネントがブラウザ上で動作していましたが、Next.jsの登場により どこでレンダリングを行うか を選べるようになりました。Server Componentは サーバー側でHTMLを生成し、Client Componentはブラウザ上でインタラクションを担当します。この2つを適切に分けることで、アプリの描画速度を上げつつ、不要なJavaScriptを減らす ことができます。つまり「軽量で高速なUI」を実現する鍵が、このServer/Clientの分離設計なのです。
Next.js 13以降のApp Routerは、Server-First という考え方を中心に設計されています。これは、できる限りの処理をサーバーで完結させ、クライアントは本当に必要な部分だけを担当するという思想です。
TodoAppにおける実装
Server ComponentとClient Componentの分離
本アプリでは、データフェッチはServer Component、インタラクティブなUIはClient Componentに分けています。
Server Component: TodoApp
// 'use client'なし = Server Component
import { getTasks, getActiveTaskCount } from '../actions';
import { TaskForm } from '../components/TaskForm/TaskForm';
import { FilterButtons } from '../components/FilterButtons/FilterButtons';
import { TaskItem } from '../components/TaskItem/TaskItem';
import { TaskCounter } from '../components/TaskCounter/TaskCounter';
import type { FilterType } from '../types';
import styles from './TodoApp.module.css';
interface TodoAppProps {
searchParams?: { filter?: string };
}
export async function TodoApp({ searchParams }: TodoAppProps) {
const filter = (searchParams?.filter || 'all') as FilterType;
// サーバー側でデータフェッチ
const allTasks = await getTasks();
const filteredTasks = allTasks.filter(task => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true;
});
const activeCount = await getActiveTaskCount();
return (
<div className={styles.container}>
<h1 className={styles.title}>To-Doリスト</h1>
<TaskForm />
<FilterButtons />
<ul className={styles.taskList}>
{filteredTasks.length === 0 ? (
<li className={styles.emptyMessage}>タスクがありません</li>
) : (
filteredTasks.map(task => (
<TaskItem key={task.id} task={task} />
))
)}
</ul>
<TaskCounter activeCount={activeCount} />
</div>
);
}
Server Componentの利点
- データベースに直接アクセス可能
- シークレット情報をクライアントに送信しない
- 初期ロードが高速(HTMLとして事前レンダリング)
- SEOに有利
Client Components: インタラクティブな要素
'use client'; // ← useStateを使うためClient Component
import { useState } from 'react';
import { addTask } from '../../actions'; // Server Actionをインポート
import { Button } from '@/components/Button/Button';
import { Input } from '@/components/Input/Input';
export function TaskForm() {
const [taskText, setTaskText] = useState('');
const handleSubmit = async () => {
if (taskText.trim()) {
await addTask(taskText.trim()); // Server Action呼び出し
setTaskText('');
}
};
return (
<div>
<Input value={taskText} onChange={setTaskText} onKeyPress={(key) => {
if (key === 'Enter') handleSubmit();
}} />
<Button onClick={handleSubmit}>追加</Button>
</div>
);
}
Client Component化が必要な場合
-
useState、useEffect等のReact Hooksを使用 -
onClick、onChange等のイベントハンドラを設定 -
useRouter、useSearchParams等のNext.js Client Hooksを使用 - ブラウザAPI(localStorage、window等)にアクセス
Component階層とレンダリング場所
page.tsx (Server Component)
↓ searchParamsを渡す
TodoApp (async Server Component) ← サーバーでデータフェッチ
├─ TaskForm (Client Component) ← ユーザー入力を処理
├─ FilterButtons (Client Component) ← URL操作
├─ TaskItem (Client Component) ← チェックボックス操作
└─ TaskCounter (Client Component) ← ボタンクリック処理
- page.tsx → TodoApp: サーバー側で実行(データフェッチ)
- TodoApp以下の各コンポーネント: クライアント側で実行(インタラクティブ機能)
- TodoAppがServer Componentでも、子コンポーネントは個別にClient Componentとして宣言可能
Server Actionsによる状態管理
Next.js 14では、Server Actionsを使ってサーバー側でデータ操作を行います。これにより、API Routesを作成せずに、直接データベース操作をコンポーネントから呼び出せます。
Server Actionsとは?
Server Actionsは、サーバー側で実行される非同期関数です。'use server'ディレクティブでマークされます。
Server Actionsのメリット
-
API Routesが不要:
/api/tasksのようなエンドポイントを作る必要がない - 型安全性: 同じTypeScript型をサーバーとクライアントで共有
- Progressive Enhancement: JavaScriptが無効でもフォーム送信が動作
-
Revalidation:
revalidatePath()で特定ページの再検証が可能
actions.ts - Server Actions実装
'use server'; // ← この宣言ですべての関数がServer Actionになる
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/db';
// タスク一覧取得
export async function getTasks() {
const tasks = await prisma.task.findMany({
orderBy: { createdAt: 'asc' }, // 作成日時の昇順
});
return tasks;
}
// タスク追加
export async function addTask(text: string) {
await prisma.task.create({
data: { text },
});
revalidatePath('/'); // ホームページのキャッシュを再検証
}
// タスクの完了/未完了を切り替え
export async function toggleTask(id: number) {
const task = await prisma.task.findUnique({ where: { id } });
if (task) {
await prisma.task.update({
where: { id },
data: { completed: !task.completed },
});
revalidatePath('/');
}
}
// タスク削除
export async function deleteTask(id: number) {
await prisma.task.delete({ where: { id } });
revalidatePath('/');
}
// 完了済みタスクを一括削除
export async function clearCompleted() {
await prisma.task.deleteMany({
where: { completed: true },
});
revalidatePath('/');
}
// 未完了タスク数を取得
export async function getActiveTaskCount() {
const count = await prisma.task.count({
where: { completed: false },
});
return count;
}
revalidatePath('/')を呼び出すことで、指定したパスのServer Componentを再レンダリングします。
// データ更新後にキャッシュをクリア
await prisma.task.create({ data: { text } });
revalidatePath('/'); // ← TodoAppコンポーネントが再実行される
Server ActionsとAPI Routesの違い
従来のNext.jsでは、データ操作を行う際に /app/api/ 配下にAPI Routesを定義し、fetch() で呼び出すのが一般的でした。しかし、API Routesでは サーバーで定義 → クライアントからリクエスト → JSONを受け取る という手順が必要で、コード量も増えがちでした。
Next.js 14のServer Actionsは、これらのステップをすべて統合し、 サーバー関数をそのままクライアントから呼び出す という自然な書き方を可能にします。これにより、データフローがシンプルになり、型定義の重複やエンドポイント管理の手間が大幅に減ります。
| 項目 | Server Actions | API Routes |
|---|---|---|
| 定義場所 | コンポーネントファイル内 |
/app/api/* ディレクトリ |
| 呼び出し方 | 普通の関数として呼び出し | fetch('/api/tasks') |
| 型安全性 | 完全に型安全 | 手動で型定義が必要 |
| フォーム統合 | <form action={serverAction}> |
JavaScriptが必須 |
| 用途 | コンポーネントからの直接データ操作 | 外部APIとしての公開 |
本アプリでは、シンプルさと型安全性を優先してServer Actionsを採用しています。
Client ComponentからServer Actionを呼び出す
'use client';
import { toggleTask, deleteTask } from '../../actions';
import { Checkbox } from '@/components/Checkbox/Checkbox';
import { Button } from '@/components/Button/Button';
import type { Task } from '../../types';
interface TaskItemProps {
task: Task;
}
export function TaskItem({ task }: TaskItemProps) {
return (
<li>
<Checkbox
checked={task.completed}
onChange={() => toggleTask(task.id)} // ← Server Action呼び出し
/>
<span>{task.text}</span>
<Button
onClick={() => deleteTask(task.id)} // ← Server Action呼び出し
variant="danger"
>
削除
</Button>
</li>
);
}
Client Componentから普通の関数のようにServer Actionを呼び出すだけで、サーバー側でデータベース操作が実行されます。
URL-Based State Management
フィルタ状態(すべて/未完了/完了済み)は、URLのクエリパラメータで管理します。これにより、URLを共有するだけで同じフィルタ状態を再現できます。
FilterButtonsコンポーネント
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/Button/Button';
import type { FilterType } from '../../types';
import styles from './FilterButtons.module.css';
export function FilterButtons() {
const router = useRouter();
const searchParams = useSearchParams();
const currentFilter = (searchParams.get('filter') || 'all') as FilterType;
const handleFilterChange = (filter: FilterType) => {
if (filter === 'all') {
router.push('/'); // 'all'の場合はクエリパラメータなし
} else {
router.push(`/?filter=${filter}`); // 'active'または'completed'
}
};
const filters: { key: FilterType; label: string }[] = [
{ key: 'all', label: 'すべて' },
{ key: 'active', label: '未完了' },
{ key: 'completed', label: '完了済み' },
];
return (
<div className={styles.filterButtons}>
{filters.map(({ key, label }) => (
<Button
key={key}
onClick={() => handleFilterChange(key)}
variant={currentFilter === key ? 'primary' : 'secondary'}
>
{label}
</Button>
))}
</div>
);
}
URL-Based State Managementの流れ
URL-Based State Managementのメリット
- 共有可能: URLをコピーするだけで同じ状態を共有できる
- ブラウザ履歴: 戻る/進むボタンで状態が復元される
- サーバー側フィルタリング: 初期ロード時から正しいフィルタが適用される
- 状態管理ライブラリ不要: ReduxやZustand等が不要
Prismaによるデータベース統合
本アプリケーションは、Prisma ORMを使用してデータベースとの連携を行います。localStorageではなく、実際のデータベース(開発環境ではSQLite、本番環境ではPostgreSQL等)にデータを永続化します。
Prismaとは?
Prismaは、Node.js/TypeScript向けの次世代ORMです。
Prismaの主な特徴
| 特徴 | 説明 |
|---|---|
| 型安全性 | データベーススキーマから自動的にTypeScript型を生成 |
| 自動補完 | IDEでクエリメソッドの自動補完が効く |
| マイグレーション | スキーマ変更を安全にデータベースに反映 |
| 複数DB対応 | SQLite、PostgreSQL、MySQL、SQL Server、MongoDB等に対応 |
| Prisma Studio | データベースをGUIで閲覧・編集できるツール |
データベーススキーマ定義
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite" // 開発環境:SQLite、本番環境:PostgreSQL等
url = env("DATABASE_URL")
}
model Task {
id Int @id @default(autoincrement())
text String
completed Boolean @default(false)
createdAt DateTime @default(now())
}
スキーマの説明
-
generator client: Prisma Clientの生成先を
src/generated/prismaに指定 -
datasource db: 開発環境ではSQLite、本番環境では環境変数
DATABASE_URLで切り替え可能 -
model Task: タスクテーブルの定義
-
id: 自動採番される主キー -
text: タスクの内容 -
completed: 完了状態(デフォルト:false) -
createdAt: 作成日時(デフォルト:現在時刻)
-
Prisma Client Singleton
開発環境でHot Reloadによる複数インスタンス生成を防ぐため、Prisma Clientをシングルトンパターンで実装します。
import { PrismaClient } from '@/generated/prisma/client';
// グローバル変数として定義(開発環境でのホットリロード対策)
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ['query', 'error', 'warn'], // クエリログを出力
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
なぜSingletonパターンが必要?
Next.jsの開発モードでは、ファイル変更時にモジュールが再読み込みされます。その度にnew PrismaClient()が実行されると、データベース接続が増え続けてしまいます。globalThisを使うことで、ホットリロード時も同一インスタンスを再利用します。
Prismaコマンド
Prisma Studio(データベースGUI)
npx prisma studio
http://localhost:5555 でデータベースをブラウザから閲覧・編集できます。
マイグレーション(スキーマをDBに反映)
# 開発環境でマイグレーション作成
npx prisma migrate dev --name init
# 本番環境でマイグレーション適用
npx prisma migrate deploy
Prisma Client再生成
npx prisma generate
スキーマ変更後、TypeScript型を再生成します。
アプリケーションフロー図
タスク追加の完全なフロー
まとめ
本アプリケーションは、Next.jsの最新機能(Server Components、Server Actions)とPrismaを組み合わせた、2025年時点での推奨アーキテクチャを採用しています。従来のREST API + fetchパターンではなく、型安全な関数呼び出しでサーバーとクライアントを統合する新しいパラダイムを体験できます。
Next.js版で学んだこと
Next.js固有の機能
- App Router: layout.tsx/page.tsxによるファイルベースルーティング
- Server/Client Components: 'use client'ディレクティブの使い分け
-
Server Actions:
'use server'によるサーバーサイドデータ操作 - revalidatePath(): キャッシュ再検証によるリアルタイム更新
- searchParams: URL状態管理の組み込みサポート