Next.js 13でApp Routerが導入されて以来、React Server Components(RSC)の概念は多くの開発者を混乱させてきました。Next.js 15ではさらにキャッシュの動作が変更され、理解すべき概念が増えています。この記事では、サーバーコンポーネントとクライアントコンポーネントの本質的な違いから、実践的なデータフェッチパターン、よくある間違いとその解決法まで、体系的に解説します。
サーバーコンポーネントとクライアントコンポーネントの違い
App Routerでは、デフォルトですべてのコンポーネントがサーバーコンポーネントです。クライアントコンポーネントにするには、ファイルの先頭に"use client"ディレクティブを追加します。この違いは実行環境の違いであり、それぞれに明確な得意・不得意があります。
- サーバーコンポーネント:サーバーで実行、データベースに直接アクセス可能、JavaScriptバンドルに含まれない、useStateやuseEffectは使えない
- クライアントコンポーネント:ブラウザで実行、インタラクティブな機能(useState、useEffect、onClick等)が使える、バンドルサイズを増加させる
ℹ️ 情報
重要な誤解:クライアントコンポーネントもサーバーサイドでのHTML生成(SSR)は行われます。"クライアント"とはハイドレーション後のインタラクティブな実行環境を指します。
データフェッチのパターン
Next.js 15では、fetch APIがデフォルトでキャッシュされなくなりました(Next.js 13〜14ではデフォルトでキャッシュされていた)。この変更により、明示的なキャッシュ設定が必要になっています。
// app/products/page.tsx
// サーバーコンポーネント(デフォルト)
interface Product {
id: number
title: string
price: number
}
async function getProducts(): Promise<Product[]> {
// Next.js 15: デフォルトでno-store(キャッシュなし)
const res = await fetch('https://api.example.com/products')
if (!res.ok) {
throw new Error('Failed to fetch products')
}
return res.json()
}
async function getCachedProducts(): Promise<Product[]> {
// 1時間キャッシュ
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }
})
if (!res.ok) {
throw new Error('Failed to fetch products')
}
return res.json()
}
async function getStaticProducts(): Promise<Product[]> {
// ビルド時のみ取得(ISRなし)
const res = await fetch('https://api.example.com/products', {
cache: 'force-cache'
})
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<main>
<h1>製品一覧</h1>
<ul>
{products.map((product) => (
<li key={product.id}>
{product.title} - ¥{product.price.toLocaleString()}
</li>
))}
</ul>
</main>
)
}データベースへの直接アクセス
サーバーコンポーネントの最大の利点は、APIを経由せずにデータベースに直接アクセスできることです。PrismaやDrizzle ORMを使えば、型安全なクエリが書けます。
// app/dashboard/page.tsx
import { db } from '@/lib/db' // Prisma Client
export default async function DashboardPage() {
// APIルートを経由せず直接DBクエリ
const users = await db.user.findMany({
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 10,
})
return (
<div>
<h1>ダッシュボード</h1>
<table>
<thead>
<tr>
<th>名前</th>
<th>メール</th>
<th>登録日</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.createdAt.toLocaleDateString('ja-JP')}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}"use client"の正しい使い方
"use client"は最小限のコンポーネントにのみ適用するのがベストプラクティスです。インタラクティブな部分だけを切り出し、親コンポーネントはサーバーコンポーネントのままにすることで、バンドルサイズを最小化できます。
// components/ProductCard.tsx
// サーバーコンポーネント("use client"なし)
import { AddToCartButton } from './AddToCartButton'
interface Product {
id: number
title: string
price: number
}
interface ProductCardProps {
product: Product
}
export function ProductCard({ product }: ProductCardProps) {
return (
<div className="border rounded-lg p-4">
<h2>{product.title}</h2>
<p>¥{product.price.toLocaleString()}</p>
{/* クライアントコンポーネントを子として埋め込み */}
<AddToCartButton productId={product.id} />
</div>
)
}
// components/AddToCartButton.tsx
'use client'
import { useState } from 'react'
interface AddToCartButtonProps {
productId: number
}
export function AddToCartButton({ productId }: AddToCartButtonProps) {
const [added, setAdded] = useState(false)
return (
<button
onClick={() => setAdded(true)}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
{added ? 'カートに追加済み' : 'カートに追加'}
</button>
)
}よくある間違いと解決法
App Routerに移行する際、多くの開発者が同じ間違いを犯します。以下に最もよく見られる問題とその解決法をまとめます。
- 間違い:クライアントコンポーネント内でasync/awaitを直接使う → 解決:別のサーバーコンポーネントでデータを取得し、propsで渡す
- 間違い:サーバーコンポーネントでクライアント専用ライブラリ(localStorage、window等)を使う → 解決:"use client"を追加するか、動的インポートでSSRを無効化する
- 間違い:fetch()のキャッシュ設定を忘れる → 解決:明示的にrevalidateかcacheオプションを設定する
- 間違い:サーバーコンポーネントにイベントハンドラーを付ける → 解決:そのロジックをクライアントコンポーネントに移動する
パフォーマンス最適化
サーバーコンポーネントを使いこなすことで、大幅なパフォーマンス改善が期待できます。特にLCP(Largest Contentful Paint)の改善に効果的です。並列データフェッチ、Suspenseによる段階的レンダリング、静的生成と動的レンダリングの使い分けが重要なポイントです。
// app/dashboard/page.tsx
// 並列データフェッチの例
import { Suspense } from 'react'
import { UserStats } from './UserStats'
import { RecentOrders } from './RecentOrders'
import { RevenueChart } from './RevenueChart'
export default function DashboardPage() {
return (
<div className="grid grid-cols-3 gap-6">
{/* 各コンポーネントが独立してデータをフェッチ */}
<Suspense fallback={<div>統計を読み込み中...</div>}>
<UserStats />
</Suspense>
<Suspense fallback={<div>注文を読み込み中...</div>}>
<RecentOrders />
</Suspense>
<Suspense fallback={<div>グラフを読み込み中...</div>}>
<RevenueChart />
</Suspense>
</div>
)
}
// app/dashboard/UserStats.tsx
async function getUserCount(): Promise<number> {
const res = await fetch('/api/users/count', {
next: { revalidate: 60 }
})
const data = await res.json() as { count: number }
return data.count
}
export async function UserStats() {
const count = await getUserCount()
return <div>ユーザー数: {count}</div>
}💡 ヒント
Suspenseを使うことで、遅いデータソースが他のコンポーネントのレンダリングをブロックしなくなります。ウォーターフォールを避け、並列フェッチを積極的に活用しましょう。