React Server Components(RSC)とは

React Server Components(RSC)は、サーバー上でのみ実行されるReactコンポーネントです。 従来のReactコンポーネントは、たとえサーバーサイドレンダリング(SSR)を行ってもクライアントに JavaScriptバンドルが送信されハイドレーションが必要でした。 RSCはこの前提を根本から覆します — サーバーで実行された結果のみがクライアントに送られ、 コンポーネントのJavaScript自体はブラウザに一切送信されません

App Routerでは、すべてのコンポーネントがデフォルトでServer Componentです。 クライアントサイドのインタラクティビティが必要な場合にのみ、明示的に'use client'ディレクティブを宣言してClient Componentに切り替えます。

特性 Server Components Client Components
実行環境 サーバーのみ サーバー(SSR)+ クライアント(ハイドレーション)
JSバンドル クライアントに送信されない クライアントに送信される
データアクセス DB・ファイルシステムに直接アクセス可 API経由のみ
状態管理 useState / useEffect 使用不可 useState / useEffect 使用可
イベントハンドラ onClick等のイベント使用不可 すべてのDOMイベント使用可
宣言方法 デフォルト(何もしない) 'use client'をファイル先頭に記述

RSC Payloadの仕組み

Server Componentがサーバーで実行されると、その結果はRSC Payloadと呼ばれる特殊なデータ形式でクライアントに送信されます。 RSC PayloadはHTMLでもJSONでもない、Reactが解釈できるストリーミング可能なバイナリ形式です。

sequenceDiagram
    participant S as Server
    participant C as Client (React Runtime)
    participant D as DOM

    S->>S: Server Componentを実行
    S->>S: RSC Payloadを生成
    Note over S: コンポーネントツリーのシリアライズ結果<br/>Client Component参照 + Props<br/>Suspense境界の情報
    S->>C: RSC Payload をストリーミング送信
    C->>C: RSC Payloadを解析
    C->>C: Client Componentのモジュールをロード
    C->>D: 仮想DOMを構築しレンダリング
    Note over D: Server Componentの出力はそのまま使用<br/>Client Componentのみハイドレーション
RSC Payloadの生成からレンダリングまでの流れ

RSC Payloadには主に以下の情報が含まれます。

  • Server Componentのレンダリング結果: HTMLに変換される前の仮想DOM表現
  • Client Componentへの参照: どのモジュールをクライアントでロードすべきかの情報
  • Client Componentに渡すProps: シリアライズ可能な値のみ(関数は渡せない)
  • Suspense境界の情報: ストリーミングの区切りポイント

Client Componentsの宣言 — 'use client'ディレクティブ

Client Componentは、ファイルの先頭に'use client'ディレクティブを記述することで宣言します。 このディレクティブはモジュール境界を定義し、そのファイルとそこからimportされるすべてのモジュールがクライアントバンドルに含まれます。

'use client'

import { useState } from 'react'

// このコンポーネントとその依存関係がクライアントバンドルに含まれる
export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        インクリメント
      </button>
    </div>
  )
}

重要な点は、'use client'そのファイルが境界であることを宣言しているのであって、 「このコンポーネントはクライアントでのみ実行される」という意味ではありません。 Client ComponentもSSR時にはサーバーで実行されます。 正確には「このコンポーネント以下はクライアントバンドルに含まれ、ハイドレーションの対象になる」という宣言です。

Server/Client境界の設計原則

RSCアーキテクチャにおいて最も重要な設計判断は、どこにServer/Client境界を置くかです。 基本原則は明確です — できるだけコンポーネントツリーの葉(末端)に近い位置で'use client'を宣言すること。

graph TD
    A["ServerComponent (layout.tsx)\n📦 バンドルなし"] --> B["ServerComponent (記事一覧)\n📦 バンドルなし"]
    A --> C["ServerComponent (サイドバー)\n📦 バンドルなし"]
    B --> D["ServerComponent (記事カード)\n📦 バンドルなし"]
    D --> E["'use client'\nLikeButton\n📦 2KB"]
    D --> F["'use client'\nShareButton\n📦 1KB"]
    C --> G["'use client'\nSearchBox\n📦 5KB"]
    C --> H["ServerComponent (カテゴリリスト)\n📦 バンドルなし"]

    style A fill:#3b82f6,stroke:#1d4ed8,color:#fff
    style B fill:#3b82f6,stroke:#1d4ed8,color:#fff
    style C fill:#3b82f6,stroke:#1d4ed8,color:#fff
    style D fill:#3b82f6,stroke:#1d4ed8,color:#fff
    style H fill:#3b82f6,stroke:#1d4ed8,color:#fff
    style E fill:#f97316,stroke:#ea580c,color:#fff
    style F fill:#f97316,stroke:#ea580c,color:#fff
    style G fill:#f97316,stroke:#ea580c,color:#fff
理想的な境界設計: インタラクティブな葉コンポーネントのみがClient Component(オレンジ)

よくある誤りは、ページ全体を'use client'にしてしまうことです。 これではRSCの利点が完全に失われ、従来のCSRと同じバンドルサイズになります。

コンポジションパターン

childrenパターン — Server ComponentをClient Componentに渡す

Client Componentの中でServer Componentを直接importすることはできません。 しかし、childrenや任意のpropsとしてServer Componentを渡すことは可能です。 これが最も重要なコンポジションパターンです。

// app/dashboard/layout.tsx (Server Component)
import Sidebar from './sidebar'          // Server Component
import InteractivePanel from './panel'    // Client Component

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex">
      {/* Client Componentにchildren経由でServer Componentを渡す */}
      <InteractivePanel>
        <Sidebar />   {/* これはServer Componentのまま */}
      </InteractivePanel>
      <main>{children}</main>
    </div>
  )
}
// app/dashboard/panel.tsx (Client Component)
'use client'

import { useState } from 'react'

export default function InteractivePanel({
  children,
}: {
  children: React.ReactNode
}) {
  const [isOpen, setIsOpen] = useState(true)

  return (
    <aside className={isOpen ? 'w-64' : 'w-0'}>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? '閉じる' : '開く'}
      </button>
      {isOpen && children}  {/* Server Componentの出力がそのまま表示される */}
    </aside>
  )
}

このパターンが機能する理由は、childrenはすでにレンダリング済みのRSC Payloadとして渡されるからです。 Client Componentはchildrenの中身を実行するのではなく、受け取った結果をそのままDOMに配置するだけです。

Context Providerパターン

ReactのContext APIはClient Componentでしか使えません。 しかし、アプリケーション全体にテーマやロケール情報を提供したい場合、Context Providerをルートレイアウトに配置する必要があります。 この場合、Providerだけを'use client'で切り出し、children経由でServer Componentを渡すパターンを使います。

// app/providers.tsx (Client Component)
'use client'

import { ThemeProvider } from 'next-themes'

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="dark">
      {children}
    </ThemeProvider>
  )
}

// app/layout.tsx (Server Component)
import { Providers } from './providers'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        <Providers>
          {children}  {/* Server Componentツリーがそのまま渡される */}
        </Providers>
      </body>
    </html>
  )
}

server-onlyパッケージによる環境分離

Server Componentでしか使用すべきでないモジュール(データベース接続、APIキーを含むfetch等)が 誤ってClient Componentでimportされると、ビルドは成功するもののシークレットがクライアントバンドルに漏洩する危険があります。

server-onlyパッケージを使うと、このようなimportミスをビルド時エラーとして検出できます。

// lib/db.ts — サーバー専用モジュール
import 'server-only'

import { Pool } from 'pg'

const pool = new Pool({
  connectionString: process.env.DATABASE_URL, // シークレット
})

export async function getUsers() {
  const { rows } = await pool.query('SELECT * FROM users')
  return rows
}

// もしClient Componentからimportすると...
// ビルドエラー: "server-only" module cannot be imported from a Client Component

バンドルサイズ削減の実測データ

RSCの最大の実利的メリットはバンドルサイズの大幅な削減です。 以下は、実際にRSCを採用した企業から報告されているパフォーマンス改善データです。

企業/プロダクト 改善項目 数値 備考
Frigade クライアントJSバンドル 62%削減 SDK全体をRSCに移行。200KB→76KBに圧縮
DoorDash LCP(Largest Contentful Paint) 65%改善 マーチャントポータルをApp Routerに移行
Vercel Dashboard 初期JSバンドル 40%削減 Pages Router → App Router移行
OpenAI ChatGPT Web 非公開 Next.js App Routerで構築。リアルタイムストリーミングにRSC活用

Frigadeの事例は特に示唆的です。 React向けのプロダクトツアーSDKを提供する同社は、SDK内のコンポーネントの大部分がデータフェッチと表示のみで インタラクションを必要としないことに気付き、それらをServer Componentに変換しました。 結果として、ユーザーのバンドルに含まれるJS量が200KBから76KBに減少(62%削減)しました。

Server Actionsの基本 — 'use server'

Server Actionsは、クライアントから直接呼び出せるサーバーサイド関数です。 'use server'ディレクティブで宣言し、フォーム送信やデータ変更に使用します。 REST APIのエンドポイントを手動で作成する必要がなくなります。

// app/actions.ts — Server Actions定義ファイル
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  // サーバー上で実行される(DBアクセス、シークレット使用可)
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  await db.post.create({
    data: { title, content },
  })

  revalidatePath('/blog')  // キャッシュの再検証
  redirect('/blog')         // リダイレクト
}
// app/blog/new/page.tsx — Server ComponentからServer Actionsを使用
import { createPost } from '../actions'

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input type="text" name="title" placeholder="タイトル" />
      <textarea name="content" placeholder="本文" />
      <button type="submit">投稿する</button>
    </form>
  )
}
// JavaScriptが無効でもフォーム送信が機能する(Progressive Enhancement)

インターリーブパターン

実際のアプリケーションでは、Server ComponentとClient Componentが交互にネストされる場合があります。 これをインターリーブパターンと呼びます。

// Server Component → Client Component → Server Component のネスト

// app/page.tsx (Server Component)
import ClientTabs from './client-tabs'
import ServerContent from './server-content'

export default async function Page() {
  const categories = await getCategories() // サーバーでデータ取得

  return (
    <ClientTabs categories={categories}>
      {/* Server Componentをchildren経由で渡す */}
      <ServerContent />
    </ClientTabs>
  )
}

// app/client-tabs.tsx (Client Component)
'use client'
import { useState } from 'react'

export default function ClientTabs({
  categories,
  children,
}: {
  categories: string[]
  children: React.ReactNode
}) {
  const [activeTab, setActiveTab] = useState(0)

  return (
    <div>
      <nav>
        {categories.map((cat, i) => (
          <button key={cat} onClick={() => setActiveTab(i)}>
            {cat}
          </button>
        ))}
      </nav>
      {children}  {/* Server Componentの結果がそのまま表示される */}
    </div>
  )
}

このパターンのポイントは、Client Componentの中にServer Componentを直接importすることはできないが、 props(特にchildren)として渡すことは可能ということです。 Server Componentは親のServer Componentがレンダリングした時点でRSC Payloadに変換済みであり、 Client Componentはそのシリアライズ済みの結果を受け取るだけだからです。

graph TD
    A["Server Component\n(page.tsx)"] -->|children props| B["Client Component\n'use client' (tabs.tsx)"]
    A -->|直接import| C["Server Component\n(header.tsx)"]
    B -->|children表示| D["Server Component\n(content.tsx)\n※RSC Payloadとして渡される"]
    B -.->|❌ 直接importは不可| E["Server Component\n(data.tsx)"]

    style A fill:#3b82f6,stroke:#1d4ed8,color:#fff
    style C fill:#3b82f6,stroke:#1d4ed8,color:#fff
    style D fill:#3b82f6,stroke:#1d4ed8,color:#fff
    style E fill:#ef4444,stroke:#dc2626,color:#fff
    style B fill:#f97316,stroke:#ea580c,color:#fff
インターリーブパターン: Server ComponentはClient Componentにchildrenとして渡せるが、Client Componentから直接importはできない

まとめ

React Server Componentsは、「すべてのJavaScriptをクライアントに送信する」という従来のReactの前提を覆し、 必要なインタラクティブ部分だけをクライアントバンドルに含めるというパラダイムシフトを実現しました。

設計の要点は、'use client'境界をできるだけ葉に近い位置に配置し、 childrenパターンによるコンポジションでServer/Client Componentを柔軟に組み合わせることです。 Frigadeの62%バンドル削減やDoorDashのLCP 65%改善といった実測データが示すように、 この設計原則に従うことで大幅なパフォーマンス改善が期待できます。

次章では、Server Componentsでのデータフェッチ方法と、Next.js 16で導入されたCache Componentsを含む キャッシュ戦略を詳しく解説します。

理解度チェック

問題 0 / 50%
Q1

App Routerにおいて、コンポーネントがデフォルトで動作する環境はどれですか?

キーボード: 1〜4 で選択、Enter で回答