React Server Components実践:次世代Reactアーキテクチャ

前端工程

React Server Componentsとは?なぜ重要なのか?

React Server Components(RSC)はReactチームが提案した全く新しいコンポーネントモデルであり、コンポーネントをサーバー側でレンダリングし、結果を直接クライアントにストリーミングすることで、Reactアプリケーションのアーキテクチャを根本的に変革します。

RSCが解決する核心的な問題

問題 従来のSPA SSR RSC
初画面JSサイズ 大(全量バンドル) 中(hydration必要) 小(クライアントコンポーネントのみ)
データ取得 useEffect + API getServerSideProps コンポーネント内でデータソースに直接アクセス
バンドルサイズ 全依存関係がバンドルに SPAと同じ サーバー依存関係はゼロバンドル
リクエストウォーターフォール 深刻(クライアント直列) 中(サーバー並列) サーバー並列 + ストリーミング

RSCの3つのコア優位性

  1. ゼロバンドルサイズ:サーバーコンポーネントの依存関係はクライアントバンドルに含まれない
  2. 直接データアクセス:コンポーネント内でデータベースやファイルシステムに直接読み書き、API層不要
  3. 自動コード分割:クライアントコンポーネントが自然にコード分割の境界になる

💡 JSONフォーマッターツールでNext.js設定ファイルをチェックし、App Routerの設定が正しいことを確認できます。


Serverコンポーネント vs Clientコンポーネント:完全比較

ServerとClientコンポーネントの違いを理解することは、RSCをマスターする第一歩です。

コアの違い

特徴 Server Component Client Component
レンダリング環境 サーバー クライアント(ブラウザ)
ファイル拡張子 .tsx(デフォルト) .tsx + "use client"
データ取得 DB/API/ファイルに直接アクセス useEffect / SWR / React Query
状態管理 useState/useReducerなし useState / useReducer / Context
副作用 useEffect / ライフサイクルなし useEffect / useLayoutEffect
依存関係のバンドル クライアントバンドルに含まれない クライアントバンドルに含まれる
DOMアクセス なし あり
イベント処理 onClick/onChangeなし あり
非同期サポート async/await サードパーティライブラリが必要

コンポーネント選択のデシジョンツリー

インタラクションが必要(onClick / useState / useEffect)?
├── はい → Client Component
└── いいえ → サーバーリソースへのアクセスが必要(DB / ファイルシステム / プライベートAPI)?
    ├── はい → Server Component
    └── いいえ → クライアントバンドルサイズを削減する必要がある?
        ├── はい → Server Component
        └── いいえ → Server Component(デフォルト選択)

実践例:Serverコンポーネント

// app/articles/page.tsx(Server Component、デフォルト)
import { db } from '@/lib/db';

interface Article {
  id: number;
  title: string;
  content: string;
  createdAt: Date;
}

export default async function ArticlesPage() {
  const articles: Article[] = await db.query(
    'SELECT id, title, content, created_at FROM articles ORDER BY created_at DESC LIMIT 20'
  );

  return (
    <main className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">最新記事</h1>
      <div className="space-y-4">
        {articles.map((article) => (
          <article key={article.id} className="border rounded-lg p-4">
            <h2 className="text-xl font-semibold">{article.title}</h2>
            <p className="text-gray-600 mt-2">{article.content}</p>
            <time className="text-sm text-gray-400">
              {article.createdAt.toLocaleDateString('ja-JP')}
            </time>
          </article>
        ))}
      </div>
    </main>
  );
}

実践例:Clientコンポーネント

// app/components/LikeButton.tsx
'use client';

import { useState, useTransition } from 'react';

interface LikeButtonProps {
  articleId: number;
  initialLikes: number;
}

export default function LikeButton({ articleId, initialLikes }: LikeButtonProps) {
  const [likes, setLikes] = useState(initialLikes);
  const [isPending, startTransition] = useTransition();

  const handleLike = async () => {
    startTransition(async () => {
      const res = await fetch(`/api/articles/${articleId}/like`, {
        method: 'POST',
      });
      const data = await res.json();
      setLikes(data.likes);
    });
  };

  return (
    <button
      onClick={handleLike}
      disabled={isPending}
      className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
    >
      {isPending ? '処理中...' : `いいね (${likes})`}
    </button>
  );
}

"use client"と"use server"ディレクティブ詳解

この2つのディレクティブはRSCアーキテクチャの境界マーカーであり、コードの実行環境を決定します。

"use client"ディレクティブ

// app/components/SearchBar.tsx
'use client';

import { useState } from 'react';

export default function SearchBar() {
  const [query, setQuery] = useState('');

  return (
    <div className="flex gap-2">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="記事を検索..."
        className="border rounded px-3 py-2 flex-1"
      />
      <button className="bg-blue-500 text-white px-4 py-2 rounded">
        検索
      </button>
    </div>
  );
}

"use server"ディレクティブ

// app/actions/article.ts
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function createArticle(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  if (!title || !content) {
    throw new Error('タイトルと内容は必須です');
  }

  await db.query(
    'INSERT INTO articles (title, content, created_at) VALUES ($1, $2, NOW())',
    [title, content]
  );

  revalidatePath('/articles');
}

export async function deleteArticle(id: number) {
  await db.query('DELETE FROM articles WHERE id = $1', [id]);
  revalidatePath('/articles');
}

ディレクティブ使用ルール

ルール 説明
"use client"はファイルの先頭に必須 全importの前に宣言
"use server"はファイルの先頭に必須 全importの前に宣言、エクスポート関数はasync必須
ServerコンポーネントはClientコンポーネントをインポート可能 しかしClientコンポーネントはServerコンポーネントをインポート不可
ClientコンポーネントはpropsでServerコンポーネントを受け取れる children propで渡す
"use client"は境界マーカー そのファイルと全依存関係をクライアントコードとしてマーク

コンポーネント構成パターン:ServerがClientをラップ

// app/articles/page.tsx(Server Component)
import { db } from '@/lib/db';
import LikeButton from '@/components/LikeButton';
import SearchBar from '@/components/SearchBar';

export default async function ArticlesPage() {
  const articles = await db.query('SELECT * FROM articles');

  return (
    <div>
      <SearchBar />
      {articles.map((article) => (
        <article key={article.id}>
          <h2>{article.title}</h2>
          <p>{article.content}</p>
          <LikeButton articleId={article.id} initialLikes={article.likes} />
        </article>
      ))}
    </div>
  );
}

データ取得パターン:useEffectに別れを

RSCの最大のパラダイムシフトはデータ取得をクライアントからサーバーに移動し、useEffectリクエストウォーターフォールを完全に排除することです。

従来パターン vs RSCパターン

// 従来:useEffectウォーターフォール
'use client';
import { useState, useEffect } from 'react';

export default function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  useEffect(() => {
    if (!user) return;
    fetch(`/api/users/${userId}/posts`)
      .then(res => res.json())
      .then(data => setPosts(data));
  }, [userId, user]);

  if (!user) return <div>読み込み中...</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      {posts.map(post => <p key={post.id}>{post.title}</p>)}
    </div>
  );
}
// RSC:サーバー側並列取得
import { db } from '@/lib/db';

export default async function UserProfile({ userId }: { userId: string }) {
  const [user, posts] = await Promise.all([
    db.query('SELECT * FROM users WHERE id = $1', [userId]),
    db.query('SELECT * FROM posts WHERE user_id = $1 ORDER BY created_at DESC', [userId]),
  ]);

  return (
    <div>
      <h1>{user.name}</h1>
      {posts.map(post => <p key={post.id}>{post.title}</p>)}
    </div>
  );
}

直接データベースアクセス

// app/dashboard/page.tsx
import { db } from '@/lib/db';

export default async function DashboardPage() {
  const [totalUsers, totalOrders, recentOrders] = await Promise.all([
    db.query('SELECT COUNT(*) as count FROM users'),
    db.query('SELECT COUNT(*) as count FROM orders WHERE created_at > NOW() - INTERVAL 30 DAY'),
    db.query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 10'),
  ]);

  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="bg-white rounded-lg shadow p-6">
        <h3 className="text-gray-500">総ユーザー数</h3>
        <p className="text-3xl font-bold">{totalUsers.count}</p>
      </div>
      <div className="bg-white rounded-lg shadow p-6">
        <h3 className="text-gray-500">過去30日間の注文</h3>
        <p className="text-3xl font-bold">{totalOrders.count}</p>
      </div>
      <div className="bg-white rounded-lg shadow p-6 col-span-3">
        <h3 className="text-gray-500 mb-4">最近の注文</h3>
        <table className="w-full">
          <thead>
            <tr>
              <th>注文番号</th>
              <th>金額</th>
              <th>ステータス</th>
            </tr>
          </thead>
          <tbody>
            {recentOrders.map(order => (
              <tr key={order.id}>
                <td>{order.orderNo}</td>
                <td>¥{order.amount}</td>
                <td>{order.status}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

ファイルシステムアクセス

// app/docs/page.tsx
import { readFile, readdir } from 'fs/promises';
import { join } from 'path';

export default async function DocsPage() {
  const docsDir = join(process.cwd(), 'content/docs');
  const files = await readdir(docsDir);
  const docs = await Promise.all(
    files
      .filter(f => f.endsWith('.md'))
      .map(async (f) => {
        const content = await readFile(join(docsDir, f), 'utf-8');
        return { filename: f, content };
      })
  );

  return (
    <div>
      {docs.map(doc => (
        <article key={doc.filename}>
          <h2>{doc.filename}</h2>
          <pre>{doc.content}</pre>
        </article>
      ))}
    </div>
  );
}

💡 JSON to TypeScriptツールでAPIレスポンスからTypeScript型定義を生成し、手動型定義の手間を削減できます。


StreamingとSuspense統合

RSCはReact Suspenseと深く統合されており、ストリーミングレンダリングを実現します。ページの各セクションが独立して読み込まれ、ユーザーは全データの待機が不要です。

基本的なSuspenseパターン

// app/page.tsx
import { Suspense } from 'react';

async function SlowSection() {
  const data = await fetch('https://api.example.com/slow', {
    next: { revalidate: 60 },
  }).then(res => res.json());

  return <div>{data.map(item => <p key={item.id}>{item.name}</p>)}</div>;
}

async function FastSection() {
  const data = await fetch('https://api.example.com/fast').then(res => res.json());
  return <div>{data.message}</div>;
}

export default function HomePage() {
  return (
    <div>
      <h1>ストリーミングレンダリング例</h1>
      <Suspense fallback={<div className="animate-pulse">高速セクション読み込み中...</div>}>
        <FastSection />
      </Suspense>
      <Suspense fallback={<div className="animate-pulse">低速セクション読み込み中...</div>}>
        <SlowSection />
      </Suspense>
    </div>
  );
}

ネストされたSuspense:プログレッシブローディング

// app/dashboard/page.tsx
import { Suspense } from 'react';

async function RevenueChart() {
  const revenue = await db.query('SELECT * FROM revenue_monthly ORDER BY month');
  return <div className="h-64 bg-gray-100 rounded">チャート: {revenue.length} データポイント</div>;
}

async function UserGrowthChart() {
  const growth = await db.query('SELECT * FROM user_growth ORDER BY month');
  return <div className="h-64 bg-gray-100 rounded">成長曲線: {growth.length} データポイント</div>;
}

async function RecentActivity() {
  const activities = await db.query('SELECT * FROM activities ORDER BY created_at DESC LIMIT 20');
  return (
    <ul>
      {activities.map(a => <li key={a.id}>{a.description}</li>)}
    </ul>
  );
}

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">ダッシュボード</h1>
      <div className="grid grid-cols-2 gap-4">
        <Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
          <RevenueChart />
        </Suspense>
        <Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
          <UserGrowthChart />
        </Suspense>
      </div>
      <Suspense fallback={<div className="space-y-2">{Array.from({length: 5}).map((_, i) => (
        <div key={i} className="h-8 animate-pulse bg-gray-200 rounded" />
      ))}</div>}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

loading.tsx:Next.js組み込みSuspense

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="space-y-6 animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-48" />
      <div className="grid grid-cols-2 gap-4">
        <div className="h-64 bg-gray-200 rounded" />
        <div className="h-64 bg-gray-200 rounded" />
      </div>
      <div className="space-y-2">
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="h-8 bg-gray-200 rounded" />
        ))}
      </div>
    </div>
  );
}

Next.js App RouterとRSC

Next.js App RouterはRSCのベストプラクティスフレームワークであり、完全なRSCサポートを提供します。

App Routerディレクトリ構造

app/
├── layout.tsx          # ルートレイアウト(Server Component)
├── page.tsx            # ホームページ(Server Component)
├── loading.tsx         # ホームページローディング状態
├── error.tsx           # ホームページエラー処理
├── not-found.tsx       # 404ページ
├── globals.css
├── articles/
│   ├── layout.tsx      # 記事レイアウト
│   ├── page.tsx        # 記事一覧
│   ├── loading.tsx
│   ├── [id]/
│   │   ├── page.tsx    # 記事詳細
│   │   └── loading.tsx
│   └── new/
│       └── page.tsx    # 新規記事
├── api/
│   └── health/
│       └── route.ts    # API Route
└── components/
    ├── Header.tsx
    ├── LikeButton.tsx  # Client Component
    └── SearchBar.tsx   # Client Component

ルートレイアウト

// app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: {
    template: '%s | ToolsKu ブログ',
    default: 'ToolsKu ブログ',
  },
  description: '技術記事とチュートリアル',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className="min-h-screen bg-gray-50">
        <nav className="bg-white shadow-sm border-b">
          <div className="max-w-6xl mx-auto px-4 py-3 flex justify-between items-center">
            <a href="/" className="text-xl font-bold text-blue-600">ToolsKu</a>
            <div className="flex gap-4">
              <a href="/articles" className="text-gray-600 hover:text-blue-600">記事</a>
              <a href="/about" className="text-gray-600 hover:text-blue-600">概要</a>
            </div>
          </div>
        </nav>
        <main className="max-w-6xl mx-auto px-4 py-8">
          {children}
        </main>
      </body>
    </html>
  );
}

動的ルート

// app/articles/[id]/page.tsx
import { db } from '@/lib/db';
import { notFound } from 'next/navigation';
import LikeButton from '@/components/LikeButton';

interface PageProps {
  params: Promise<{ id: string }>;
}

export default async function ArticleDetailPage({ params }: PageProps) {
  const { id } = await params;
  const article = await db.query(
    'SELECT * FROM articles WHERE id = $1',
    [id]
  );

  if (!article) {
    notFound();
  }

  return (
    <article className="max-w-3xl mx-auto">
      <h1 className="text-3xl font-bold mb-4">{article.title}</h1>
      <div className="prose lg:prose-xl" dangerouslySetInnerHTML={{ __html: article.htmlContent }} />
      <div className="mt-8">
        <LikeButton articleId={article.id} initialLikes={article.likes} />
      </div>
    </article>
  );
}

Server Actions:サーバー側ミューテーション

Server ActionsはRSCエコシステムでデータ変更を処理するコアメカニズムであり、従来のAPI Routeに代わるものです。

基本的なServer Action

// app/articles/new/page.tsx
import { createArticle } from '@/actions/article';

export default function NewArticlePage() {
  return (
    <form action={createArticle} className="max-w-2xl mx-auto space-y-4">
      <div>
        <label className="block text-sm font-medium mb-1">タイトル</label>
        <input
          name="title"
          type="text"
          required
          className="w-full border rounded px-3 py-2"
        />
      </div>
      <div>
        <label className="block text-sm font-medium mb-1">内容</label>
        <textarea
          name="content"
          required
          rows={10}
          className="w-full border rounded px-3 py-2"
        />
      </div>
      <button
        type="submit"
        className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
      >
       記事を公開
      </button>
    </form>
  );
}

useActionState:状態付きServer Action

// app/components/ArticleForm.tsx
'use client';

import { useActionState } from 'react';
import { createArticleWithState } from '@/actions/article';

const initialState = { message: '', errors: {} };

export default function ArticleForm() {
  const [state, formAction, isPending] = useActionState(
    createArticleWithState,
    initialState
  );

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <input name="title" type="text" placeholder="タイトル" className="w-full border rounded px-3 py-2" />
        {state.errors.title && <p className="text-red-500 text-sm">{state.errors.title}</p>}
      </div>
      <div>
        <textarea name="content" rows={10} placeholder="内容" className="w-full border rounded px-3 py-2" />
        {state.errors.content && <p className="text-red-500 text-sm">{state.errors.content}</p>}
      </div>
      <button
        type="submit"
        disabled={isPending}
        className="bg-blue-500 text-white px-6 py-2 rounded disabled:opacity-50"
      >
        {isPending ? '公開中...' : '記事を公開'}
      </button>
      {state.message && <p className="text-green-600">{state.message}</p>}
    </form>
  );
}
// actions/article.ts
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function createArticleWithState(prevState: any, formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  const errors: Record<string, string> = {};

  if (!title || title.trim().length < 2) {
    errors.title = 'タイトルは2文字以上必要です';
  }
  if (!content || content.trim().length < 10) {
    errors.content = '内容は10文字以上必要です';
  }

  if (Object.keys(errors).length > 0) {
    return { message: '', errors };
  }

  await db.query(
    'INSERT INTO articles (title, content, created_at) VALUES ($1, $2, NOW())',
    [title, content]
  );

  revalidatePath('/articles');
  return { message: '記事が公開されました!', errors: {} };
}

楽観的更新:useOptimistic

// app/components/OptimisticLikeButton.tsx
'use client';

import { useOptimistic } from 'react';
import { toggleLike } from '@/actions/article';

interface LikeButtonProps {
  articleId: number;
  initialLikes: number;
  isLiked: boolean;
}

export default function OptimisticLikeButton({ articleId, initialLikes, isLiked }: LikeButtonProps) {
  const [optimisticState, addOptimistic] = useOptimistic(
    { likes: initialLikes, isLiked },
    (state, newIsLiked: boolean) => ({
      likes: newIsLiked ? state.likes + 1 : state.likes - 1,
      isLiked: newIsLiked,
    })
  );

  const handleToggle = async () => {
    const newIsLiked = !optimisticState.isLiked;
    addOptimistic(newIsLiked);
    await toggleLike(articleId, newIsLiked);
  };

  return (
    <button
      onClick={handleToggle}
      className={`px-4 py-2 rounded ${optimisticState.isLiked ? 'bg-red-500 text-white' : 'bg-gray-200'}`}
    >
      {optimisticState.isLiked ? '❤️' : '🤍'} {optimisticState.likes}
    </button>
  );
}

キャッシュ戦略:revalidateとISR

RSCのキャッシュ戦略は、データの鮮度パフォーマンスのバランスを決定します。

キャッシュ戦略比較

戦略 関数 キャッシュ期間 ユースケース
静的レンダリング デフォルト ビルド時生成、永続キャッシュ ブログ、ドキュメント
ISR revalidate 定期的な再検証 ニュース、商品一覧
動的レンダリング no-store リクエストごとに再取得 リアルタイムデータ、ユーザーダッシュボード
オンデマンド再検証 revalidatePath/Tag 手動トリガー コンテンツ更新後

fetchキャッシュ設定

// 静的レンダリング:ビルド時に取得、永続キャッシュ
const staticData = await fetch('https://api.example.com/docs', {
  cache: 'force-cache',
});

// ISR:60秒ごとに再検証
const isrData = await fetch('https://api.example.com/articles', {
  next: { revalidate: 60 },
});

// 動的レンダリング:リクエストごとに再取得
const dynamicData = await fetch('https://api.example.com/realtime', {
  cache: 'no-store',
});

// タグベースキャッシュ、オンデマンドでクリア可能
const taggedData = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] },
});

オンデマンド再検証

// actions/revalidate.ts
'use server';

import { revalidatePath, revalidateTag } from 'next/cache';

export async function revalidateArticles() {
  revalidatePath('/articles');
  revalidatePath('/articles/[id]', 'page');
}

export async function revalidateProducts() {
  revalidateTag('products');
}

ルートセグメント設定

// app/articles/page.tsx
export const revalidate = 300; // ISR:5分ごとに再検証

export default async function ArticlesPage() {
  const articles = await fetch('https://api.example.com/articles', {
    next: { revalidate: 300 },
  }).then(res => res.json());

  return <div>{articles.map(a => <p key={a.id}>{a.title}</p>)}</div>;
}
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // 動的レンダリングを強制

export default async function DashboardPage() {
  const data = await db.query('SELECT * FROM stats');
  return <div>{data.length} 件のレコード</div>;
}

💡 Base64エンコードツールでAPIトークンとキャッシュキーのエンコードニーズに対応できます。


パフォーマンス比較:Bundle Size、TTFB、FCP

RSCはパフォーマンス指標で大幅な改善を示しています。実際のプロジェクトの比較データです。

Bundle Size比較

指標 Pages Router (SSR) App Router (RSC) 改善
ホームJSサイズ 187 KB 42 KB -77%
記事JSサイズ 203 KB 38 KB -81%
サードパーティライブラリ 94 KB 0 KB(サーバー側) -100%
Hydrationスクリプト 156 KB 23 KB -85%

Core Web Vitals比較

指標 Pages Router App Router (RSC) 改善
TTFB 320ms 180ms -44%
FCP 1.8s 0.9s -50%
LCP 2.5s 1.4s -44%
TTI 3.2s 1.6s -50%
CLS 0.12 0.05 -58%

なぜRSCは速いのか?

  1. ゼロHydration:Server ComponentはHydration不要、JS実行時間を削減
  2. ストリーミング:ページチャンクが段階的に到着、FCPが大幅に前倒し
  3. 依存関係がバンドルに含まれないmarkedhighlight.jsなどはサーバー側のみで実行
  4. リクエストウォーターフォールの削減:サーバー側で並列データ取得

パフォーマンス測定コード

// app/api/metrics/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const metrics = {
    bundleSizeKB: 42,
    ttfbMs: 180,
    fcpMs: 900,
    lcpMs: 1400,
    cls: 0.05,
    timestamp: new Date().toISOString(),
  };

  return NextResponse.json(metrics);
}

よくある落とし穴とエラー

落とし穴1:Server ComponentでクライアントAPIを使用

// ❌ 間違い:Server ComponentでuseStateは使用不可
export default async function Page() {
  const [count, setCount] = useState(0); // エラー!
  return <div>{count}</div>;
}

// ✅ 正しい:Client Componentに抽出
// components/Counter.tsx
'use client';
import { useState } from 'react';
export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

落とし穴2:ClientコンポーネントがServerコンポーネントをインポート

// ❌ 間違い:ClientコンポーネントはServerコンポーネントを直接インポート不可
'use client';
import ServerDataTable from './ServerDataTable'; // エラー!

export default function ClientWrapper() {
  return <ServerDataTable />;
}

// ✅ 正しい:children propで渡す
// components/ClientWrapper.tsx
'use client';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>切り替え</button>
      {isOpen && children}
    </div>
  );
}

// page.tsx
import ClientWrapper from '@/components/ClientWrapper';
import ServerDataTable from '@/components/ServerDataTable';

export default function Page() {
  return (
    <ClientWrapper>
      <ServerDataTable />
    </ClientWrapper>
  );
}

落とし穴3:Server Actionでrevalidateを忘れる

// ❌ 間違い:データ変更後キャッシュが更新されない
'use server';
export async function updateArticle(id: number, data: FormData) {
  await db.query('UPDATE articles SET title = $1 WHERE id = $2', [data.get('title'), id]);
  // revalidatePathがない!ユーザーには古いデータが表示される
}

// ✅ 正しい:変更後に再検証
'use server';
import { revalidatePath } from 'next/cache';
export async function updateArticle(id: number, data: FormData) {
  await db.query('UPDATE articles SET title = $1 WHERE id = $2', [data.get('title'), id]);
  revalidatePath('/articles');
  revalidatePath(`/articles/${id}`);
}

落とし穴4:シリアライズ不可能なデータのシリアライズ

// ❌ 間違い:シリアライズ不可能なpropsをClient Componentに渡す
import LikeButton from './LikeButton';
export default async function Page() {
  const result = await db.query('SELECT * FROM articles');
  // DateオブジェクトはClient Componentにシリアライズして渡せない
  return <LikeButton createdAt={result.createdAt} />;
}

// ✅ 正しい:シリアライズ可能な値に変換
export default async function Page() {
  const result = await db.query('SELECT * FROM articles');
  return <LikeButton createdAt={result.createdAt.toISOString()} />;
}

よくあるエラークイックリファレンス

エラーメッセージ 原因 解決策
useState is not defined Server ComponentでHookを使用 "use client"を追加またはClient Componentに抽出
onClick is not defined Server Componentでイベントを使用 インタラクティブ部分をClient Componentに抽出
Functions cannot be passed as props 関数をClient Componentに渡す Server Actionsを使用
Only plain objects can be passed シリアライズ不可能なデータを渡す JSON互換形式に変換
Hydration mismatch Server/Clientのレンダリング不一致 動的コンテンツの一貫性を確認

Pages Routerからのマイグレーション

マイグレーションステップ概要

ステップ 内容 推定時間
1 app/ディレクトリを作成、pages/を保持 1時間
2 ルートレイアウト_app.tsxapp/layout.tsxに移行 2-4時間
3 ページごとにpages/app/に移行 ページあたり1-2時間
4 getServerSideProps → Server Componentに移行 ページあたり1-2時間
5 getStaticProps → 静的レンダリング + ISRに移行 ページあたり1-2時間
6 API Routes → Server Actions / Route Handlersに移行 各0.5-1時間
7 pages/ディレクトリを削除 0.5時間

getServerSidePropsマイグレーション

// Pages Router:getServerSideProps
export async function getServerSideProps({ params }) {
  const article = await db.article.findUnique({ where: { id: params.id } });
  return { props: { article } };
}

export default function ArticlePage({ article }) {
  return <div>{article.title}</div>;
}
// App Router:Server Component
export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });

  if (!article) {
    notFound();
  }

  return <div>{article.title}</div>;
}

getStaticPropsマイグレーション

// Pages Router:getStaticProps + ISR
export async function getStaticPaths() {
  const articles = await db.article.findMany({ select: { id: true } });
  return {
    paths: articles.map(a => ({ params: { id: String(a.id) } })),
    fallback: 'blocking',
  };
}

export async function getStaticProps({ params }) {
  const article = await db.article.findUnique({ where: { id: params.id } });
  return { props: { article }, revalidate: 300 };
}
// App Router:generateStaticParams + ISR
export async function generateStaticParams() {
  const articles = await db.article.findMany({ select: { id: true } });
  return articles.map(a => ({ id: String(a.id) }));
}

export const revalidate = 300;

export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });
  return <div>{article.title}</div>;
}

API Routesマイグレーション

// Pages Router:API Route
// pages/api/articles.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    const articles = await db.article.findMany();
    res.status(200).json(articles);
  } else if (req.method === 'POST') {
    const { title, content } = req.body;
    const article = await db.article.create({ data: { title, content } });
    res.status(201).json(article);
  }
}
// App Router:Route Handler
// app/api/articles/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET() {
  const articles = await db.article.findMany();
  return NextResponse.json(articles);
}

export async function POST(request: NextRequest) {
  const { title, content } = await request.json();
  const article = await db.article.create({ data: { title, content } });
  return NextResponse.json(article, { status: 201 });
}

SEO最適化とRSC

RSCは本質的にSEOフレンドリーです。Server Componentはサーバー側でレンダリングされるため、検索エンジンが完全なHTMLを直接クロールできます。

Metadata API

// app/articles/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';

interface PageProps {
  params: Promise<{ id: string }>;
}

export async function generateMetadata(
  { params }: PageProps,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });

  return {
    title: article.title,
    description: article.excerpt,
    openGraph: {
      title: article.title,
      description: article.excerpt,
      type: 'article',
      publishedTime: article.createdAt.toISOString(),
      authors: [article.author.name],
      images: [{ url: article.coverImage, width: 1200, height: 630 }],
    },
    twitter: {
      card: 'summary_large_image',
      title: article.title,
      description: article.excerpt,
      images: [article.coverImage],
    },
  };
}

SitemapとRobots

// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { db } from '@/lib/db';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const articles = await db.article.findMany({
    select: { id: true, updatedAt: true },
  });

  const articleUrls = articles.map(article => ({
    url: `https://toolsku.com/articles/${article.id}`,
    lastModified: article.updatedAt,
    changeFrequency: 'weekly' as const,
    priority: 0.8,
  }));

  return [
    { url: 'https://toolsku.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
    { url: 'https://toolsku.com/articles', lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
    ...articleUrls,
  ];
}
// app/robots.ts
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/api/', '/dashboard/'] },
    ],
    sitemap: 'https://toolsku.com/sitemap.xml',
  };
}

構造化データ(JSON-LD)

// app/articles/[id]/page.tsx
export default async function ArticlePage({ params }: PageProps) {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: article.title,
    description: article.excerpt,
    image: article.coverImage,
    datePublished: article.createdAt.toISOString(),
    dateModified: article.updatedAt.toISOString(),
    author: {
      '@type': 'Person',
      name: article.author.name,
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>
        <h1>{article.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: article.htmlContent }} />
      </article>
    </>
  );
}

FAQ

RSCとSSRの違いは?

SSRはサーバー側でHTMLをレンダリングしますが、クライアント側でHydrationが必要です(完全なJSを読み込んでコンポーネントツリーを再構築)。RSCがレンダリングしたServer ComponentはHydration不要であり、そのコードはクライアントに送信されません。

すべてのコンポーネントをServer Componentにすべき?

デフォルトはServer Componentで、インタラクションが必要な場合(useState、useEffect、イベント処理)のみClient Componentを使用します。経験則:80% Server + 20% Client。

Next.jsなしでRSCを使える?

理論上は可能です。Reactは基盤プロトコルを提供していますが、RSCのトランスポートとレンダリングパイプラインを自前で実装する必要があります。現在、Next.js App Routerが最も成熟したRSC実装です。他のフレームワーク(Remix、Waku)も追従中です。

Server Actionsは安全?

Server Actionsは本質的にPOSTリクエストであり、Next.jsが自動的にCSRF保護を提供します。ただし、Action内部で入力検証と権限チェックを行う必要があります。クライアントからのデータを信用しないでください。

RSCはSEOに役立つ?

非常に役立ちます。Server Componentはサーバー側で完全なHTMLをレンダリングするため、検索エンジンが直接クロールできます。Metadata API、Sitemap、JSON-LDと組み合わせることで、クライアントサイドレンダリングをはるかに超えるSEO効果が得られます。

Server Componentのデバッグ方法は?

  1. Server Componentでconsole.logを使用 — 出力はサーバー端末に表示
  2. Next.js Dev Toolsでコンポーネントのレンダリング境界を確認
  3. React DevToolsでClient Componentツリーを確認
  4. next.config.jslogging: trueを有効化

大規模プロジェクトのマイグレーション順序の推奨?

  1. まず純表示ページ(ブログ、ドキュメント)を移行 — リスク最低
  2. 次にデータ表示ページ(一覧、詳細)を移行 — getServerSidePropsを置き換え
  3. 最後にインタラクティブページ(フォーム、ダッシュボード)を移行 — Server Actionsが必要
  4. pages/app/を共存させ、段階的に移行

ブラウザローカルツールを無料で試す →

#React#RSC#Server Components#Next.js#教程