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つのコア優位性
- ゼロバンドルサイズ:サーバーコンポーネントの依存関係はクライアントバンドルに含まれない
- 直接データアクセス:コンポーネント内でデータベースやファイルシステムに直接読み書き、API層不要
- 自動コード分割:クライアントコンポーネントが自然にコード分割の境界になる
💡 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は速いのか?
- ゼロHydration:Server ComponentはHydration不要、JS実行時間を削減
- ストリーミング:ページチャンクが段階的に到着、FCPが大幅に前倒し
- 依存関係がバンドルに含まれない:
marked、highlight.jsなどはサーバー側のみで実行 - リクエストウォーターフォールの削減:サーバー側で並列データ取得
パフォーマンス測定コード
// 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.tsx → app/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のデバッグ方法は?
- Server Componentで
console.logを使用 — 出力はサーバー端末に表示 - Next.js Dev Toolsでコンポーネントのレンダリング境界を確認
- React DevToolsでClient Componentツリーを確認
next.config.jsでlogging: trueを有効化
大規模プロジェクトのマイグレーション順序の推奨?
- まず純表示ページ(ブログ、ドキュメント)を移行 — リスク最低
- 次にデータ表示ページ(一覧、詳細)を移行 — getServerSidePropsを置き換え
- 最後にインタラクティブページ(フォーム、ダッシュボード)を移行 — Server Actionsが必要
pages/とapp/を共存させ、段階的に移行
ブラウザローカルツールを無料で試す →