React Server Components完全ガイド〜RSC・Client Components・Server Actions・Streaming【2026年版】〜

React Server Components(以下 RSC)は、React 19 と Next.js App Router の到来によって「実験的な何か」から本番運用の前提へと地位を変えました。本記事は、"use client" / "use server" ディレクティブ、Server Actions、Suspense Streaming、データフェッチ、認証、テストまでをコピペで動くコードを軸に総整理するハブ記事です。

対象読者は、useState / useEffect までは書けるが「いつ Server Component で書くべきか」「"use client" をどこに置くべきか」「Server Action とは結局 API ルートと何が違うのか」がモヤッとしている20〜40代の現役 Webエンジニアです。「描画はサーバー、操作はクライアント」という一貫した方針で進めます。

  1. RSC が解決した3つの構造的課題
    1. 従来の Client-only React の限界
    2. RSC で書き直すと何が消えるか
  2. “use client” と “use server” ディレクティブの本質
    1. “use client” は「境界」を引く
    2. “use server” は Server Action を作る
    3. “use client” と “use server” の比較表
  3. データフェッチ:fetch / cache / revalidate
    1. Server Component で fetch する基本形
    2. 並列フェッチでウォーターフォールを潰す
    3. cache() で関数単位のメモ化
    4. revalidate / revalidatePath / revalidateTag
  4. Server Action 完全実装パターン
    1. 最小の Server Action
    2. useFormStatus で送信中UIを出す
    3. useActionState でエラーと前回値を保持する
    4. useOptimistic で楽観的更新
    5. Server Action を Zod で型安全にする
  5. Suspense と Streaming で体感速度を上げる
    1. Suspense 境界を設計する
    2. loading.tsx で route 全体の Skeleton
    3. error.tsx で route 境界エラーをキャッチ
  6. Server Component と Client Component の境界設計
    1. Server → Client への props は serializable のみ
    2. children パターンで Server を Client に挿し込む
    3. third-party ライブラリの境界ラッパー
  7. DB 直接アクセス・認証・サーバー専用API
    1. Prisma を Server Component から直接呼ぶ
    2. Drizzle ORM を使う場合
    3. cookies() と headers() で認証を解決
    4. ログイン・ログアウトを Server Action で
  8. SEO・メタデータ・Edge runtime
    1. generateMetadata で動的 OGP
    2. Edge runtime と Node runtime の使い分け
  9. テスト戦略:RSC のテストは2層で考える
    1. Server Action のロジック単体テスト
    2. E2E は Playwright で route 全体を検証
  10. ハマりやすい落とし穴 Best 7
  11. 移行戦略:既存 SPA から RSC へ
  12. FAQ
    1. Q1. RSC は Next.js 専用の機能ですか?
    2. Q2. Server Component と SSR は何が違いますか?
    3. Q3. Server Action は API ルートと何が違いますか?
    4. Q4. RSC で global state(Zustand 等)はどう扱う?
    5. Q5. データフェッチに TanStack Query はもう要らない?
    6. Q6. RSC のデバッグはどうやる?
    7. Q7. Server Action の認可はどう書くべき?
  13. まとめ:RSC を「3つの境界」で捉える

RSC が解決した3つの構造的課題

RSC は単なる「SSR の進化版」ではありません。「クライアントに送る JS バンドルからサーバー専用のコードを完全に消す」ための新しいコンポーネントモデルです。これにより以下の3つが同時に解決されました。

従来の課題従来の解RSC の解
巨大な JS バンドルcode splitting / dynamic importそもそも RSC は JS を送らない
API ルート二度書きBFF / tRPC / GraphQLServer Component で直接 DB アクセス
ウォーターフォール fetchuseEffect 並列化 / SWRサーバー側で await 並列実行
シークレットの露出環境変数の慎重な分離サーバー専用コードに同梱可能
SEO 最適化SSR + hydration初期 HTML が常に完成形

従来の Client-only React の限界

従来の SPA では、データフェッチは useEffect 内で行うのが定番でした。これは「マウント後に取りに行く」ため、初期描画後にスピナーが出るという UX の悪さと、ウォーターフォール化しやすいという2つの問題を抱えていました。

// ❌ 従来パターン:useEffect でクライアントから fetch
"use client";
import { useEffect, useState } from "react";

type User = { id: string; name: string };

export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((r) => r.json())
      .then((data: User) => setUser(data))
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  if (!user) return <p>Not found</p>;
  return <h1>{user.name}</h1>;
}

RSC で書き直すと何が消えるか

同じ要件を RSC で書くと、useState / useEffect / API ルート / ローディング状態のすべてが消えます。async コンポーネントが React 標準になったことが RSC の本質です。

// ✅ RSC パターン:async Server Component
// app/users/[id]/page.tsx
import { db } from "@/lib/db";

type Props = { params: Promise<{ id: string }> };

export default async function UserPage({ params }: Props) {
  const { id } = await params;
  const user = await db.user.findUnique({ where: { id } });
  if (!user) return <p>Not found</p>;
  return <h1>{user.name}</h1>;
}

“use client” と “use server” ディレクティブの本質

App Router ではデフォルトが Server Componentです。"use client""use server" はファイル冒頭に書く「境界宣言」で、ビルド時にバンドル分割の判定基準になります。誤解されがちですが、これはランタイムのフラグではなくバンドラへの命令です。

“use client” は「境界」を引く

"use client" をファイル冒頭に書くと、そのファイルとそこから import されるモジュールがクライアントバンドルに含まれます。重要なのは 「子コンポーネントは自動的に Client になる」という点です。

// app/components/Counter.tsx
"use client";
import { useState } from "react";

export function Counter({ initial = 0 }: { initial?: number }) {
  const [count, setCount] = useState(initial);
  return (
    <button onClick={() => setCount((c) => c + 1)}>
      count: {count}
    </button>
  );
}

“use server” は Server Action を作る

"use server" は逆に 「ここから先はサーバーでしか動かない関数」を宣言します。ファイル先頭に書けばファイル全体が Server Action モジュールになり、関数内冒頭に書けばその関数だけがアクションになります。

// app/actions/posts.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = String(formData.get("title") ?? "");
  if (!title) throw new Error("title is required");
  await db.post.create({ data: { title } });
  revalidatePath("/posts");
}

“use client” と “use server” の比較表

項目“use client”“use server”
対象ファイル全体ファイルまたは関数
意味クライアント境界サーバー実行関数
useState / イベント使える使えない(コンポーネントではない)
DB 直接アクセス不可
環境変数(秘密)不可
主な用途インタラクションミューテーション

データフェッチ:fetch / cache / revalidate

RSC の最大の魅力はデータフェッチです。async コンポーネントの中で await fetchawait db.xxx をそのまま書けます。さらに React が fetch を自動でメモ化するため、同一リクエスト内で同じ URL を複数回呼んでも一度しかネットワークに出ない仕様です。

Server Component で fetch する基本形

// app/posts/page.tsx
type Post = { id: number; title: string };

async function getPosts(): Promise<Post[]> {
  const res = await fetch("https://api.example.com/posts", {
    // Next.js 拡張: 60秒キャッシュ
    next: { revalidate: 60 },
  });
  if (!res.ok) throw new Error("Failed to fetch");
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

並列フェッチでウォーターフォールを潰す

複数のデータを取りに行く場合、await を直列に並べるとウォーターフォールになります。Promise.all を使って並列化すれば、合計レイテンシは「最も遅い1本」に収束します。

// ❌ 直列(ウォーターフォール)
const user = await getUser(id);
const posts = await getPostsByUser(id); // user 完了を待ってから始まる

// ✅ 並列(Promise.all)
const [user, posts] = await Promise.all([
  getUser(id),
  getPostsByUser(id),
]);

cache() で関数単位のメモ化

fetch ではない関数(DB クエリなど)も reactcache() でリクエスト内メモ化できます。同一レンダリングツリー内で getUser(1) を何度呼んでも DB アクセスは1回に集約されます。

// lib/queries.ts
import { cache } from "react";
import { db } from "@/lib/db";

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

// 同じレンダー内なら何度呼んでも DB アクセスは1回
// page.tsx と header.tsx の両方から getUser(id) を呼んでも安全

revalidate / revalidatePath / revalidateTag

// 時間ベースの再検証
fetch(url, { next: { revalidate: 3600 } }); // 1時間

// タグベースの再検証(細粒度)
fetch(url, { next: { tags: ["posts"] } });

// Server Action 内から発火
"use server";
import { revalidatePath, revalidateTag } from "next/cache";

export async function createPost(data: FormData) {
  await db.post.create({ /* ... */ });
  revalidatePath("/posts");     // パス単位
  revalidateTag("posts");       // タグ単位
}

Server Action 完全実装パターン

Server Action は「フォーム送信を React 標準で扱えるようにした仕組み」です。API ルート(app/api/.../route.ts)を書かなくても、form action={serverFn} だけで POST が成立します。

最小の Server Action

// app/posts/new/page.tsx
import { createPost } from "@/app/actions/posts";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <button type="submit">Create</button>
    </form>
  );
}

useFormStatus で送信中UIを出す

送信中の状態は react-domuseFormStatus で取得します。これは Client Component 内でしか使えませんが、親フォームの状態を子から読めるのが特徴で、SubmitButton を独立コンポーネント化できます。

// app/components/SubmitButton.tsx
"use client";
import { useFormStatus } from "react-dom";

export function SubmitButton({ label }: { label: string }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} aria-busy={pending}>
      {pending ? "送信中..." : label}
    </button>
  );
}

useActionState でエラーと前回値を保持する

React 19 で導入された useActionState(旧 useFormState)は、Server Action の結果を state として扱う Hook です。バリデーションエラーや成功メッセージの表示が劇的に簡潔になります。

// app/components/PostForm.tsx
"use client";
import { useActionState } from "react";
import { createPostAction } from "@/app/actions/posts";
import { SubmitButton } from "./SubmitButton";

type State = { error?: string; success?: boolean };
const initial: State = {};

export function PostForm() {
  const [state, formAction] = useActionState(createPostAction, initial);
  return (
    <form action={formAction}>
      <input name="title" required />
      {state.error && <p role="alert">{state.error}</p>}
      {state.success && <p>作成しました</p>}
      <SubmitButton label="作成" />
    </form>
  );
}
// app/actions/posts.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

type State = { error?: string; success?: boolean };

export async function createPostAction(
  _prev: State,
  formData: FormData
): Promise<State> {
  const title = String(formData.get("title") ?? "").trim();
  if (title.length < 3) {
    return { error: "タイトルは3文字以上必要です" };
  }
  try {
    await db.post.create({ data: { title } });
    revalidatePath("/posts");
    return { success: true };
  } catch {
    return { error: "作成に失敗しました" };
  }
}

useOptimistic で楽観的更新

useOptimistic は、Server Action の完了を待たずに UI を先に更新する Hook です。ネットワークの体感速度がゼロに近づき、SNS 系の UX 改善に絶大な効果を発揮します。

// app/components/TodoList.tsx
"use client";
import { useOptimistic } from "react";
import { addTodo } from "@/app/actions/todos";

type Todo = { id: string; title: string; sending?: boolean };

export function TodoList({ initial }: { initial: Todo[] }) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    initial,
    (state: Todo[], newTitle: string): Todo[] => [
      ...state,
      { id: crypto.randomUUID(), title: newTitle, sending: true },
    ]
  );

  async function handleAction(formData: FormData) {
    const title = String(formData.get("title") ?? "");
    addOptimistic(title); // ⚡ 即座にUI更新
    await addTodo(formData); // サーバーは裏で完了
  }

  return (
    <>
      <form action={handleAction}>
        <input name="title" />
        <button>追加</button>
      </form>
      <ul>
        {optimisticTodos.map((t) => (
          <li key={t.id} style={{ opacity: t.sending ? 0.5 : 1 }}>
            {t.title}
          </li>
        ))}
      </ul>
    </>
  );
}

Server Action を Zod で型安全にする

Server Action は FormData を受け取るので、そのままだとフィールドはすべて文字列です。Zod でスキーマ検証することで、サーバー側の入力境界を型安全にできます。

// app/actions/posts.ts
"use server";
import { z } from "zod";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

const PostSchema = z.object({
  title: z.string().min(3).max(100),
  body: z.string().min(10),
  publishedAt: z.coerce.date().optional(),
});

type State =
  | { ok: true }
  | { ok: false; fieldErrors: Record<string, string[]> };

export async function createPost(
  _prev: State | null,
  formData: FormData
): Promise<State> {
  const parsed = PostSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
  }
  await db.post.create({ data: parsed.data });
  revalidatePath("/posts");
  return { ok: true };
}

Suspense と Streaming で体感速度を上げる

RSC の真価は Streaming SSR と組み合わせたときに発揮されます。重い部分を <Suspense> で囲めば、その部分だけが「あとから流れてくる」HTML として送られ、ユーザーは即座に骨組みを見られます。

Suspense 境界を設計する

// app/dashboard/page.tsx
import { Suspense } from "react";
import { UserPanel } from "./UserPanel";
import { SalesChart } from "./SalesChart";
import { RecentOrders } from "./RecentOrders";

export default function Dashboard() {
  return (
    <div>
      <h1>ダッシュボード</h1>
      {/* 速い部分は通常通り */}
      <UserPanel />

      {/* 重い部分は Suspense で分離 */}
      <Suspense fallback={<ChartSkeleton />}>
        <SalesChart />
      </Suspense>

      <Suspense fallback={<p>注文を読み込み中...</p>}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

function ChartSkeleton() {
  return <div className="h-64 animate-pulse bg-gray-200" />;
}

loading.tsx で route 全体の Skeleton

App Router では app/dashboard/loading.tsx を置くだけで、その route の Suspense fallback が自動配線されます。最も手軽な Streaming 導入方法です。

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="space-y-4">
      <div className="h-8 w-48 animate-pulse bg-gray-200" />
      <div className="h-64 w-full animate-pulse bg-gray-200" />
      <div className="h-32 w-full animate-pulse bg-gray-200" />
    </div>
  );
}

error.tsx で route 境界エラーをキャッチ

// app/dashboard/error.tsx
"use client"; // error.tsx は必ず Client

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div role="alert">
      <h2>エラーが発生しました</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>再試行</button>
    </div>
  );
}

Server Component と Client Component の境界設計

境界設計は RSC で最も悩むポイントです。原則は「Client は葉(leaf)に追いやる」。木の根元(layout、page)をできる限り Server に保ち、末端のボタンやインタラクションだけを Client にすると、JS バンドルが最小化されます。

Server → Client への props は serializable のみ

Server Component から Client Component に渡せる props には厳しい制約があります。関数(イベントハンドラなど)・クラスインスタンス・Date 以外の独自クラスは渡せません。シリアライズ可能なプリミティブと plain object のみが境界をまたげます。

渡せる渡せない
string / number / boolean / null関数(onClick など)
plain object / arrayクラスインスタンス(独自クラス)
Date / Map / Set / BigIntSymbol(registered 以外)
JSX(Server Component の出力)Promise 以外の thenable
Promise(React が解決を待つ)循環参照

children パターンで Server を Client に挿し込む

Client Component の子に Server Component を置くには、children prop を使う必要があります。これは RSC の最重要パターンの一つで、"use client" モジュール内で直接 import すると Client 化してしまうのを回避できます。

// ❌ ダメな例:Client の中で Server を import すると Client 化される
// app/components/Tabs.tsx
"use client";
import { ServerHeavyChart } from "./ServerHeavyChart"; // Client 化されてしまう
export function Tabs() { return <ServerHeavyChart />; }
// ✅ 正解:Client は children で受け取り、配置は Server で決める
// app/components/Tabs.tsx
"use client";
import { useState, type ReactNode } from "react";

export function Tabs({ children }: { children: ReactNode }) {
  const [tab, setTab] = useState(0);
  return (
    <div>
      <button onClick={() => setTab(0)}>A</button>
      <button onClick={() => setTab(1)}>B</button>
      {children}
    </div>
  );
}

// app/page.tsx (Server)
import { Tabs } from "./components/Tabs";
import { ServerHeavyChart } from "./components/ServerHeavyChart";

export default function Page() {
  return (
    <Tabs>
      <ServerHeavyChart />{/* これは Server のまま */}
    </Tabs>
  );
}

third-party ライブラリの境界ラッパー

react-select や framer-motion など Client 専用の third-party ライブラリは、薄いラッパーを作って "use client" を一回だけ書くと整理しやすいです。

// app/components/MotionDiv.tsx
"use client";
import { motion, type HTMLMotionProps } from "framer-motion";

export function MotionDiv(props: HTMLMotionProps<"div">) {
  return <motion.div {...props} />;
}

// これで Server Component から <MotionDiv> を import するだけで使える

DB 直接アクセス・認証・サーバー専用API

Prisma を Server Component から直接呼ぶ

// lib/db.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { db?: PrismaClient };
export const db = globalForPrisma.db ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.db = db;

// app/posts/page.tsx
import { db } from "@/lib/db";

export default async function Posts() {
  const posts = await db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: "desc" },
    take: 20,
  });
  return (
    <ul>
      {posts.map((p) => (<li key={p.id}>{p.title}</li>))}
    </ul>
  );
}

Drizzle ORM を使う場合

// lib/drizzle.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });

// app/users/page.tsx
import { db } from "@/lib/drizzle";
import { users } from "@/lib/schema";
import { desc } from "drizzle-orm";

export default async function UsersPage() {
  const rows = await db.select().from(users).orderBy(desc(users.createdAt)).limit(50);
  return <pre>{JSON.stringify(rows, null, 2)}</pre>;
}

cookies() と headers() で認証を解決

// app/account/page.tsx
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { verifySession } from "@/lib/auth";

export default async function AccountPage() {
  const cookieStore = await cookies();
  const token = cookieStore.get("session")?.value;
  if (!token) redirect("/login");

  const headersList = await headers();
  const ua = headersList.get("user-agent") ?? "unknown";

  const user = await verifySession(token);
  if (!user) redirect("/login");

  return (
    <section>
      <h1>{user.name} さんのアカウント</h1>
      <p>User-Agent: {ua}</p>
    </section>
  );
}

ログイン・ログアウトを Server Action で

// app/actions/auth.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { signIn, signOut } from "@/lib/auth";

export async function loginAction(formData: FormData) {
  const email = String(formData.get("email"));
  const password = String(formData.get("password"));
  const token = await signIn(email, password);
  (await cookies()).set("session", token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7,
  });
  redirect("/dashboard");
}

export async function logoutAction() {
  (await cookies()).delete("session");
  redirect("/login");
}

SEO・メタデータ・Edge runtime

generateMetadata で動的 OGP

// app/posts/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost } from "@/lib/queries";

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.ogImage }],
    },
  };
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <article dangerouslySetInnerHTML={{ __html: post.html }} />;
}

Edge runtime と Node runtime の使い分け

項目Edge runtimeNode runtime(デフォルト)
起動速度非常に速い(ミリ秒)普通
地理分散世界中の Edge から実行リージョン固定
使える APIWeb 標準のみNode.js API 全部
Prisma / 多くのORMNG(専用クライアントが必要)OK
fs / crypto(Node版)使用不可使用可
用途軽量API・認証・配信重い処理・DB アクセス
// route segment config
// app/api/edge/route.ts
export const runtime = "edge";

export async function GET() {
  return new Response("Hello from Edge", {
    headers: { "content-type": "text/plain" },
  });
}

テスト戦略:RSC のテストは2層で考える

RSC は純粋な単体テストが現状ややしづらい領域です。React Testing Library は Client Component のテストには引き続き有効ですが、Server Component は「関数として呼んで返り値の JSX を検証する」アプローチか、E2E に寄せるのが現実的です。

Server Action のロジック単体テスト

// app/actions/posts.test.ts
import { describe, it, expect, vi } from "vitest";
import { createPostAction } from "./posts";

vi.mock("@/lib/db", () => ({
  db: { post: { create: vi.fn().mockResolvedValue({ id: 1 }) } },
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));

describe("createPostAction", () => {
  it("3文字未満はエラー", async () => {
    const fd = new FormData();
    fd.set("title", "ab");
    const result = await createPostAction({}, fd);
    expect(result).toEqual({ error: "タイトルは3文字以上必要です" });
  });

  it("成功時は success:true を返す", async () => {
    const fd = new FormData();
    fd.set("title", "valid title");
    const result = await createPostAction({}, fd);
    expect(result.success).toBe(true);
  });
});

E2E は Playwright で route 全体を検証

// e2e/posts.spec.ts
import { test, expect } from "@playwright/test";

test("新規投稿フロー", async ({ page }) => {
  await page.goto("/posts/new");
  await page.fill('input[name="title"]', "RSC実践記事");
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL("/posts");
  await expect(page.getByText("RSC実践記事")).toBeVisible();
});

ハマりやすい落とし穴 Best 7

  • “use client” を root layout に書いてしまう:ツリー全体が Client 化されて RSC の恩恵がゼロに。境界は最も葉に近い場所に置く。
  • Server から Client に関数を props で渡す:エラーになる。イベントハンドラは Client 側で完結させる。
  • Server Action 内で例外を投げてクライアントに見せる:本番では digest しか出ない。戻り値の { error } で返す設計に統一。
  • cookies() を Server Component の上の方で呼んで全ページ動的化:意図せず static 化が失われる。必要な葉だけに閉じ込める。
  • fetch の dedupe を期待して別 URL を叩く:メモ化はキー(URL+メソッド)一致のみ。共通関数経由にする。
  • Edge runtime で Prisma 標準クライアント:動かない。Accelerate / Driver Adapters か Node runtime に。
  • revalidatePath(‘/’) 連発:キャッシュ全部飛ばし状態になる。タグ運用に切り替えるのが定石。

移行戦略:既存 SPA から RSC へ

既存の Pages Router / SPA から App Router + RSC への移行は段階的に行うのが鉄則です。一気に書き換えると現場が止まります。パフォーマンス改善記事とも合わせて、優先順位を決めて少しずつ進めます。

  1. App Router を共存させる:pages/app/ は同時に動かせる。新規 route から app/ に書く。
  2. データフェッチを Server Component に寄せる:既存の useEffect + fetch を順次 async コンポーネントへ。
  3. フォーム処理を Server Action に置換:app/api/* ルートのうちフォーム POST 用は Server Action に統合可能。
  4. Client コンポーネントを葉に追いやる:layout / page を Server に保ち、ボタンやモーダルだけ Client。
  5. Streaming を導入:重いセクションに <Suspense> を貼り、loading.tsx で skeleton 化。
  6. キャッシュ戦略を整える:revalidate と tag を整理し、最後に force-dynamic 等の細かい指定を見直す。

FAQ

Q1. RSC は Next.js 専用の機能ですか?

いいえ。RSC は React 本体の機能です。ただし RSC を実運用するにはバンドラとサーバーランタイムの連携が必要で、現状その実装は Next.js App Router がほぼ唯一の本番運用例です。Remix や Waku も追従中ですが、本記事では Next.js を前提に解説しました。

Q2. Server Component と SSR は何が違いますか?

SSR は「クライアント用の React コンポーネントを一度サーバーで描画してから JS を hydrate する」仕組みです。Server Component は「クライアントには絶対送られない React コンポーネント」で、JS バンドルにも含まれません。両者は併存可能で、ページ全体の HTML は SSR で生成されつつ、その中の RSC 部分は hydrate されない、という形になります。

Q3. Server Action は API ルートと何が違いますか?

Server Action は「関数として import して呼べる API」です。型がそのまま共有でき、エンドポイント設計や fetch のラッパーが要りません。ただし内部的には POST リクエストになるため、外部のクライアント(モバイルアプリなど)からも叩く想定なら従来通り route.ts の方が向きます。

Q4. RSC で global state(Zustand 等)はどう扱う?

Zustand や Jotai は Client Component の中だけで使います。RSC の世界には「クライアントの global state」は存在せず、サーバー側の状態は DB と URL search params が担当します。「URL を state にする」という発想に慣れると RSC とよく馴染みます。

Q5. データフェッチに TanStack Query はもう要らない?

初回ロードや SEO 重視のページは RSC が圧倒的に向きます。一方で「クライアント側でのリアルタイム更新」「楽観的更新の柔軟さ」「無限スクロール」などは TanStack Query が今も強力です。両者は補完関係にあり、「画面の骨格は RSC、頻繁に更新する部分は TanStack Query」という棲み分けが現実解です。

Q6. RSC のデバッグはどうやる?

Server Component はサーバー側のログ(ターミナル / Vercel Logs)に出るので、まずは console.log を入れて経路を確認します。React DevTools は Client Component しか可視化しないので、Server 側は「サーバーログ + Network タブで HTML を覗く」のが基本動線です。

Q7. Server Action の認可はどう書くべき?

Server Action は誰でも POST できるエンドポイントと同じです。アクション内で必ず cookies() から session を取り、権限チェックを通してから DB を触ること。"use server" ファイル先頭に共通ガード関数(requireUser() 等)を置く運用が事故が少なくて済みます。

まとめ:RSC を「3つの境界」で捉える

本記事を通して RSC を整理すると、覚えるべきは結局この3つの境界です。

  • 描画境界:Server Component が「サーバーで一度だけ描画される領域」、Client Component が「ブラウザで何度も再描画される領域」。境界は "use client"
  • 実行境界:Server Action は「サーバーで実行される関数」。境界は "use server"
  • 表示境界:Suspense が「いま見せる部分」と「後から流す部分」を分けるストリーミングの境界。

この3境界を意識すれば、「どこに "use client" を置くか」「どこを Suspense で囲むか」「どこを Server Action にするか」という日々の設計判断が一気にクリアになります。RSC は新しい概念ではなく、これまでの React に「サーバー」を一級市民として加えただけです。本記事の各コードを手元で動かしながら、3境界の感覚を体に染み込ませてください。

キャリアとして RSC・Next.js を実務レベルで習得したい方は、現役エンジニアの伴走付きで Next.js App Router を扱える テックアカデミー侍エンジニア 等のオンラインスクールを活用するのが最短ルートです。フロントエンド転職を見据えるなら DMM WEBCAMP、フリーランスや高単価案件を狙うなら レバテックフリーランス の RSC / Next.js 案件動向もチェックしておくと判断材料が増えます。

コメント

タイトルとURLをコピーしました