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 ComponentClient 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 に渡せる