Next.js: サーバーコンポーネントのメンタルモデル
最終更新:2026-08-26
RSC メンタルモデルは Next.js 16 における最も重要なパラダイムシフトです。これを理解して初めて、App Router の背後にある設計思想を真に把握できます。
1. 学ぶこと
- RSC の定義と「ゼロクライアント JS」の仕組み
- サーバーコンポーネントとクライアントコンポーネントの間のレンダリング境界
'use client'命令とクライアント境界の透過ルール- ネストコンポーネントのルール: サーバー → クライアントは許可、クライアント → サーバーは不許可
- シリアライズ可能な Props の制限と React Flight ペイロード形式
2. フルスタック開発者の実話
(1) ペインポイント: バンドルサイズが制御不能
Alice は TaskFlow チームのフルスタック開発者です。彼女が構築した Dashboard ページにはデータテーブルコンポーネントが含まれており、日付のフォーマットといくつかの棒グラフを描画するためだけに date-fns、recharts、lodash の3つのライブラリを参照しています。ページの初期 JS バンドルは 480 KB に膨れ上がり、Lighthouse のパフォーマンススコアは 52 に低下しました。さらに悪いことに、このテーブルは単なるサーバーレンダリングされた純粋に表示用のコンポーネントであり、ユーザーはまったく操作する必要がないにもかかわらず、これらのライブラリのコードがクライアントにダウンロードされています。
(2) RSC の解決策
RSC は、サーバーサイドコンポーネントがサーバー上でのみ実行され、純粋な HTML とシリアライズされたデータを出力し、JavaScript バンドルが完全に除外されることを保証します。
// 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 バンドルに現れないため、大規模なライブラリを安全に使用し、データベースに直接アクセスし、ファイルシステムを読み取ることができます。
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 のコア原則は: コンポーネントにインタラクティブロジックがなければ、そのコードはブラウザに送信されるべきではないということです。これは以下を意味します:
date-fns、lodash、bcryptなどのサーバーサイド専用ライブラリを RSC で使用しても、バンドルサイズが増加しません- データベースクエリは API ルートを経由せずに、コンポーネント内で直接実行されます
- RSC は最終的に純粋な HTML 文字列を出力し、ブラウザが即座にレンダリングします
(2) RSC vs. 従来の SSR
従来の SSR (Pages Router) もサーバー上で HTML をレンダリングしますが、ハイドレーションのためにコンポーネントの JavaScript コードをクライアントに送信します。一方、RSC はまったく異なります。サーバーサイドコンポーネントのコードは決してクライアントに到達しません。
| 次元 | 従来の SSR (Pages Router) | RSC (App Router) |
|---|---|---|
| サーバーサイドレンダリング | ✅ HTML | ✅ HTML |
| クライアントハイドレーション | ✅ 完全 | ❌ 不要 |
| コンポーネントコードがクライアントに送信される | ✅ すべて | ❌ クライアントコンポーネントのみ |
| 状態保持 | 注意深い処理が必要 | 自然にステートレス |
| データ取得タイミング | getServerSideProps |
コンポーネント内で直接 await |
▶ サンプル: バンドルサイズの違いの検証 (難易度: ⭐⭐)
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.
// 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>
)
}
ビルド後チェック .next/static/chunks — date-fns のコードが除外されている
ビルド後、.next/static/chunks ディレクトリには date-fns のコードが含まれていません — クライアントサイドの JS オーバーヘッドがゼロであることが確認されました。ページは "This Week" 見出し + 7つのリスト項目 (例: "Monday: 2026-07-06", "Tuesday: 2026-07-07", ...) をレンダリングします。
4. 'use client' ディレクティブとクライアント境界
'use client' はモジュールレベルのディレクティブで、ファイル内のコンポーネントをクライアントコンポーネントとしてマークします。RSC コンポーネントツリーでインタラクティブ機能 (useState、onClick、useEffect) が必要な場合、このディレクティブをファイルの先頭に追加する必要があります。
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つの鉄則があります:
- ✅ サーバーコンポーネントはクライアントコンポーネントをインポートしてレンダリングできる
- ❌ クライアントコンポーネントはサーバーコンポーネントを直接インポートできない (サーバーコンポーネントはサーバーサイドにのみ存在するため)
// ✅ 正しい: サーバーコンポーネントがクライアントコンポーネントをインポート
// 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>
}
// ❌ エラー: クライアントコンポーネントはサーバーコンポーネントを直接インポートできない
// app/ClientList.tsx
'use client'
import ServerItem from './ServerItem' // ❌ コンパイルエラー: サーバーコンポーネントはクライアントサイドでインポートできない
export default function ClientList() {
return <ServerItem /> // この行はエラーを引き起こす
}
(2) クライアントコンポーネントにサーバーコンポーネントを埋め込む方法
ヒント: children props でデータを渡します。クライアントコンポーネントの children スロットは、サーバーコンポーネントからのレンダリング結果を受け取ることができます。
// 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 (難易度 ⭐⭐)
Includes a sidebar.
Visible text: }>
{children}
// 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>
)
}
An interactive component with state management.
Visible text: }
// 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 の制限 (難易度: ⭐⭐⭐)
Renders a list of items using .map().
Visible text: }> | Main Content
// 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>
}
Renders the ClientComponent component UI.
5. React Flight ペイロードとコンポーネントツリーの統合
RSC のサーバーサイドレンダリングが完了すると、RSC ペイロード (React Flight プロトコル) と呼ばれる特別なデータ形式が出力されます。これにはシリアライズされた HTML ツリー、コンポーネント参照、props データが含まれます。クライアントがペイロードを受信すると、ローカルのクライアントコンポーネントと統合して最終的なコンポーネントツリーを形成します。
(1) RSC ペイロードの構造
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 レスポンスを表示します:
# Network パネルを開き、ページをリフレッシュし、Fetch/XHR をフィルタリング
# 現在のページのリクエストを見つけ、Response を表示
# Content-Type: text/x-component が RSC ペイロード
# RSC ペイロード抜粋 (簡略化):
M1:{"id":"./app/page.tsx","chunks":["app/page-abc123.js"]}
J0:["$","div",null,{"children":["$","h1",null,{"children":"Dashboard"}]}]
S1:"react.suspense"
▶ サンプル: クライアントコンポーネントがサーバーコンポーネントを参照する方法 (難易度: ⭐⭐⭐)
// 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>
}
// 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>
)
}
Renders: Client Shell
Visible text: Client Shell
// app/flight-demo/page.tsx
import ClientShell from './ClientShell'
import ServerData from './ServerData'
export default function FlightDemoPage() {
return (
<ClientShell dataSlot={<ServerData />}>
)
}
6. 完全な例: RSC コンポーネントツリーアーキテクチャ
// 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>
}
❓ よくある質問
children prop またはサーバーアクションを使って両者を橋渡しすることです。📖 まとめ
- RSC (React Server Component) はサーバー上でのみ実行され、クライアントサイドの JavaScript オーバーヘッドがゼロです
'use client'はクライアントコンポーネントの境界を示します。インタラクションロジックはクライアントサイドに存在する必要があります- サーバーコンポーネントはクライアントコンポーネントをインポートできますが、その逆はできません (
childrenprop で回避可能) - シリアライズ可能 Props の制限: 関数、Date オブジェクト、
undefinedは RSC props として渡せません - React Flight ペイロードは RSC のシリアライズプロトコルで、HTML スニペット、コンポーネント参照、データを含みます
- ベストプラクティス: 可能な限りサーバーコンポーネントを使用し、インタラクションロジックを小さなクライアントラッパーに分離します
📝 練習問題
-
基礎問題 (⭐):
app/page.tsxにサーバーコンポーネントを作成し、fetch('https://api.github.com/repos/vercel/next.js')を直接呼び出してデータを取得し、スター評価をレンダリングしてください。クライアントがnode-fetchなどのライブラリをダウンロードしていないことを検証してください。 -
発展問題 (⭐⭐): クライアントコンポーネント (カウンターボタン) とサーバーコンポーネント (ユーザー一覧) を含むページを作成し、
childrenprop を使用してサーバーデータをクライアントラッパーに渡してください。ブラウザの DevTools の Network タブで RSC ペイロードレスポンスを検証してください。 -
チャレンジ (⭐⭐⭐): 3層コンポーネントツリーを構築してください: Layout (Server) → ClientTabs (Client、現在のタブを管理する
useStateを含む) → ServerTabContent (Server Components、children経由で渡される)、各タブのコンテンツは異なる非同期データクエリで構成されます。すべてのサーバーコンポーネントのデータ取得がクライアントサイドのバンドルサイズを増やさないことを確認してください。