0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Next.js入門】Server/Client Componentsを使い分けたTODOアプリを作ろう

Last updated at Posted at 2025-11-05

はじめに

こんにちは!この記事では、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/ディレクトリを使います。

主要な特徴

  1. Server Components by default: デフォルトでServer Component(サーバー側でレンダリング)
  2. Layout システム: 複数ページで共有するレイアウトを効率的に定義
  3. 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で全ページ共通のレイアウトを定義します。

src/app/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の重要なポイント

  1. Metadataのエクスポート: SEOに必要なメタ情報を型安全に定義
  2. グローバルスタイルのインポート: theme.cssとglobals.cssを読み込み
  3. HTML構造の完全制御: <html><body>タグを明示的に記述

従来のpages routerでは_app.tsx_document.tsxに分かれていた機能が、App Routerではlayout.tsxに統合されています。

page.tsx - ページコンポーネント

page.tsxがルート(/)のページとしてレンダリングされます。

src/app/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.tsxsearchParamsを渡すことで、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
src/features/todos/TodoApp/TodoApp.tsx
// '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: インタラクティブな要素
src/features/todos/components/TaskForm/TaskForm.tsx
'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化が必要な場合

  • useStateuseEffect等のReact Hooksを使用
  • onClickonChange等のイベントハンドラを設定
  • useRouteruseSearchParams等の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のメリット

  1. API Routesが不要: /api/tasksのようなエンドポイントを作る必要がない
  2. 型安全性: 同じTypeScript型をサーバーとクライアントで共有
  3. Progressive Enhancement: JavaScriptが無効でもフォーム送信が動作
  4. Revalidation: revalidatePath()で特定ページの再検証が可能

actions.ts - Server Actions実装

src/features/todos/actions.ts
'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を呼び出す

src/features/todos/components/TaskItem/TaskItem.tsx
'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コンポーネント

src/features/todos/components/FilterButtons/FilterButtons.tsx
'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のメリット

  1. 共有可能: URLをコピーするだけで同じ状態を共有できる
  2. ブラウザ履歴: 戻る/進むボタンで状態が復元される
  3. サーバー側フィルタリング: 初期ロード時から正しいフィルタが適用される
  4. 状態管理ライブラリ不要: 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で閲覧・編集できるツール

データベーススキーマ定義

prisma/schema.prisma
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をシングルトンパターンで実装します。

src/lib/db.ts
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状態管理の組み込みサポート
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?