React Server Components(RSC)は、Reactコンポーネントをサーバー上で実行し、その結果だけをクライアントに送信する仕組みです。Next.js App Routerで本格採用され、パフォーマンスと開発体験の両面で大きな変革をもたらしています。
本記事では、RSCの仕組み、Server ComponentsとClient Componentsの使い分け、データ取得パターン、そして実務での実装ベストプラクティスを解説します。
React Server Componentsの基本概念
React Server Components(RSC)は、コンポーネントの実行場所を「サーバー」と「クライアント」に明確に分離するアーキテクチャです。
従来のReactとRSCの違い
従来のReact(CSR: Client-Side Rendering)では、すべてのコンポーネントがクライアント(ブラウザ)上で実行されていました。サーバーからJavaScriptバンドルを受け取り、ブラウザ上でコンポーネントツリーを構築してDOMをレンダリングします。
RSCでは、サーバー上でコンポーネントを実行し、その結果(HTML + シリアライズされたコンポーネントツリー)をクライアントに送信します。サーバーで実行されるコンポーネントのJavaScriptはクライアントに送られないため、バンドルサイズが大幅に削減されます。
RSCのメリット
バンドルサイズの削減:Server Componentsで使用するライブラリ(マークダウンパーサー、日付フォーマッター、シンタックスハイライターなど)はクライアントに送信されません。数百KBのライブラリをバンドルサイズに影響なく使用できます。
直接的なデータアクセス:Server Componentsはサーバー上で実行されるため、データベースやファイルシステムに直接アクセスできます。APIエンドポイントを経由する必要がありません。
セキュリティ:APIキーやDBの接続情報がクライアントに漏洩するリスクがありません。
初期ロードの高速化:サーバーでレンダリングされたHTMLがすぐに表示されるため、ユーザーの体感速度が向上します。
Server ComponentsとClient Componentsの使い分け
基本的な判断基準
Server Components(デフォルト)を使う場面:
データの取得と表示、静的なUI要素、SEOが重要なコンテンツ、大きなライブラリを使う処理(マークダウン変換、構文ハイライトなど)。
Client Components("use client"が必要)を使う場面:
ユーザーインタラクション(onClick、onChangeなど)、状態管理(useState、useReducer)、副作用(useEffect)、ブラウザAPI(localStorage、window、IntersectionObserverなど)。
// Server Component(デフォルト - "use client"を書かない)
// サーバー上で実行される
async function ArticlePage({ id }: { id: string }) {
// データベースに直接アクセス可能
const article = await db.article.findUnique({ where: { id } });
if (!article) return <NotFound />;
return (
<article>
<h1>{article.title}</h1>
<ArticleContent content={article.content} />
{/* Client Componentも子として配置可能 */}
<LikeButton articleId={id} />
<CommentSection articleId={id} />
</article>
);
}
// Client Component - "use client"ディレクティブが必須
"use client";
import { useState } from "react";
function LikeButton({ articleId }: { articleId: string }) {
const [liked, setLiked] = useState(false);
const [count, setCount] = useState(0);
const handleLike = async () => {
setLiked(!liked);
setCount((c) => (liked ? c - 1 : c + 1));
await fetch(`/api/articles/${articleId}/like`, {
method: liked ? "DELETE" : "POST",
});
};
return (
<button onClick={handleLike}>
{liked ? "♥" : "♡"} {count}
</button>
);
}
コンポーネントの境界設計
RSCの設計では、「Client Componentsの境界をできるだけ葉(リーフ)に近づける」ことが重要です。
// 悪い例:ページ全体をClient Componentにしてしまう
"use client"; // ← ページ全体がクライアントで実行される
export default function ProductPage({ id }: { id: string }) {
const [quantity, setQuantity] = useState(1);
// ...
}
// 良い例:インタラクティブな部分だけをClient Componentに分離
// ProductPage.tsx(Server Component)
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetchProduct(params.id);
return (
<div>
{/* 静的な部分はServer Component */}
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductImages images={product.images} />
{/* インタラクティブな部分だけClient Component */}
<AddToCartForm product={product} />
<ProductReviews productId={product.id} />
</div>
);
}
データ取得パターン
Server Componentでの直接データ取得
// Server Componentではasync/awaitが直接使える
async function UserProfile({ userId }: { userId: string }) {
// 並列でデータ取得
const [user, posts, followers] = await Promise.all([
fetchUser(userId),
fetchUserPosts(userId),
fetchFollowers(userId),
]);
return (
<div>
<h1>{user.name}</h1>
<p>{followers.length}フォロワー</p>
<h2>投稿一覧</h2>
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
// データ取得関数(サーバー上で実行)
async function fetchUser(id: string): Promise<User> {
// DBに直接アクセス
const user = await db.user.findUnique({ where: { id } });
if (!user) throw new Error("User not found");
return user;
}
Suspenseによるストリーミング
import { Suspense } from "react";
// 遅いデータ取得を含むコンポーネント
async function RecommendedProducts() {
// この取得に3秒かかるとする
const products = await fetchRecommendations();
return (
<div>
{products.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
);
}
// ページコンポーネント
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetchProduct(params.id);
return (
<div>
{/* メインコンテンツは即座に表示 */}
<h1>{product.name}</h1>
<p>¥{product.price.toLocaleString()}</p>
{/* おすすめ商品はストリーミングで後から表示 */}
<Suspense fallback={<p>おすすめ商品を読み込み中...</p>}>
<RecommendedProducts />
</Suspense>
</div>
);
}
Suspenseを使うことで、遅いデータ取得がページ全体のレンダリングをブロックしません。メインコンテンツが先に表示され、おすすめ商品は準備ができ次第ストリーミングで配信されます。
Server Actionsによるデータ更新
// actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().min(1, "名前を入力してください"),
email: z.string().email("メールアドレスの形式が不正です"),
message: z.string().min(10, "10文字以上で入力してください"),
});
export async function submitContact(prevState: any, formData: FormData) {
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
};
const result = ContactSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
await db.contact.create({ data: result.data });
revalidatePath("/contacts");
return { success: true, errors: {} };
}
// ContactForm.tsx
"use client";
import { useActionState } from "react";
import { submitContact } from "./actions";
export function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, {
success: false,
errors: {},
});
return (
<form action={formAction}>
<div>
<input name="name" placeholder="名前" />
{state.errors.name && <span>{state.errors.name[0]}</span>}
</div>
<div>
<input name="email" type="email" placeholder="メールアドレス" />
{state.errors.email && <span>{state.errors.email[0]}</span>}
</div>
<div>
<textarea name="message" placeholder="メッセージ" />
{state.errors.message && <span>{state.errors.message[0]}</span>}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "送信中..." : "送信"}
</button>
{state.success && <p>送信しました</p>}
</form>
);
}
実務でのコンポーネント設計パターン
Container/Presentationalパターン(RSC版)
// Container: Server Component(データ取得を担当)
async function UserListContainer() {
const users = await db.user.findMany({
orderBy: { createdAt: "desc" },
take: 20,
});
return <UserListPresentation users={users} />;
}
// Presentation: Server or Client Component(表示を担当)
function UserListPresentation({ users }: { users: User[] }) {
return (
<ul>
{users.map((user) => (
<li key={user.id}>
<span>{user.name}</span>
<span>{user.email}</span>
</li>
))}
</ul>
);
}
Server ComponentからClient Componentへのデータ受け渡し
// Server Component
async function Dashboard() {
// サーバーでデータを取得
const initialData = await fetchDashboardData();
// Client ComponentにPropsとして渡す
// 注意: シリアライズ可能な値のみ渡せる(関数やクラスインスタンスは不可)
return (
<div>
<h1>ダッシュボード</h1>
{/* 初期データをPropsで渡し、クライアントで動的に更新 */}
<DashboardCharts initialData={initialData.charts} />
{/* フィルタリングなどインタラクティブな操作が必要 */}
<DataTable
initialData={initialData.tableRows}
columns={initialData.columns}
/>
</div>
);
}
// Client Component
"use client";
function DashboardCharts({ initialData }: { initialData: ChartData[] }) {
const [dateRange, setDateRange] = useState("7d");
// initialDataを初期値として使い、フィルター変更時にクライアントから再取得
const { data } = useQuery({
queryKey: ["charts", dateRange],
queryFn: () => fetchCharts(dateRange),
initialData: dateRange === "7d" ? initialData : undefined,
});
return (/* ... */);
}
Compositionパターン:Client Componentの中にServer Componentを配置する
// Client Component(レイアウト)
"use client";
function Modal({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>開く</button>
{isOpen && (
<div className="modal-overlay">
<div className="modal-content">
{children} {/* Server Componentを子として受け取れる */}
<button onClick={() => setIsOpen(false)}>閉じる</button>
</div>
</div>
)}
</>
);
}
// Server Component(親)
async function ProductDetail({ id }: { id: string }) {
const product = await fetchProduct(id);
return (
<div>
<h1>{product.name}</h1>
<Modal>
{/* Server Componentの結果をchildrenとして渡す */}
<ProductSpecs specs={product.specs} />
</Modal>
</div>
);
}
パフォーマンス最適化のポイント
データ取得の並列化
// 悪い例:逐次実行(ウォーターフォール)
async function Page() {
const user = await fetchUser(); // 1秒
const posts = await fetchPosts(); // 1秒
const comments = await fetchComments(); // 1秒
// 合計: 3秒
}
// 良い例:並列実行
async function Page() {
const [user, posts, comments] = await Promise.all([
fetchUser(), // 1秒
fetchPosts(), // 1秒
fetchComments(), // 1秒
]);
// 合計: 1秒
}
// さらに良い例:Suspenseでストリーミング
function Page() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserSection />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostsSection />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<CommentsSection />
</Suspense>
</div>
);
}
キャッシュ戦略
// Next.jsでのキャッシュ制御
// 静的に生成(ビルド時に実行、キャッシュされる)
async function StaticPage() {
const data = await fetch("https://api.example.com/data", {
cache: "force-cache", // デフォルト
});
}
// 動的にレンダリング(リクエストごとに実行)
async function DynamicPage() {
const data = await fetch("https://api.example.com/data", {
cache: "no-store",
});
}
// 一定時間キャッシュ(ISR: Incremental Static Regeneration)
async function ISRPage() {
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 3600 }, // 1時間ごとに再検証
});
}
まとめ
React Server Componentsは、サーバーとクライアントの役割を明確に分離し、パフォーマンスと開発体験を大幅に向上させるアーキテクチャです。
基本概念:Server Componentsはサーバー上で実行され、JavaScriptバンドルに含まれません。Client Componentsはインタラクティブな部分にのみ使用します。
設計原則:Client Componentsの境界を末端に近づけ、できるだけ多くのコンポーネントをServer Componentとして保つことが重要です。Compositionパターンで、Client Componentの子としてServer Componentの結果を渡すことも可能です。
データ取得:Server Componentsではasync/awaitでDB直接アクセスが可能です。Suspenseでストリーミングし、遅いデータ取得がページ全体をブロックしないようにします。
データ更新:Server Actionsでフォーム送信やデータ変更をサーバー側で処理し、revalidatePathでキャッシュを更新します。
RSCはReactの未来の方向性です。Next.js App Routerを使うプロジェクトでは、まずすべてのコンポーネントをServer Componentとして書き始め、インタラクションが必要な部分だけをClient Componentに変換するアプローチが効果的です。
関連記事
AIエージェント開発入門|自律型AIの仕組みと構築方法を解説【2026年版】
AI駆動コーディングワークフロー|Claude Code・Cursor・Copilotの実践的使い分け
プロンプトエンジニアリング上級編|Chain-of-Thought・Few-Shot・ReActの実践
APIレート制限の設計と実装|トークンバケット・スライディングウィンドウ解説
APIバージョニング戦略|URL・ヘッダー・クエリパラメータの使い分け
BIツール入門|Metabase・Redash・Looker Studioでデータ可視化する方法
チャットボット開発入門|LINE Bot・Slack Botの構築方法と活用事例
CI/CDパイプラインの基礎|継続的インテグレーション・デリバリーの全体像