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>
)
}
// app/dashboard/HeavyStats.tsx(Server Component)
async function HeavyStats() {
// 時間のかかる処理
const stats = await fetchAnalytics() // 2秒かかると仮定
return <StatsChart data={stats} />
}
並列データ取得
複数の非同期処理を Promise.all で並列実行するとさらに高速化できます。
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/
│ ├── page.tsx
│ └── loading.tsx ← このファイルを置くだけで自動適用
// app/dashboard/loading.tsx
export default function Loading() {
return <div className="animate-pulse">読み込み中...</div>
}
Static Export との比較・使いどころ
このブログ自体が output: "export" の Static Export で構築されています。RSC・Streaming と Static Export は 用途が根本的に異なります。
| 機能 | App Router (サーバーあり) | Static Export |
|---|---|---|
| RSC によるデータ取得 | ✅ リクエスト時に実行 | ✅ ビルド時のみ実行 |
| Streaming / Suspense | ✅ | ❌(output: "export" 非対応) |
| ISR(段階的再生成) | ✅ | ❌ |
| API Route(動的) | ✅ | ❌ |
| ホスティングコスト | サーバー必要 | 静的ファイルのみ(無料〜安価) |
| CDN との相性 | 良い | 最高 |
Static Export で RSC を使う場合
output: "export" でも RSC 構文は使えますが、データ取得はビルド時のみ実行されます。
// ビルド時にファイルを読み込む(Rootless Docker 記事のように)
import fs from "fs"
import path from "path"
export default async function Page() {
// ビルド時に実行される(リクエスト時ではない)
const content = fs.readFileSync(
path.join(process.cwd(), "posts/example.md"),
"utf-8"
)
return <article>{content}</article>
}
使い分けの判断基準
ユーザーごとに異なるデータを返す必要がある?
→ 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. cookies() / headers() は動的 API
cookies() や headers() を使うと、そのルートは自動的に動的レンダリングになります。Static Export では使えません。
import { cookies } from "next/headers"
// これを使った瞬間、Static Export ではビルドエラーになる
export default async function Page() {
const cookieStore = cookies()
// ...
}
5. Suspense 境界の粒度
Suspense の境界が大きすぎると、一部の遅いコンポーネントのせいでページ全体が遅れます。境界は細かく設定しましょう。
// NG: 全体を一つの Suspense で囲む
<Suspense fallback={<Loading />}>
<FastComp /> {/* 速いのに待たされる */}
<SlowComp />
</Suspense>
// OK: 遅いコンポーネントだけを Suspense で囲む
<FastComp />
<Suspense fallback={<Loading />}>
<SlowComp />
</Suspense>
まとめ
- Server Component はデフォルト。インタラクティブな部分だけ
"use client"を付ける - Streaming + Suspense でページの体感速度を改善できる
- Static Export はブログ・ドキュメントなどコンテンツが静的なサイトに最適。Streaming は使えないがビルド時 RSC は使える
- Client Component の境界に注意。境界より下は全部クライアントになる
- props はシリアライズ可能なものだけ Server → Client に渡せる