React Server Components 本番運用ガイド:アーキテクチャパターン、パフォーマンス最適化と15の落とし穴

前端工程

RSCが私たちのメンタルモデルを破壊した一週間

200ルートのダッシュボードをReact Server Componentsに移行しました。最初の一週間は大混乱でした。開発者たちは繰り返し質問しました:「useStateはどこに?」「このコンポーネントをインポートできないのはなぜ?」「ページはレンダリングされるのにクリックが効かない。」3回の本番障害を経て、問題はReactではなくメンタルモデルにあると気づきました。

React Server Componentsは「SSR 2.0」ではありません。根本的に異なる実行モデルです:コンポーネントはサーバー上でのみ実行され、そのJavaScriptは決してクライアントに送信されません。この記事では、6ヶ月間の実運用経験を、実践可能なパターン、アンチパターン、アーキテクチャの意思決定に凝縮しました。


メンタルモデルの転換:Server ≠ SSR

最大の混乱ポイント:RSCはSSRではありません。以下の表で関係を明確にします:

概念 コードの実行場所 JSをクライアントに送信? アクセス可能なもの
SSR(従来型) リクエスト時にサーバー はい(ハイドレーション用バンドル) リクエストコンテキスト、DB
Client Component ハイドレーション後にクライアント はい(完全なバンドル) ブラウザAPI、state、effects
Server Component(RSC) サーバーのみ、クライアントには行かない いいえ DB、ファイルシステム、シークレット
"use client" バウンダリ サーバー→クライアントの変換点 はい ディレクティブでありランタイムではない

重要な洞察:Server Componentを使えば、データベースクエリとその結果をサーバー上に保持できます。機密データをpropsにシリアライズすることも、ORMをブラウザに送信することもありません。コンポーネントはデータベースから読み取り、Reactツリーにレンダリングし、レンダリング出力だけがクライアントに届きます。

// この関数全体がサーバー上で実行される
// このコンポーネントのJSはブラウザに一切送信されない
async function UserProfile({ userId }: { userId: string }) {
  // データベースに直接アクセス ― APIルートもfetchもクライアントサイドのデータ取得も不要
  const user = await db.user.findUnique({
    where: { id: userId },
    include: { posts: true, settings: true },
  });

  // 秘密鍵、トークン、環境変数 ― すべてここでは安全
  const avatarUrl = await s3.getSignedUrl(`avatars/${user.avatarKey}`);

  // レンダリングされたHTMLだけがクライアントに届く
  return (
    <div>
      <h1>{user.name}</h1>
      <Avatar url={avatarUrl} /> {/* これはClient Componentでも可 */}
      <PostList posts={user.posts} />
    </div>
  );
}

従来のモデル(getServerSidePropsですべてをJSONにシリアライズし、クライアントコンポーネントがAPIルート経由でアバターURLを再取得する)と比較してください。ネットワークのウォーターフォール削減だけでも移行の価値があります。


Server/Clientバウンダリ:ここが難しい

"use client"ディレクティブがバウンダリをマークします。インポートツリーでそれより上のものはすべてサーバーで実行され、それより下のものはクライアントで実行されます。シンプルに聞こえますが、その影響はアーキテクチャ全体に波及します。

ルール1:Server ComponentはClient Componentをインポートできる

// page.tsx — Server Component(ディレクティブなし)
import { InteractiveChart } from "./InteractiveChart"; // Client Component
import { fetchChartData } from "@/lib/data";

export default async function DashboardPage() {
  const data = await fetchChartData(); // サーバー上で実行

  return (
    <div>
      <h1>売上ダッシュボード</h1>
      {/* Server Componentがサーバー取得データをClient Componentに渡す */}
      <InteractiveChart data={data} />
    </div>
  );
}
// InteractiveChart.tsx — Client Component
"use client";

import { LineChart } from "recharts";
import { useState } from "react";

export function InteractiveChart({ data }: { data: ChartData[] }) {
  const [range, setRange] = useState<"7d" | "30d" | "90d">("7d");

  return (
    <div>
      <select value={range} onChange={(e) => setRange(e.target.value as any)}>
        <option value="7d">7日間</option>
        <option value="30d">30日間</option>
        <option value="90d">90日間</option>
      </select>
      <LineChart data={filterByRange(data, range)}>{/* ... */}</LineChart>
    </div>
  );
}

このパターン「サーバーで取得、クライアントで操作」は、以前useEffect + fetchのボイラープレートだったものの80%を置き換えます。

ルール2:Client ComponentはServer Componentを直接インポートできない

これは動作しません:

// ❌ Client ComponentがServer Componentをインポートしようとしている
"use client";
import { ServerOnlyWidget } from "./ServerOnlyWidget"; // エラー

しかし、childrenパターンを通じて合成できます:

// ✅ 親のServer ComponentからchildrenとしてServer Componentを渡す
// page.tsx(Server Component)
import { ClientShell } from "./ClientShell";
import { ServerWidget } from "./ServerWidget";

export default function Page() {
  return (
    <ClientShell>
      <ServerWidget /> {/* サーバーでレンダリング、事前レンダリングされたchildrenとして渡す */}
    </ClientShell>
  );
}
// ClientShell.tsx
"use client";
import { useState } from "react";

export function ClientShell({ children }: { children: React.ReactNode }) {
  const [collapsed, setCollapsed] = useState(false);
  return (
    <div className={collapsed ? "w-16" : "w-64"}>
      <button onClick={() => setCollapsed(!collapsed)}>折りたたみ</button>
      {children} {/* ServerWidgetのレンダリング結果がここに表示される */}
    </div>
  );
}

この「スロット」パターンがサーバーとクライアントのコンテンツを織り交ぜる主要な手段です。私はこれをトロイの木馬パターンと呼んでいます:Client Componentが木馬で、Server Componentが中の兵士です。


ストリーミングSSR:パフォーマンスを左右する機能

RSCの本当の強みはサーバーサイドレンダリングそのものではなく、ストリーミングです。すべてのデータの読み込みを待ってから最初のバイトを送信するのではなく、サーバーはページを断片的にストリーミングします。

アーキテクチャ

HTTPレスポンスストリーム:
┌─────────────────────────────────────────────────────────────┐
│ <!DOCTYPE html><head>...</head><body>                       │
│   <Header />          ← 即時送信(静的コンテンツ)              │
│   ── Suspenseバウンダリ ──                                    │
│   <Skeleton />        ← 読み込み中はスケルトンを表示           │
│   ── 200ms後 ──                                              │
│   <ProductList />     ← データ準備完了でスケルトンを置換       │
│   ── Suspenseバウンダリ ──                                    │
│   <Skeleton />                                               │
│   ── 800ms後 ──                                              │
│   <Reviews />         ← データ準備完了でスケルトンを置換       │
│ </body></html>                                              │
└─────────────────────────────────────────────────────────────┘

実装

import { Suspense } from "react";
import { ProductList, ProductListSkeleton } from "./ProductList";
import { Reviews, ReviewsSkeleton } from "./Reviews";
import { Recommendations } from "./Recommendations";

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* 静的コンテンツはストリーム内で即座に送信 */}
      <Header />
      <Breadcrumb productId={params.id} />

      {/* シェルを先に送信、コンテンツは後からストリーミング */}
      <Suspense fallback={<ProductListSkeleton />}>
        <ProductList productId={params.id} />
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>

      {/* Suspenseバウンダリのネストも可能 */}
      <Suspense fallback={<div>おすすめを読み込み中...</div>}>
        <Recommendations productId={params.id} />
      </Suspense>
    </div>
  );
}

私たちが確認した主要指標の改善:TTFBはほぼ変わらず(~150ms)でしたが、LCPは2.8sから0.9sに低下しました。ページがプログレッシブにストリーミングされるため、ユーザーはより早く意味のあるコンテンツを認識できます。

ストリーミング + PPR(部分事前レンダリング)

2026年、Next.jsはPartial Prerenderingをサポートし、単一のHTTPレスポンス内で静的コンテンツと動的コンテンツを組み合わせられます:

// ラッパーは静的(ビルド時に事前レンダリング)
// Suspenseの穴は動的(リクエスト時にストリーミング)

export default function Page() {
  return (
    <div className="static-shell">
      {/* これらは静的 ― ビルド時に事前レンダリング */}
      <StaticNav />
      <StaticSidebar />

      {/* この穴には動的コンテンツがストリーミングされる */}
      <Suspense fallback={<CartSkeleton />}>
        <DynamicCart /> {/* ユーザー固有のカートデータ */}
      </Suspense>
    </div>
  );
}

PPRは静的生成と動的コンテンツのトレードオフを解消します。マーケティングページは即時の静的配信を受け、パーソナライズされた部分はブロックせずにストリーミングされます。


Server Actions:強力だが危険

Server Actionsを使用すると、フォーム送信やイベントハンドラから直接サーバーサイド関数を呼び出せます。APIルートもfetchもシリアライズのボイラープレートも不要です。

正しい使い方

// actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  published: z.boolean().default(false),
});

export async function createPost(formData: FormData) {
  const session = await getServerSession();
  if (!session?.user) {
    return { error: "認証されていません" };
  }

  // 常にサーバーでバリデーション ― クライアントの入力を信頼しない
  const parsed = CreatePostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
    published: formData.get("published") === "true",
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  const post = await db.post.create({
    data: {
      ...parsed.data,
      authorId: session.user.id,
    },
  });

  revalidatePath("/posts");
  return { postId: post.id };
}
// CreatePostForm.tsx
"use client";

import { useFormStatus } from "react-dom";
import { createPost } from "./actions";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "公開中..." : "記事を公開"}
    </button>
  );
}

export function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" required maxLength={200} />
      <textarea name="content" required minLength={10} />
      <label>
        <input type="checkbox" name="published" />
        すぐに公開
      </label>
      <SubmitButton />
    </form>
  );
}

Server Actionsの一般的なアンチパターン

アンチパターン1:useEffect内でServer Actionを呼び出す

// ❌ Server Actionはエフェクト内のデータ取得用ではない
"use client";
import { fetchPosts } from "./actions";

function PostList() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetchPosts().then(setPosts);
  }, []);

  return <div>{/* 記事一覧をレンダリング */}</div>;
}

データ取得にはServer Componentを使用します。Server Actionsはミューテーション(作成、更新、削除)用です。

アンチパターン2:Server Actionで機密ロジックを露出する

// ❌ このServer Actionの管理者チェックはバイパス可能
"use server";
export async function deleteUser(userId: string) {
  // Server Action内で認証チェックを絶対にスキップしない
  await db.user.delete({ where: { id: userId } });
}

アンチパターン3:巨大なシリアライズクロージャ

// ❌ モジュール全体の依存関係がクロージャの一部になる
"use server";

import { heavyLibrary } from "heavy-lib"; // 2MBのシリアライズクロージャ!

export async function doThing() {
  heavyLibrary.process();
}

Server Actionsはクロージャをシリアライズします。大きなインポートはクライアントに送信されるペイロードを増加させます。Server Actionsは軽量に保ちましょう。


15の最も一般的なRSCの落とし穴

落とし穴1:Server ComponentでHooksを使用する

// ❌ Server ComponentにはuseState、useEffect、useContextは存在しない
export default function Page() {
  const [count, setCount] = useState(0); // エラー
  // ...
}

修正:インタラクティブな部分をClient Componentに抽出するか、URLサーチパラメータで状態を管理します。

落とし穴2:バウンダリ越しに関数をpropsとして渡す

// ❌ Server ComponentがClient Componentに関数を渡そうとしている
export default function Page() {
  const handleClick = () => { /* サーバーで実行、シリアライズ失敗 */ };

  return <ClientButton onClick={handleClick} />; // エラー
}

関数はシリアライズ不可です。Server Actionsを使用するか、Client Component内でイベントハンドラを定義します。

落とし穴3:イベントハンドラに"use client"を付け忘れる

// ❌ onClickはClient Componentである必要がある
export default function Button() {
  return <button onClick={() => alert("clicked")}>クリック</button>; // エラー
}

落とし穴4:Server Componentでの過剰フェッチ

// ❌ idとnameだけ必要なのにSELECT *
const users = await db.user.findMany(); // すべてのカラムを取得

// ✅ レンダリングに必要なカラムだけを選択
const users = await db.user.findMany({
  select: { id: true, name: true },
});

落とし穴5:React.cache()で重複排除していない

// ✅ 単一レンダーパス内でDB呼び出しを重複排除
import { cache } from "react";

const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});

// 同一リクエスト内で3つの異なるServer Componentから呼び出されても
// 実際のデータベースクエリは1回だけ実行される
export async function UserProfile({ userId }: { userId: string }) {
  const user = await getUser(userId);
  return <div>{user.name}</div>;
}

export async function UserSettings({ userId }: { userId: string }) {
  const user = await getUser(userId); // キャッシュヒット ― 2回目のクエリなし
  return <div>{user.email}</div>;
}

落とし穴6:Client Componentツリーが大きすぎる

"use client"バウンダリ以下のすべてのコンポーネントのJavaScriptがブラウザに送信されます。バウンダリを可能な限りツリーの下位に押し下げてください

// ❌ ページ全体がClient Componentになっている
"use client";
export default function Page() {
  return (
    <div>
      <StaticHeader />
      <StaticContent />
      <LikeButton /> {/* これだけがインタラクティブ */}
      <StaticFooter />
    </div>
  );
}
// ✅ インタラクティブな部分だけがClient Component
// page.tsx — Server Component
import { LikeButton } from "./LikeButton";

export default function Page() {
  return (
    <div>
      <StaticHeader />   {/* Server Component */}
      <StaticContent />  {/* Server Component */}
      <LikeButton />     {/* これだけがJSを送信 */}
      <StaticFooter />   {/* Server Component */}
    </div>
  );
}

落とし穴7:Date/Timeのシリアライズ問題

// ❌ Dateオブジェクトはバウンダリ越しにクリーンにシリアライズされない
const post = await db.post.findUnique({ where: { id } });
return <ClientTimeline post={post} />; // post.createdAtはDateオブジェクト
// ✅ バウンダリ越しの前にDateを文字列に変換
const post = await db.post.findUnique({ where: { id } });
return (
  <ClientTimeline
    post={{
      ...post,
      createdAt: post.createdAt.toISOString(), // 文字列、Dateではない
    }}
  />
);

落とし穴8:CSS-in-JSライブラリがRSC非対応

多くのCSS-in-JSライブラリ(emotion、styled-components、古いMaterial UI)はランタイムJavaScriptを必要とし、Server Componentでは動作しません。Tailwind CSS、CSS Modules、またはゼロランタイムソリューション(Panda CSS、Vanilla Extract)を使用してください。

落とし穴9:Server ComponentでContext Providerを使用する

// ❌ React.createContext / Providerはクライアント機能
export default function Layout({ children }) {
  return (
    <ThemeProvider>
      {children}
    </ThemeProvider>
  );
}
// ✅ ProviderをClient Componentでラップする
// ProviderWrapper.tsx
"use client";
export function ProviderWrapper({ children }) {
  return <ThemeProvider>{children}</ThemeProvider>;
}

// layout.tsx
export default function Layout({ children }) {
  return <ProviderWrapper>{children}</ProviderWrapper>;
}

落とし穴10:LoadingとErrorバウンダリの無視

Server Componentは例外をスローする可能性があります。エラーバウンダリがないと、1回のデータベースクエリ失敗でページ全体がクラッシュします。データ依存セクションには必ずerror.tsx(Next.js)または<ErrorBoundary>を設置してください。

落とし穴11:ミューテーション後の古いキャッシュデータ

// ミューテーション後、Next.jsにキャッシュデータの更新を通知する
import { revalidatePath, revalidateTag } from "next/cache";

export async function updateProfile(data: FormData) {
  await db.user.update({ /* ... */ });
  revalidatePath("/profile");         // この特定のページを再検証
  revalidateTag("user-settings");     // 'user-settings'タグの付いたものをすべて再検証
}

落とし穴12:同一ルートでの静的と動的の混在

cookies()headers()を1回呼び出すだけでルート全体が動的レンダリングになります。PPR(部分事前レンダリング)を使用するか、動的部分をSuspenseバウンダリ内に分離してください。

落とし穴13:ネストされたSuspenseバウンダリによるレイアウトシフト

ネストされたSuspenseが解決されると、その下のコンテンツが押し下げられる可能性があります。常に期待されるスペースを確保するfallbackを提供してください:

<Suspense fallback={<div style={{ minHeight: "400px" }}><Skeleton /></div>}>
  <DynamicContent />
</Suspense>

落とし穴14:"use server"もディレクティブであることを忘れる

"use server"はファイルがServer Actionsを含むことをマークします。"use client"とは異なり、クライアントバウンダリを作成せず、呼び出し可能なサーバーエンドポイントを作成します。これらは異なるメカニズムです。

落とし穴15:移行効果の測定を怠る

移行前:バンドルサイズ(@next/bundle-analyzer)、Lighthouseスコア、Core Web Vitalsを測定します。移行後:再度測定します。私たちのダッシュボードでは初回読み込みJavaScriptが64%削減されました。指標なしでは推測しているだけです。


キャッシュ無効化戦略

RSCキャッシュは4つのレベルで動作します:

キャッシュレベル キャッシュ内容 持続時間 無効化方法
Router Cache ブラウザ内のRSCペイロード セッション / 30秒 router.refresh()、ナビゲーション
Full Route Cache サーバーでレンダリングされたHTML 再検証まで revalidatePath()revalidateTag()
Data Cache サーバーでのfetch()結果 再検証まで fetch(url, { next: { revalidate } })revalidateTag()
Request Memoization 単一レンダー内のfetch重複排除 リクエスト単位 自動

本番キャッシュ戦略

// lib/data.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";

// レイヤー1:リクエスト内重複排除(React cacheで自動)
const getProductInternal = cache(async (id: string) => {
  return db.product.findUnique({
    where: { id },
    include: { variants: true, reviews: { take: 10 } },
  });
});

// レイヤー2:タグベースの無効化が可能な永続キャッシュ
export const getProduct = unstable_cache(
  getProductInternal,
  ["product"],           // キャッシュキープレフィックス
  {
    tags: ["products"],  // グループ無効化用タグ
    revalidate: 3600,    // ミューテーションがなくても1時間ごとに再検証
  }
);

// ミューテーション内:
export async function updateProduct(id: string, data: FormData) {
  await db.product.update({ where: { id }, data: parseFormData(data) });
  revalidateTag("products"); // キャッシュされたすべての製品クエリを無効化
  revalidatePath(`/products/${id}`); // レンダリングされたページも無効化
}

Pages Routerからの移行:実際の戦闘計画

200以上のページを3ヶ月かけて移行しました。実証済みのアプローチです:

フェーズ1:共存(1〜2週目)

Next.jsは両方のルーターを同時にサポートします。新機能はApp Routerで、既存ページはPages Routerのまま:

src/app/           ← App Router(RSC、新コード)
src/pages/         ← Pages Router(既存コード、変更なし)

フェーズ2:リフト&シフト(3〜6週目)

各ページについて:

  1. src/pages/からsrc/app/に移動
  2. getServerSidePropsを非同期Server Componentに置換
  3. getStaticProps + getStaticPathsgenerateStaticParamsに置換
  4. インタラクティブ性が必要な場所にのみ"use client"を追加
// 移行前(Pages Router)
export async function getServerSideProps(ctx) {
  const post = await db.post.findUnique({ where: { id: ctx.params.id } });
  return { props: { post: JSON.parse(JSON.stringify(post)) } };
}

export default function PostPage({ post }) {
  const [liked, setLiked] = useState(false);
  return (/* ... */);
}
// 移行後(App Router — Server Component)
export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await db.post.findUnique({ where: { id: params.id } });
  const serialized = { ...post, createdAt: post.createdAt.toISOString() };
  return (
    <article>
      <h1>{post.title}</h1>
      <PostContent content={post.content} />
      <LikeButton post={serialized} />
    </article>
  );
}

// LikeButton.tsx — 独立したClient Component
"use client";
export function LikeButton({ post }) {
  const [liked, setLiked] = useState(false);
  return (/* ... */);
}

フェーズ3:最適化(7〜12週目)

  • すべてのデータ依存セクションにSuspenseバウンダリを追加
  • ルートレベルのローディング状態用にloading.tsxを追加
  • グレースフルなエラー処理用にerror.tsxを追加
  • ルートごとにバンドルサイズ削減を測定

移行時の注意点

  1. next/routernext/navigationuseRouternext/navigationからインポートされ、APIが異なります
  2. next/head → Metadata API:静的な<Head>タグはgenerateMetadata()エクスポートに置き換え
  3. _app.tsxlayout.tsx:レイアウトはラップではなくネスト。ナビゲーション間で状態が永続化されます
  4. req / resオブジェクト:Server Componentでは使用不可。代わりにheaders()cookies()を使用

意思決定マトリックス:いつ何を使うか

シナリオ 使用 理由
静的コンテンツ(ブログ、ドキュメント) Server Component + 静的生成 ビルド時レンダリング、サーバーコストゼロ
パーソナライズコンテンツ(ダッシュボード) Server Component + 動的レンダリング リクエストごとにユーザーデータを取得
リアルタイム更新(チャット、通知) Client Component + WebSocket ページ更新なしで更新
フォーム送信 Server Action APIルート不要、プログレッシブエンハンスメント
高インタラクティブ(図エディタ) Client Component DOM操作、ドラッグ&ドロップ
静的+動的混合(EC) PPR + Suspense 静的シェル+動的ホール

移行後のパフォーマンス数値

Pages RouterからRSCへのダッシュボード移行の実測データ:

指標 移行前(Pages Router) 移行後(RSC) 変化
初回読み込みJS(解析後) 843 KB 297 KB -64.7%
Largest Contentful Paint(P75) 2.8s 0.9s -67.9%
Time to Interactive 3.4s 1.2s -64.7%
First Input Delay(P75) 87ms 12ms -86.2%
Cumulative Layout Shift 0.21 0.03 -85.7%
リクエストあたりサーバーCPU 45ms 32ms -28.9%
サイト全体のビルド時間 4分12秒 2分48秒 -33.3%

クライアントに送信されるJavaScriptの大幅な削減がパフォーマンス向上の主な要因でした。ハイドレーション用にバンドルされていた依存関係ツリー全体を排除しました:Prismaクライアント(380KB)、date-fns(67KB)、zod(45KB)、その他ユーティリティライブラリ。


まとめ:React Server Componentsはhooks以来のReactにおける最も重要なパラダイムシフトです。成功の鍵は技術知識ではなくメンタルモデルにあります:Server Componentはデータの取得とレンダリングを行い、Client Componentはインタラクティブ性を追加する。バウンダリはコンポーネントツリーのできるだけ下位に配置する。ストリーミングでコンテンツをプログレッシブに配信する。Server Actions内ですべてを検証する。unstable_cacherevalidateTagで積極的にキャッシュする。移行前後で必ず測定する。パフォーマンスの向上は確実ですが、それには規律が必要です。


オンラインツール

ブラウザローカルツールを無料で試す →

#React#Server Components#RSC#Next.js#SSR#前端工程#2026