2025年11月27日
React Server Componentsの本番運用上の課題について

本記事は、原著者の許諾のもとに翻訳・掲載しております。
数週間前、私たちの本番アプリがハングし始めました。コンポーネントがランダムに読み込まれなくなったのです。ユーザーの画面ローディングスピナーの前で固まってしまいました。40時間デバッグした末に、私たちは気づきました。React Server Components(RSC)が問題だったのです。
イントロダクション:理想 vs. 現実
当初、React Server Components(RSC)は革命的であるはずでした。 Reactチームは以下を強調していました:
- ✅ パフォーマンスの向上
- ✅ バンドルサイズの削減
- ✅ 自動的なコード分割
- ✅ コンポーネントからのダイレクトなデータベースアクセス
私たちは彼らを信頼し、Next.jsアプリ全体をServer Componentsと共にApp Routerへ移行しました。
3カ月後、私たちのアプリは以下の状況に陥りました:
- 初期ロードが遅い
- デバッグがより複雑に
- 経験の浅い開発者にとって理解しにくい
- 原因不明なキャッシュ問題に悩まされている
この記事は、Reactコミュニティーが必要としている率直な会話です。マーケティングでも、誇張でもありません。React Server Componentsを使った、本番環境でのリアルな体験談です。
Part 1:React Server Componentsとは何か(シンプルバージョン)
従来モデル(Client Components)
// This runs in the browser
'use client'
export default function UserProfile() {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(setUser)
}, [])
if (!user) return <div>Loading...</div>
return <div>{user.name}</div>
}処理フロー:
- ブラウザーがJavaScriptをダウンロードする
- コンポーネントがマウントされる
useEffectが発火- APIへのフェッチリクエスト
- レスポンスを待機
- stateを更新
- 再レンダリング
結果:ユーザーに「Loading...」が1〜2秒間表示される
Server Componentモデル
// This runs on the server
import { db } from '@/lib/database'
export default async function UserProfile() {
const user = await db.user.findFirst()
return <div>{user.name}</div>
}処理フロー:
- リクエストがサーバーに到達
- コンポーネントがサーバーで実行される
- データベースクエリが実行される
- データを含んだHTMLがブラウザーに送信される
- ユーザーにコンテンツが即座に表示される
結果:ユーザーは即座にデータを閲覧できる(理論上は)
理想
Server Componentsは以下の問題の解決を目指すものでした:
- ローディング状態
- クライアントサイドのデータフェッチ
- APIルートのボイラープレート
- 巨大なJavaScriptバンドル
現実は、もっと複雑です。
Part 2:落とし穴
問題点 1:暗黙のウォーターフォール
Server Componentsで実際に起こることを見てみましょう:
// app/dashboard/page.tsx
export default async function Dashboard() {
const user = await getUser() // 200ms
return (
<div>
<Header user={user} />
<Stats userId={user.id} /> {/* Another server component */}
<RecentActivity userId={user.id} /> {/* Another server component */}
</div>
)
}
// Stats component
async function Stats({ userId }) {
const stats = await getStats(userId) // 300ms - WAITS for parent!
return <div>{stats.total}</div>
}
// RecentActivity component
async function RecentActivity({ userId }) {
const activity = await getActivity(userId) // 250ms - WAITS for Stats!
return <div>{activity.map(...)}</div>
}期待する動作:並列リクエスト(最大300ms)
実際の動作:シーケンシャルなウォーターフォール
getUser()- 200ms- コンポーネントがレンダリングされ、
<Stats>を検出 getStats()- 300ms(ステップ1の後に開始)- コンポーネントがレンダリングされ、
<RecentActivity>を検出 getActivity()- 250ms(ステップ3の後に開始)
合計時間:750ms(並列化されていない!)
なぜこうなるのか:Reactはコンポーネントをシーケンシャルにレンダリングします。各非同期コンポーネントが、次のコンポーネントをブロックするのです。
修正
手動で並列化しなければなりません:
export default async function Dashboard() {
// Run all queries in parallel
const [user, stats, activity] = await Promise.all([
getUser(),
getStats(),
getActivity()
])
return (
<div>
<Header user={user} />
<Stats data={stats} /> {/* Now a regular component */}
<RecentActivity data={activity} /> {/* Now a regular component */}
</div>
)
}しかし、これでは以下の利点が失われます:
- コンポーネントのカプセル化
- 関心の分離
- Server Componentsの目的
問題点 2:キャッシュというブラックボックス
React 19とNext.js 14以降は、積極的なキャッシュ機構を備えています。これは良いことのように聞こえますが、本番環境で問題を起こすまでは、です。
私たちが遭遇した実際のバグ:
// app/posts/page.tsx
export default async function PostsPage() {
const posts = await db.post.findMany()
return <PostList posts={posts} />
}起こったこと:
- ユーザーが新しい投稿を作成
/postsにリダイレクトされる- 新しい投稿が表示されない
- ページをリロードしても無駄
- ブラウザーのキャッシュをクリアしても無駄
理由:Next.jsがサーバー上でデータベースのクエリ結果をキャッシュしていました。そして、そのキャッシュを無効化(invalidate)していなかったのです。
解決策:
export const revalidate = 0 // Disable caching
export default async function PostsPage() {
const posts = await db.post.findMany()
return <PostList posts={posts} />
}しかしながら:
- パフォーマンス上の利点が失われる
- ページロードごとにデータベースにアクセスが走る
- Client Componentsを使っていた頃のパフォーマンスに逆戻り
より深刻な問題:何がキャッシュされているのか確認できません。キャッシュインスペクターのようなものはありません。推測するしかないのです。
問題点 3:クライアントとサーバーの境界が分かりにくい
これが私たちのチームにとって最大の問題です:
// ❌ This looks like it should work
'use client'
import { ServerComponent } from './ServerComponent'
export default function ClientComponent() {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
<ServerComponent /> {/* Error! */}
</div>
)
}エラー:「Server ComponentをClient Componentにインポートしています」
理由:ひとたび'use client'を使うと、その配下は全てClient Componentでなければならないからです。
修正:
// ✅ Pass Server Component as children
'use client'
export default function ClientComponent({ children }) {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
{children}
</div>
)
}
// In parent (Server Component)
<ClientComponent>
<ServerComponent />
</ClientComponent>これは直感的でないように見えます。経験の浅い開発者は、この仕様に何週間も苦しめられています。
問題点 4:フォームの複雑性
従来のフォームハンドリング:
'use client'
export default function Form() {
async function handleSubmit(e) {
e.preventDefault()
const res = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(formData)
})
if (res.ok) router.push('/success')
}
return <form onSubmit={handleSubmit}>...</form>
}シンプルに機能し、誰もが理解できます。
Server Actions(RSC流):
// app/actions.ts
'use server'
export async function submitForm(formData: FormData) {
const name = formData.get('name')
await db.user.create({ data: { name } })
revalidatePath('/users')
redirect('/success')
}
// Form component
export default function Form() {
return (
<form action={submitForm}>
<input name="name" />
<button type="submit">Submit</button>
</form>
)
}問題点:
- エラーハンドリングが不明確:どこでエラーをキャッチすればよいのでしょう?
- ローディング状態:どうやってスピナーを表示するのでしょう?
- バリデーション:クライアントサイドのバリデーションにはClient Componentが必要
「解決」にはuseFormStatusが必要です:
'use client'
import { useFormStatus } from 'react-dom'
import { submitForm } from './actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
)
}
export default function Form() {
return (
<form action={submitForm}>
<input name="name" />
<SubmitButton />
</form>
)
}このために必要なもの:
- Server Actions用の別ファイル
- ボタン用のClient Component
- 学習すべき新しいフック
- 増えるファイルと複雑さ
以前の方法のシンプルさと比べて、どれほどのメリットがあるのでしょうか。
問題点 5:TypeScriptの型安全性が失われる
Server Componentsは、TypeScriptを巧妙なやり方で壊します:
// lib/db.ts
export async function getUser() {
return await db.user.findFirst()
}
// app/page.tsx - Server Component
export default async function Page() {
const user = await getUser()
return <UserProfile user={user} /> // Type error!
}
// components/UserProfile.tsx - Client Component
'use client'
interface Props {
user: User // Prisma type with Date objects
}
export default function UserProfile({ user }: Props) {
return <div>{user.createdAt.toISOString()}</div> // Runtime error!
}問題点:Server ComponentsはpropsをJSONにシリアライズします。Dateオブジェクトは文字列になってしまうのです。
TypeScriptはこれを型エラーとして検知できません。本番環境でランタイムエラーが発生します。
修正:手動でのシリアライズ
export async function getUser() {
const user = await db.user.findFirst()
return {
...user,
createdAt: user.createdAt.toISOString() // Manual conversion
}
}このために必要なこと:
- データベース呼び出しごとのシリアライズ関数
- サーバー用とクライアント用で別々の型定義
- 安全のためのランタイムチェック
Part 3:Server Componentsがうまく機能するケース
ただ否定ばかりしたいわけではありません。Server Componentsがうまく機能する特定のユースケースもあります。
✅ ユースケース 1:静的コンテンツサイト
// Blog post page
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return (
<article>
<h1>{post.title}</h1>
<Markdown content={post.content} />
</article>
)
}うまくいく理由:
- インタラクティビティーが不要
- コンテンツの変更がまれ
- キャッシュに最適
- SEOに強い
結論:Server Componentsが輝ける場所です。
✅ ユースケース 2:ダッシュボードのレイアウト
export default async function DashboardLayout({ children }) {
const user = await getCurrentUser()
return (
<div>
<Sidebar user={user} />
<main>{children}</main>
</div>
)
}うまくいく理由:
- 全てのページでユーザーデータが必要
- レイアウト部分のインタラクティビティーは最小限
- ユーザーセッションをキャッシュできる
結論:良いユースケースです。
✅ ユースケース 3:データテーブル(フィルターなし)
export default async function UsersTable() {
const users = await db.user.findMany()
return (
<table>
{users.map(user => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
))}
</table>
)
}うまくいく理由:
- 表示専用のデータ
- クライアントサイドのstateが不要
- サーバーサイドレンダリングの方が速い
結論:適切なユースケースです。
Part 4:Server Componentsが失敗するケース
❌ アンチパターン 1:リアルタイム更新
// ❌ This doesn't work
export default async function LiveFeed() {
const posts = await getPosts()
return <PostList posts={posts} />
}問題点:更新をサブスクライブする方法がありません。WebSocketを利用するためにはClient Componentが必要です。
必要なもの:useEffectとWebSocket接続を持つClient Component。
Server Componentsはこういった場面では力を発揮できません。
❌ アンチパターン 2:複雑なフォーム
// ❌ This gets messy fast
export default function MultiStepForm() {
// How do you manage form state across steps?
// How do you validate before submission?
// How do you show field-level errors?
}問題点:フォームはクライアントサイドのstateを必要とします。Server Actionsとクライアントのstateを混ぜるのは混乱のもとです。
解決策:制御された入力を持つClient Componentを使う。
❌ アンチパターン 3:インタラクティブ性の高いUI
// ❌ Server Components are wrong here
export default async function DataGrid() {
const data = await getData()
// Users need to:
// - Sort columns
// - Filter rows
// - Select items
// - Paginate
return <Table data={data} />
}問題点:全てのインタラクションでサーバーとのラウンドトリップが必要になります。
解決策:ローカルstateまたはTanstack Query(旧React Query)等を持つClient Component。
Part 5:Server Componentsの本当のコスト
Server Componentsの隠れたコストについて話しましょう。
コスト 1:開発者体験
Server Components以前:
- 経験の浅い開発者がチームに参加
- Reactフックを学ぶ
- クライアントサイドのデータフェッチを理解する
- 1〜2週間で価値を発揮し始める
Server Components以後:
- 経験の浅い開発者がチームに参加
- Reactフックを学ぶ
- Server Componentsを学ぶ
- クライアント/サーバーの境界ルールを学ぶ
- Server Actionsを学ぶ
- キャッシュの挙動を学ぶ
- どのパターンをいつ使うべきか学ぶ
- 1〜2カ月で生産的になる(運が良ければ)
私たちのチームの実際の統計:オンボーディング期間が2週間から6週間に増加しました。
コスト 2:デバッグの難しさ
Client Componentのバグ:
- DevToolsを開く
- コンソールでエラーを確認
- ブレークポイントを追加
- コードをステップ実行
- バグを修正
所要時間:10〜30分
Server Componentのバグ:
- エラーはターミナルに表示される(ブラウザーではない)
- ブラウザーのDevToolsが使えない
- console.log文を追加
- 問題を再現させる
- ターミナルのログを確認
- ステップ3〜6を何度も繰り返す
- 最終的にバグを発見
所要時間:1〜3時間
コスト 3:バンドルサイズ
理想:Server Components利用によるバンドルサイズ削減
現実の確認:
Server Components以前(純粋なクライアント):
- Reactバンドル:45KB
- アプリコード:120KB
- 合計:165KB
Server Components以後:
- Reactバンドル:45KB
- React Server Componentsランタイム:28KB(new!)
- アプリコード(クライアント部分):80KB
- Server Actionボイラープレート:15KB
- 合計:168KB
バンドルサイズ +3KB(1.8%)
しかし、待ってください、まだあります:
- HTMLサイズの増加(サーバーでレンダリングされたコンテンツ)
- ネットワークリクエストの増加(Server Componentツリー)
- RSCペイロードのオーバーヘッド
実際の結果:初期バンドルは減少どころかわずかに増大し、総転送データ量は増加しました。
コスト 4:パフォーマンス(という驚き)
移行前後で測定しました:
メトリクス:Time to Interactive (TTI)
Server Components以前:
- ホームページ:1.2秒
- ダッシュボード:1.8秒
- 製品ページ:1.4秒
Server Components以後:
- ホームページ:1.9秒(58%悪化!)
- ダッシュボード:2.4秒(33%悪化!)
- 製品ページ:1.1秒(21%改善)
なぜ遅くなったのか?
- サーバーレンダリングに時間がかかる
- ウォーターフォールリクエスト(問題点 1を参照)
- APIレスポンスのクライアントサイドキャッシュがない
なぜ製品ページは速くなったのか?
- シンプルでデータ中心のページ
- インタラクティビティーがない
- RSCの完璧なユースケース
学び:Server Componentsは自動的に速くなるわけではありません。
Part 6:コミュニケーションの問題
私が最もフラストレーションを感じるのは、Reactチームがこれらの問題を認識していたことです。
私の率直な印象:
- ウォーターフォール問題:Reactのドキュメントに記載があるがやや伝わりづらい
- キャッシュ問題:「より良いdevtoolsを開発中です」(2年間ずっと)
- TypeScript問題:「これは期待される動作です」
- デバッグの難しさ:「console.logを使ってください」(本気で?)
コミュニティーは、ドキュメントからではなく、本番環境でのつらい経験を通じてこれらの問題を発見しました。
以下と比較してみてください:
- Svelte:優れたドキュメント、明確な制限事項
- Vue:トレードオフについて正直
- Solid:学習曲線について率直
Part 7:では、実際どうすべきか?
戦略 1:選択的導入(推奨)
Server Componentsを使うケース:
- 静的コンテンツ
- シンプルなデータ表示
- レイアウトコンポーネント
- SEOが重要なページ
Client Componentsを使うケース:
- バリデーション付きのフォーム
- リアルタイム機能
- インタラクティブなUI
- 複雑なstate管理
構成例:
app/
(marketing)/ # Server Components
page.tsx
about/page.tsx
(dashboard)/ # Mixed
layout.tsx # Server Component
page.tsx # Client Component (interactive)
(blog)/ # Server Components
[slug]/page.tsx戦略 2:ハイブリッドレンダリング
// Server Component (page)
export default async function ProductPage({ params }) {
const product = await getProduct(params.id)
// Render static content on server
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Interactive parts as Client Components */}
<AddToCartButton productId={product.id} />
<Reviews productId={product.id} />
</div>
)
}
// Client Component (interactive)
'use client'
function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false)
async function handleClick() {
setLoading(true)
await addToCart(productId)
setLoading(false)
}
return <button onClick={handleClick}>Add to Cart</button>
}これがうまくいく理由:
- サーバーが静的コンテンツをレンダリング
- クライアントがインタラクティビティーを処理
- 関心の分離が明確
戦略 3:待つ(議論の余地はあるが、妥当)
新しいプロジェクトを始める場合:
以下に該当するなら、まだServer Componentsを使わないことを検討してください:
- 小規模なチームである
- 迅速なイテレーションが必要
- アプリのインタラクティブ性が高い
- 開発者体験を重視する
代わりに以下を使い続けてください:
- Pages Router(Next.js 12までの標準)
- Tanstack Queryを使ったClient Components
- 従来のAPIルート
理由:これらのパターンは:
- ドキュメントが整備されている
- よく理解されている
- 実戦でテスト済み
- デバッグが容易
Server Componentsはいずれ成熟します。エコシステムも改善されるでしょう。移行は後からでもできます。
Part 8:移行ガイド(どうしても移行する場合)
ステップ 1:アプリの棚卸し
全てのページを分類します:
✅ Good for RSC:
- Marketing pages
- Blog posts
- Documentation
- Static dashboards
⚠️ Maybe:
- User profiles
- Product listings
- Search results
❌ Bad for RSC:
- Real-time chat
- Complex forms
- Canvas/drawing apps
- Admin panels with lots of interactivityステップ 2:小さく始める
全てを書き換えないでください。1種類のページタイプを選びます:
// Start with: Static blog posts
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return <Article post={post} />
}まずはシンプルなページでパターンを学びましょう。
ステップ 3:徐々にインタラクティビティーを追加する
// app/blog/[slug]/page.tsx (Server Component)
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return (
<article>
<h1>{post.title}</h1>
<Content>{post.content}</Content>
{/* Client Component for interactions */}
<LikeButton postId={post.id} />
<Comments postId={post.id} />
</article>
)
}Server Componentsは、データフェッチと静的コンテンツに集中させましょう。
ステップ 4:ウォーターフォールに注意する
React DevToolsのProfilerを使いましょう:
// ❌ Bad: Sequential
<ServerComponent1 />
<ServerComponent2 /> {/* Waits for 1 */}
<ServerComponent3 /> {/* Waits for 2 */}
// ✅ Good: Parallel
const [data1, data2, data3] = await Promise.all([
getData1(),
getData2(),
getData3()
])ステップ 5:適切なエラー境界(Error Boundary)を設定する
// app/error.tsx
'use client' // Error boundaries must be Client Components
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}Server Componentsは本番環境で失敗する可能性があるため、エラー境界が必要です。
Part 9:代替案
代替案 1:Client Components + Tanstack Queryを使い続ける
'use client'
import { useQuery } from '@tanstack/react-query'
export default function ProductPage({ params }) {
const { data: product, isLoading } = useQuery({
queryKey: ['product', params.id],
queryFn: () => fetch(`/api/products/${params.id}`).then(r => r.json())
})
if (isLoading) return <Skeleton />
return <ProductDetails product={product} />
}メリット:
- よく理解されたパターン
- 優れたDX
- 強力なキャッシュ
- 容易なデバッグ
デメリット:
- クライアントサイドのローディング状態
- 大きめの初期バンドル
- SEOには追加作業が必要
結論:多くのアプリにとって、いまだに素晴らしい選択肢です。
代替案 2:Remixに移行する
Remixには Next.js より前から(loader を通じて)Server Components に類似した仕組みがありました:
// routes/products/$id.tsx
export async function loader({ params }) {
return json(await getProduct(params.id))
}
export default function Product() {
const product = useLoaderData()
return <ProductDetails product={product} />
}メリット:
- よりシンプルなメンタルモデル
- より整備されたドキュメント
- 明確なデータローディングパターン
- 優れたエラーハンドリング
デメリット:
- 異なるフレームワーク
- 移行コスト
結論:新規プロジェクトでは検討する価値があります。
代替案 3:Astroとアイランドアーキテクチャ
---
// src/pages/product/[id].astro
const product = await getProduct(Astro.params.id)
---
<Layout>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton client:load productId={product.id} />
</Layout>メリット:
- デフォルトで静的
- オプトインでインタラクティブにできる
- 優れたパフォーマンス
- シンプルなメンタルモデル
デメリット:
- 純粋なReactではない
- エコシステムが小さい
結論:コンテンツ中心のサイトには最適です。
Part 10:未来(これから)
Reactチームのロードマップ
最近のRFCや議論から:
- より良いDevTools - 「近日公開」(2年間聞き続けていますが)
- キャッシュの改善 - よりきめ細かな制御
- ストリーミングの改善 - Suspenseとのより良い統合
- TypeScriptサポート - Server Componentsの型サポート改善
私たちが本当に必要としているもの
- Server Componentsを使うべきでない時についての明確なドキュメント
- 実際のベンチマークを伴うパフォーマンスガイドライン
- RSCを安全に導入するための移行ツール
- 実際に機能するデバッグツール
- 制限についての正直なコミュニケーション
結論
React Server Componentsは銀の弾丸(決め手)ではありません。特定のユースケース、重大な複雑さ、そして現実的なトレードオフを伴うツールです。
現実:
- 一部のアプリには適しているが、他のアプリには適していない
- メンタルモデルの大幅な転換が必要
- ドキュメントが不十分
- 本番環境での問題が頻発している
- 学習曲線が険しい
私の率直なお勧め:
Server Componentsを使うべきケース:
- ✅ コンテンツ中心のサイトを構築している
- ✅ 複雑さを扱えるシニアチームがいる
- ✅ アーリーアダプターであることをいとわない
- ✅ 学習に時間を投資できる
Server Componentsを使うべきでないケース:
- ❌ アプリのインタラクティブ性が高い
- ❌ 経験の浅い開発者が多い
- ❌ 迅速な開発が必要
- ❌ 安定性が最重要
Reactコミュニティーは、以下について率直に話し合う必要があります:
- RSCが助けになる時 vs ならない時
- 本当のDXコスト
- 実際のパフォーマンスへの影響
- ドキュメントの不備
Server ComponentsはReactの未来です。しかし、一部のアプリケーションにとってはまだ先の話です。 賢明な選択を。
クイック意思決定フレームワーク
自問してみてください:
①アプリの何%がインタラクティブですか?
- 30%未満:Server Componentsを検討
- 30〜70%:ハイブリッドアプローチを使用
- 70%超:Client Componentsを堅持
②チームの経験レベルは?
- 全員シニア:問題なし
- 混合:慎重に進める
- ほぼ経験が浅い開発者:待つ
③タイムラインは?
- 学習プロジェクト:実験する
- 厳しいデッドライン:避ける
- 長期的な投資:検討の余地あり
④優先事項は?
- パフォーマンス:まず測定する
- DX:待つ方がよいかも
- SEO:良いユースケース
- 複雑さ:避ける
リソース
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- X: @yosuke_furukawa
- Github: yosuke-furukawa










