React Server Components と Next.js App Router 深掘り — RSC・Streaming・Static Export の使い分け
React Server Components(RSC)と Next.js App Router は、フロントエンドの設計を根本から変えるパラダイムシフトです。この記事では RSC の仕組みから Streaming・Suspense の実装、Static Export との使い分けまで、実務で判断に迷うポイントを整理します。
React Server Components とは
RSC はサーバー側でのみレンダリングされる React コンポーネントです。クライアントに送られるのは HTML と最小限のデータだけで、コンポーネント自体の JavaScript は含まれません。
Pages Router (従来):
ブラウザ → サーバー → HTML + JS bundle (全コンポーネント込み)
App Router + RSC:
ブラウザ → サーバー → HTML + RSC payload + Client Components JS のみ
Next.js 13 で App Router が導入され、app/ ディレクトリ以下のコンポーネントはデフォルトで Server Component になります。
Server Component と Client Component の違い
| 特徴 | Server Component | Client Component |
|---|---|---|
| レンダリング場所 | サーバー | ブラウザ(+ サーバーでのプリレンダリング) |
useState / useEffect | 使えない | 使える |
| ブラウザ API | 使えない | 使える |
| DB・ファイルシステム直接アクセス | 使える | 使えない |
| JS bundle サイズへの影響 | なし | あり |
| イベントハンドラ | 使えない | 使える |
Client Component の宣言
ファイル先頭に "use client" ディレクティブを付けます。
// components/Counter.tsx
"use client"
import { useState } from "react"
export function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
)
}
Server Component からデータ取得
Server Component では async/await を直接使えます。
// app/posts/page.tsx(Server Component)
import { db } from "@/lib/db"
export default async function PostsPage() {
// サーバーで直接 DB アクセス(API Route 不要)
const posts = await db.posts.findMany({ orderBy: { date: "desc" } })
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
コンポーネント設計の基本戦略
「できるだけ Server Component のまま。インタラクティブな末端だけ Client Component にする」 が原則です。
app/
├── page.tsx ← Server Component(データ取得)
│ └── PostList.tsx ← Server Component(表示)
│ └── LikeButton.tsx ← "use client"(クリック操作)
Client Component の境界より下は自動的に Client Component になります。Server Component を Client Component の子として渡す場合は props 経由(children)で渡します。
// NG: Client Component の中で Server Component を import
"use client"
import { ServerComp } from "./ServerComp" // ✗ 動かない
// OK: children として受け取る
"use client"
export function ClientWrapper({ children }: { children: React.ReactNode }) {
return <div onClick={handleClick}>{children}</div>
}
// 親(Server Component)で組み合わせる
import { ClientWrapper } from "./ClientWrapper"
import { ServerComp } from "./ServerComp"
export default function Page() {
return (
<ClientWrapper>
<ServerComp /> {/* Server Component のまま動く */}
</ClientWrapper>
)
}
Streaming と Suspense
Streaming を使うと、ページの一部が準備できた時点から順次クライアントへ送信できます。重いデータ取得があっても、ページ全体をブロックしません。
基本的な使い方
// app/dashboard/page.tsx
import { Suspense } from "react"
import { HeavyStats } from "./HeavyStats"
import { Skeleton } from "@/components/Skeleton"
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* 即座に表示 */}
<QuickSummary />
{/* HeavyStats の取得中は Skeleton を表示 */}
<Suspense fallback={<Skeleton />}>
<HeavyStats />
</Suspense>
</div>
)
}
並列データ取得
async function Page() {
// 順次取得(遅い)
// const user = await getUser()
// const posts = await getPosts()
// 並列取得(速い)
const [user, posts] = await Promise.all([getUser(), getPosts()])
return <Profile user={user} posts={posts} />
}
loading.tsx による自動 Suspense
loading.tsx を配置するだけで、そのルートセグメント全体に Suspense が適用されます。
// app/dashboard/loading.tsx
export default function Loading() {
return <div className="animate-pulse">読み込み中...</div>
}
Static Export との比較・使いどころ
| 機能 | App Router(サーバーあり) | Static Export |
|---|---|---|
| RSC によるデータ取得 | リクエスト時に実行 | ビルド時のみ実行 |
| Streaming / Suspense | ✅ | ❌(output: "export" 非対応) |
| ISR(段階的再生成) | ✅ | ❌ |
| API Route(動的) | ✅ | ❌ |
| ホスティングコスト | サーバー必要 | 静的ファイルのみ(無料〜安価) |
使い分けの判断基準
ユーザーごとに異なるデータを返す必要がある?
→ App Router(サーバーあり)
リアルタイムデータ・頻繁な更新がある?
→ App Router(ISR や On-demand Revalidation)
コンテンツが静的・更新頻度が低い?
→ Static Export(ブログ、ドキュメントサイト)
インフラコストを最小化したい?
→ Static Export(GitHub Pages / Cloudflare Pages)
ハマりやすいポイント
1. useState を Server Component で使おうとする
Error: useState can only be used in a Client Component.
Add the "use client" directive at the top of the file.
2. Server Component から Client Component に渡せる props の制限
props はシリアライズ可能なものだけです。関数・クラスインスタンス・Date オブジェクトなどは渡せません。
// NG
<ClientComp onClick={serverFunction} /> // 関数は渡せない
<ClientComp date={new Date()} /> // Date オブジェクトは渡せない
// OK
<ClientComp dateStr={new Date().toISOString()} /> // 文字列に変換
3. "use client" を付けたファイルがバンドルに含まれる
"use client" を付けると、そのファイルとその依存関係すべてがクライアント JS バンドルに含まれます。大きなライブラリを Client Component で import すると bundle が膨らみます。
// NG: 巨大ライブラリを Client Component で import
"use client"
import { HeavyChart } from "heavy-chart-library"
// OK: dynamic import で遅延読み込み
import dynamic from "next/dynamic"
const HeavyChart = dynamic(() => import("heavy-chart-library"), { ssr: false })
4. Suspense 境界の粒度
Suspense の境界が大きすぎると、一部の遅いコンポーネントのせいでページ全体が遅れます。
// NG: 全体を一つの Suspense で囲む
<Suspense fallback={<Loading />}>
<FastComp /> {/* 速いのに待たされる */}
<SlowComp />
</Suspense>
// OK: 遅いコンポーネントだけを Suspense で囲む
<FastComp />
<Suspense fallback={<Loading />}>
<SlowComp />
</Suspense>
5. cookies() / headers() は動的 API
cookies() や headers() を使うと、そのルートは自動的に動的レンダリングになります。Static Export では使えません。
まとめ
- Server Component はデフォルト。インタラクティブな部分だけ
"use client"を付ける - Streaming + Suspense でページの体感速度を改善できる
- Static Export はブログ・ドキュメントなどコンテンツが静的なサイトに最適。Streaming は使えないがビルド時 RSC は使える
- Client Component の境界に注意。境界より下は全部クライアントになる
- props はシリアライズ可能なものだけ Server → Client に渡せる