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エンジニアです。「描画はサーバー、操作はクライアント」という一貫した方針で進めます。
- RSC が解決した3つの構造的課題
- “use client” と “use server” ディレクティブの本質
- データフェッチ:fetch / cache / revalidate
- Server Action 完全実装パターン
- Suspense と Streaming で体感速度を上げる
- Server Component と Client Component の境界設計
- DB 直接アクセス・認証・サーバー専用API
- SEO・メタデータ・Edge runtime
- テスト戦略:RSC のテストは2層で考える
- ハマりやすい落とし穴 Best 7
- 移行戦略:既存 SPA から RSC へ
- FAQ
- まとめ:RSC を「3つの境界」で捉える
RSC が解決した3つの構造的課題
RSC は単なる「SSR の進化版」ではありません。「クライアントに送る JS バンドルからサーバー専用のコードを完全に消す」ための新しいコンポーネントモデルです。これにより以下の3つが同時に解決されました。
| 従来の課題 | 従来の解 | RSC の解 |
|---|---|---|
| 巨大な JS バンドル | code splitting / dynamic import | そもそも RSC は JS を送らない |
| API ルート二度書き | BFF / tRPC / GraphQL | Server Component で直接 DB アクセス |
| ウォーターフォール fetch | useEffect 並列化 / 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 fetch や await 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 クエリなど)も react の cache() でリクエスト内メモ化できます。同一レンダリングツリー内で 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-dom の useFormStatus で取得します。これは 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 / BigInt | Symbol(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 runtime | Node runtime(デフォルト) |
|---|---|---|
| 起動速度 | 非常に速い(ミリ秒) | 普通 |
| 地理分散 | 世界中の Edge から実行 | リージョン固定 |
| 使える API | Web 標準のみ | Node.js API 全部 |
| Prisma / 多くのORM | NG(専用クライアントが必要) | 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 への移行は段階的に行うのが鉄則です。一気に書き換えると現場が止まります。パフォーマンス改善記事とも合わせて、優先順位を決めて少しずつ進めます。
- App Router を共存させる:
pages/とapp/は同時に動かせる。新規 route からapp/に書く。 - データフェッチを Server Component に寄せる:既存の
useEffect + fetchを順次 async コンポーネントへ。 - フォーム処理を Server Action に置換:
app/api/*ルートのうちフォーム POST 用は Server Action に統合可能。 - Client コンポーネントを葉に追いやる:layout / page を Server に保ち、ボタンやモーダルだけ Client。
- Streaming を導入:重いセクションに
<Suspense>を貼り、loading.tsx で skeleton 化。 - キャッシュ戦略を整える: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 案件動向もチェックしておくと判断材料が増えます。

コメント