Next.js: サーバーコンポーネントのメンタルモデル

最終更新:2026-08-26

RSC メンタルモデルは Next.js 16 における最も重要なパラダイムシフトです。これを理解して初めて、App Router の背後にある設計思想を真に把握できます。

1. 学ぶこと



2. フルスタック開発者の実話

(1) ペインポイント: バンドルサイズが制御不能

Alice は TaskFlow チームのフルスタック開発者です。彼女が構築した Dashboard ページにはデータテーブルコンポーネントが含まれており、日付のフォーマットといくつかの棒グラフを描画するためだけに date-fnsrechartslodash の3つのライブラリを参照しています。ページの初期 JS バンドルは 480 KB に膨れ上がり、Lighthouse のパフォーマンススコアは 52 に低下しました。さらに悪いことに、このテーブルは単なるサーバーレンダリングされた純粋に表示用のコンポーネントであり、ユーザーはまったく操作する必要がないにもかかわらず、これらのライブラリのコードがクライアントにダウンロードされています。

(2) RSC の解決策

RSC は、サーバーサイドコンポーネントがサーバー上でのみ実行され、純粋な HTML とシリアライズされたデータを出力し、JavaScript バンドルが完全に除外されることを保証します。

TSX
// app/dashboard/page.tsx — サーバーコンポーネント (ゼロクライアント JS)
import { getSalesData } from '@/lib/db'
import { formatDistanceToNow } from 'date-fns'

export default async function DashboardPage() {
  const sales = await getSalesData()  // データベースに直接アクセス
  return (
    <div>
      <h1>Dashboard — {sales.length} records</h1>
      <SalesTable data={sales} />
    </div>
  )
}

async function SalesTable({ data }: { data: Sale[] }) {
  return (
    <table>
      {data.map(row => (
        <tr key={row.id}>
          <td>{formatDistanceToNow(row.createdAt)}</td>
          <td>{row.amount}</td>
        </tr>
      ))}
    </table>
  )
}

(3) 効果

次元 純粋なクライアントコンポーネント RSC
バンドルサイズ 480 KB (date-fns + ReCharts + Lodash を含む) 0 KB (サーバーサイドライブラリはダウンロードされない)
データベースアクセス API ルート中継が必要 直接アクセス (ゼロレイテンシ)
ファーストビューレンダリング JS のダウンロード + 実行が必要 即時 HTML
SEO SSR / クライアントサイドレンダリングに依存 ネイティブサポート
Lighthouse スコア 52 96


3. RSC の基本定義

RSC (React Server Component) は React 19 で導入された新しいコンポーネントタイプです。サーバー上でのみ実行され、クライアントのブラウザには決して送信されません。RSC コード (依存ライブラリを含む) は JS バンドルに現れないため、大規模なライブラリを安全に使用し、データベースに直接アクセスし、ファイルシステムを読み取ることができます。

100%
graph TB
    subgraph "サーバーサイド (Server)"
        A[RSC コンポーネント] --> B[データベース/ファイルシステム/API]
        A --> C[RSC ペイロードにシリアライズ<br/>React Flight プロトコル]
    end
    subgraph "クライアント (Browser)"
        D[RSC ペイロード] --> E[クライアントコンポーネント<br/>インタラクションロジックを保持]
        D --> F[純粋な HTML レンダリング<br/>JS オーバーヘッドゼロ]
    end
    C --> D
    style A fill:#d4edda
    style D fill:#cce5ff
特性 サーバーコンポーネント クライアントコンポーネント
実行環境 サーバーサイド (Node.js) ブラウザ
JS バンドル ❌ 含まれない ✅ 含まれる
データベース/ファイルシステム ✅ 直接アクセス ❌ 利用不可 (API が必要)
React フック (useState/useEffect) ❌ 利用不可 ✅ 利用可能
イベント処理 (onClick/onSubmit) ❌ 利用不可 ✅ 利用可能
Async/Await ✅ ネイティブサポート ❌ 追加処理が必要

(1) 「ゼロクライアント JS」の意味

RSC のコア原則は: コンポーネントにインタラクティブロジックがなければ、そのコードはブラウザに送信されるべきではないということです。これは以下を意味します:

(2) RSC vs. 従来の SSR

従来の SSR (Pages Router) もサーバー上で HTML をレンダリングしますが、ハイドレーションのためにコンポーネントの JavaScript コードをクライアントに送信します。一方、RSC はまったく異なります。サーバーサイドコンポーネントのコードは決してクライアントに到達しません

次元 従来の SSR (Pages Router) RSC (App Router)
サーバーサイドレンダリング ✅ HTML ✅ HTML
クライアントハイドレーション ✅ 完全 ❌ 不要
コンポーネントコードがクライアントに送信される ✅ すべて ❌ クライアントコンポーネントのみ
状態保持 注意深い処理が必要 自然にステートレス
データ取得タイミング getServerSideProps コンポーネント内で直接 await

▶ サンプル: バンドルサイズの違いの検証 (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
Diagram: RSC Components; Database/File System/API; Serialize to RSC Payload React Flight Agreement; RSC Payload; Client Component Preserve the interaction logic; Pure HTML Rendering Zero JS Overhead.
TSX
// app/bundle-demo/page.tsx — サーバーコンポーネント (純粋にサーバーサイド)
import { format, addDays } from 'date-fns'

export default function BundleDemoPage() {
  const today = new Date()
  const dates = Array.from({ length: 7 }, (_, i) => {
    const d = addDays(today, i)
    return { label: format(d, 'EEEE'), date: format(d, 'yyyy-MM-dd') }
  })

  return (
    <div>
      <h1>This Week</h1>
      <ul>{dates.map(d => <li key={d.date}>{d.label}: {d.date}</li>)}</ul>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
ビルド後チェック .next/static/chunks — date-fns のコードが除外されている
💻 出力:

TEXT 📖 参照専用
ビルド後、.next/static/chunks ディレクトリには date-fns のコードが含まれていません — クライアントサイドの JS オーバーヘッドがゼロであることが確認されました。ページは "This Week" 見出し + 7つのリスト項目 (例: "Monday: 2026-07-06", "Tuesday: 2026-07-07", ...) をレンダリングします。


4. 'use client' ディレクティブとクライアント境界

'use client' はモジュールレベルのディレクティブで、ファイル内のコンポーネントをクライアントコンポーネントとしてマークします。RSC コンポーネントツリーでインタラクティブ機能 (useStateonClickuseEffect) が必要な場合、このディレクティブをファイルの先頭に追加する必要があります。

100%
graph TB
    A[ルートレイアウト<br/>サーバーコンポーネント] --> B[NavBar<br/>サーバーコンポーネント]
    A --> C[DashboardPage<br/>サーバーコンポーネント]
    C --> D[SalesChart<br/>'use client']
    C --> E[DataTable<br/>サーバーコンポーネント]
    D --> F[インタラクションロジック<br/>useState / useEffect]

    style A fill:#d4edda
    style B fill:#d4edda
    style C fill:#d4edda
    style D fill:#cce5ff
    style E fill:#d4edda
命令 機能
'use client' モジュールをクライアントコンポーネントとしてマーク 'use client'; export default function Btn() { ... }
'use server' 関数をサーバーアクションとしてマーク 'use server'; export async function create() { ... }

(1) 境界透過ルール

RSC コンポーネントツリーには2つの鉄則があります:

TSX
// ✅ 正しい: サーバーコンポーネントがクライアントコンポーネントをインポート
// app/page.tsx (Server)
import ClientCounter from './ClientCounter'
export default function Page() {
  return <ClientCounter />
}

// app/ClientCounter.tsx (Client)
'use client'
import { useState } from 'react'
export default function ClientCounter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
TSX
// ❌ エラー: クライアントコンポーネントはサーバーコンポーネントを直接インポートできない
// app/ClientList.tsx
'use client'
import ServerItem from './ServerItem'  // ❌ コンパイルエラー: サーバーコンポーネントはクライアントサイドでインポートできない

export default function ClientList() {
  return <ServerItem />  // この行はエラーを引き起こす
}

(2) クライアントコンポーネントにサーバーコンポーネントを埋め込む方法

ヒント: children props でデータを渡します。クライアントコンポーネントの children スロットは、サーバーコンポーネントからのレンダリング結果を受け取ることができます。

TSX
// app/layout.tsx (Server)
import ClientShell from './ClientShell'
import ServerSidebar from './ServerSidebar'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <ClientShell sidebar={<ServerSidebar />}>
      {children}
    </ClientShell>
  )
}

▶ サンプル: Props 伝達の正しい方法 — Children Props (難易度 ⭐⭐)

💻 出力:

TEXT 📖 参照専用
Includes a sidebar.
Visible text: }>
      {children}
TSX
// app/interleaving/ClientWrapper.tsx
'use client'
import { useState } from 'react'

export default function ClientWrapper({ sidebar, children }: {
  sidebar: React.ReactNode
  children: React.ReactNode
}) {
  const [isOpen, setIsOpen] = useState(true)
  return (
    <div style={{ display: 'flex' }}>
      {isOpen && <aside>{sidebar}</aside>}
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      <main>{children}</main>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
An interactive component with state management.
Visible text: }
TSX
// app/interleaving/page.tsx (Server)
import ClientWrapper from './ClientWrapper'
import { getSidebarData } from '@/lib/db'

export default function InterleavingPage() {
  const items = getSidebarData() // サーバーサイドでデータを取得
  return (
    <ClientWrapper sidebar={<ServerItemList items={items} />}>
      <h1>Main Content</h1>
    </ClientWrapper>
  )
}

async function ServerItemList({ items }: { items: string[] }) {
  return <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>
}

▶ サンプル: シリアライズ可能な Props の制限 (難易度: ⭐⭐⭐)

💻 出力:

TEXT 📖 参照専用
Renders a list of items using .map().
Visible text: }> | Main Content
TSX
// app/serializable/page.tsx (Server)
function greet() { return 'hello' }  // ❌ 関数はシリアライズ不可
const date = new Date()              // ⚠️ Date は RSC Props として許可されない

// app/serializable/ClientComponent.tsx
'use client'
export default function ClientComponent(props: {
  fn: () => string       // ❌ 関数を props に → 実行時エラー
  date: Date             // ⚠️ Date → 文字列に変換され、タイムゾーンが欠落する可能性
  data: { name: string } // ✅ 通常のオブジェクトは可
}) {
  return <div>{props.data.name}</div>
}
💻 出力:

TEXT 📖 参照専用
Renders the ClientComponent component UI.


5. React Flight ペイロードとコンポーネントツリーの統合

RSC のサーバーサイドレンダリングが完了すると、RSC ペイロード (React Flight プロトコル) と呼ばれる特別なデータ形式が出力されます。これにはシリアライズされた HTML ツリー、コンポーネント参照、props データが含まれます。クライアントがペイロードを受信すると、ローカルのクライアントコンポーネントと統合して最終的なコンポーネントツリーを形成します。

(1) RSC ペイロードの構造

100%
sequenceDiagram
    participant Server as Next.js サーバー
    participant Client as ブラウザ

    Server->>Server: RSC コンポーネントツリーを実行
    Server->>Server: React Flight ペイロードにシリアライズ
    Server->>Client: RSC ペイロード + HTML を送信
    Client->>Client: Flight ペイロードを解析
    Client->>Client: クライアントコンポーネントを統合 (ハイドレート)
    Client->>Client: 最終的なインターフェースをレンダリング
コンポーネント 説明
サーバーコンポーネント出力 シリアライズされた HTML フラグメント <div><h1>Dashboard</h1></div>
クライアントコンポーネント参照 モジュール ID + Props {id: "./chart.js", props: {data: [...]}}
データ参照 データベースクエリ結果 {sales: [{id:1, amount: 100}]}
ストリーム Suspense 境界分割 複数チャンクを段階的に送信

▶ サンプル: RSC ペイロードの表示 (難易度: ⭐⭐)

ブラウザの開発者ツールの Network パネルで RSC レスポンスを表示します:

BASH
# Network パネルを開き、ページをリフレッシュし、Fetch/XHR をフィルタリング
# 現在のページのリクエストを見つけ、Response を表示
# Content-Type: text/x-component が RSC ペイロード
💻 出力:

TEXT 📖 参照専用
# RSC ペイロード抜粋 (簡略化):
M1:{"id":"./app/page.tsx","chunks":["app/page-abc123.js"]}
J0:["$","div",null,{"children":["$","h1",null,{"children":"Dashboard"}]}]
S1:"react.suspense"

▶ サンプル: クライアントコンポーネントがサーバーコンポーネントを参照する方法 (難易度: ⭐⭐⭐)

TSX
// app/flight-demo/ServerData.tsx — 純粋なサーバーサイドデータコンポーネント
export default async function ServerData() {
  const data = await fetch('https://api.example.com/data').then(r => r.json())
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}
TSX
// app/flight-demo/ClientShell.tsx
'use client'
export default function ClientShell({ dataSlot }: { dataSlot: React.ReactNode }) {
  return (
    <div className="card">
      <h2>Client Shell</h2>
      <div className="server-data">{dataSlot}</div>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
Renders: Client Shell
Visible text: Client Shell
TSX
// app/flight-demo/page.tsx
import ClientShell from './ClientShell'
import ServerData from './ServerData'

export default function FlightDemoPage() {
  return (
    <ClientShell dataSlot={<ServerData />}>
  )
}


6. 完全な例: RSC コンポーネントツリーアーキテクチャ

TSX
// app/rsc-architecture/layout.tsx — RSC レイアウト
import ClientShell from './ClientShell'
import { getUser } from '@/lib/auth'

export default async function RscLayout({ children }: { children: React.ReactNode }) {
  const user = await getUser()                    // ✅ 直接データベースクエリ
  return (
    <ClientShell username={user?.name ?? 'Guest'}>
      <nav>
        <a href="/">Home</a>
        <a href="/dashboard">Dashboard</a>
        <a href="/settings">Settings</a>
      </nav>
      {children}
    </ClientShell>
  )
}

// app/rsc-architecture/ClientShell.tsx
'use client'
import { useState } from 'react'
import type { ReactNode } from 'react'

export default function ClientShell({ username, children }: {
  username: string
  children: ReactNode
}) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')
  return (
    <div data-theme={theme}>
      <header>
        <span>Welcome, {username}</span>
        <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
          Toggle {theme}
        </button>
      </header>
      {children}
    </div>
  )
}

// app/rsc-architecture/page.tsx
import { getProjects } from '@/lib/db'

export default async function RscArchitecturePage() {
  const projects = await getProjects()
  return (
    <div>
      <h1>Projects ({projects.length})</h1>
      <table>
        <thead><tr><th>Name</th><th>Status</th></tr></thead>
        <tbody>
          {projects.map(p => (
            <tr key={p.id}>
              <td>{p.name}</td>
              <td><StatusBadge status={p.status} /></td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    active: '#4caf50', archived: '#9e9e9e', draft: '#ff9800'
  }
  return <span style={{ background: colors[status] ?? '#ccc', padding: '2px 8px', borderRadius: 4 }}>{status}</span>
}

❓ よくある質問

Q RSC と SSR は同じものですか?
A いいえ。従来の SSR では、サーバー上で HTML をレンダリングした後も JavaScript コードがクライアントに送信されます (ハイドレーション)。RSC では、サーバーコンポーネントのコードはクライアントに送信されません — JavaScript オーバーヘッドゼロです。SSR と RSC は共存できます (App Router のデフォルトモードは RSC と SSR の組み合わせです)。
Q 'use client' ファイル内のすべてのコンポーネントはクライアントコンポーネントですか?
A はい。'use client' はモジュールレベルのディレクティブで、ファイルからエクスポートされるすべてのコンポーネントがクライアントコンポーネントになります。インタラクティブコンポーネントを別ファイルに分割して、クライアントサイドコードの量を減らすことを推奨します。
Q クライアントコンポーネントがサーバーコンポーネントを直接インポートできないのはなぜですか?
A サーバーコンポーネントはサーバーサイド実行環境にのみ存在するためです。クライアントコンポーネントがブラウザで実行される際、サーバーコンポーネントのコードはまったく存在しません。正しいアプローチは、children prop またはサーバーアクションを使って両者を橋渡しすることです。
Q 関数をクライアントコンポーネントに props として渡すとどうなりますか?
A エラーがスローされます。RSC ペイロードは JSON シリアライズ (React Flight プロトコル) に基づいており、関数はシリアライズできません。コールバックを渡す必要がある場合は、サーバーアクションまたはイベントハンドラパターンを使用します。
Q コンポーネントをサーバーとクライアントのどちらにすべきか、どう判断しますか?
A 最もシンプルな経験則: コンポーネントにインタラクティブ性が必要な場合 (useState、useEffect、onClick、ブラウザ API)、クライアント。それ以外はデフォルトでサーバーコンポーネントを使用します。一般的な最適化戦略は、インタラクティブ部分を小さなクライアントラッパーに抽出し、メイン本体はサーバーコンポーネントのままにすることです。
Q RSC ペイロードと HTML の関係は?
A Next.js 16 は HTML (即時ローディング用) と RSC ペイロード (コンポーネントツリー再構築用) の両方を送信します。HTML は最初の画面が即座に表示されることを保証し、RSC ペイロードはクライアントで解析された後にインタラクションを引き継ぎます。両者が「即時ファーストビュー + 完全なインタラクティブ性」を実現します。

📖 まとめ


📝 練習問題

  1. 基礎問題 (⭐): app/page.tsx にサーバーコンポーネントを作成し、fetch('https://api.github.com/repos/vercel/next.js') を直接呼び出してデータを取得し、スター評価をレンダリングしてください。クライアントが node-fetch などのライブラリをダウンロードしていないことを検証してください。

  2. 発展問題 (⭐⭐): クライアントコンポーネント (カウンターボタン) とサーバーコンポーネント (ユーザー一覧) を含むページを作成し、children prop を使用してサーバーデータをクライアントラッパーに渡してください。ブラウザの DevTools の Network タブで RSC ペイロードレスポンスを検証してください。

  3. チャレンジ (⭐⭐⭐): 3層コンポーネントツリーを構築してください: Layout (Server) → ClientTabs (Client、現在のタブを管理する useState を含む) → ServerTabContent (Server Components、children 経由で渡される)、各タブのコンテンツは異なる非同期データクエリで構成されます。すべてのサーバーコンポーネントのデータ取得がクライアントサイドのバンドルサイズを増やさないことを確認してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%