はじめに
Next.jsを触っていて一番迷ったのがデータフェッチの戦略。
「SSRとSSGとISRって結局どう使い分けるの?」という疑問が最初からずっとあった。PHPはリクエストのたびにHTMLを生成するので「SSR一択」だったが、Next.jsは戦略を選べる分、判断が必要になる。
App Routerでの書き方を中心に、それぞれの使い所を整理した。
4つのレンダリング戦略
CSR(Client Side Rendering)
ブラウザでデータを取得してレンダリング
→ useEffect + fetch
SSR(Server Side Rendering)
リクエストのたびにサーバーでHTMLを生成
→ cache: 'no-store'
SSG(Static Site Generation)
ビルド時にHTMLを生成して使い回す
→ cache: 'force-cache'(デフォルト)
ISR(Incremental Static Regeneration)
SSGの結果をN秒後に再生成する
→ next: { revalidate: N }
PHPはSSRしかないが、Next.jsは4つから選べる。「どの戦略を使うか」はページの性質によって変わる。
戦略ごとの書き方
SSR — リクエストのたびに最新データを取得
// app/orders/page.tsx
// キャッシュなし → 毎回FastAPIに問い合わせる
export default async function OrdersPage() {
const res = await fetch(`${process.env.FASTAPI_URL}/orders`, {
cache: 'no-store', // SSR
});
const orders = await res.json();
return (
<ul>
{orders.map((order: Order) => (
<li key={order.id}>{order.id}: {order.amount}円</li>
))}
</ul>
);
}
// または export const dynamic = 'force-dynamic' でページ全体をSSRにする
export const dynamic = 'force-dynamic';
export default async function OrdersPage() {
const res = await fetch(`${process.env.FASTAPI_URL}/orders`);
const orders = await res.json();
...
}
SSG — ビルド時に生成して使い回す
// app/about/page.tsx
// ビルド時に1回だけ実行される
export default async function AboutPage() {
const res = await fetch(`${process.env.FASTAPI_URL}/about`, {
cache: 'force-cache', // SSG(デフォルト動作)
});
const data = await res.json();
return <div>{data.content}</div>;
}
// 動的ルートのSSG
// app/blog/[slug]/page.tsx
// ビルド時に生成するパスを指定
export async function generateStaticParams() {
const res = await fetch(`${process.env.FASTAPI_URL}/posts`);
const posts = await res.json();
return posts.map((post: Post) => ({
slug: post.slug,
}));
}
export default async function BlogPost({
params,
}: {
params: { slug: string };
}) {
const res = await fetch(
`${process.env.FASTAPI_URL}/posts/${params.slug}`,
{ cache: 'force-cache' }
);
const post = await res.json();
return (
<article>
<h1>{post.title}</h1>
<div>{post.body}</div>
</article>
);
}
generateStaticParams()はPages RouterのgetStaticPaths()に相当する。ビルド時にどのURLを生成するか指定する。
ISR — 一定時間ごとに再生成する
// app/products/page.tsx
// 60秒ごとに再生成
export default async function ProductsPage() {
const res = await fetch(`${process.env.FASTAPI_URL}/products`, {
next: { revalidate: 60 }, // ISR(60秒ごとに再生成)
});
const products = await res.json();
return (
<div className="grid grid-cols-3 gap-4">
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
CSR — ブラウザでデータを取得する
// components/RealtimeChart.tsx
'use client';
import { useState, useEffect } from 'react';
export default function RealtimeChart() {
const [data, setData] = useState<ChartData[]>([]);
useEffect(() => {
// 初回取得
fetchData();
// 10秒ごとに更新
const timer = setInterval(fetchData, 10000);
return () => clearInterval(timer);
}, []);
async function fetchData() {
const res = await fetch('/api/chart-data');
const json = await res.json();
setData(json);
}
return <Chart data={data} />;
}
revalidatePathとrevalidateTag
ISRはN秒ごとに自動再生成するが、任意のタイミングで再生成したい場合はrevalidatePathかrevalidateTagを使う。
revalidatePath
// Server Actionで商品を更新したらキャッシュを消す
'use server';
import { revalidatePath } from 'next/cache';
export async function updateProduct(id: number, formData: FormData) {
await fetch(`${process.env.FASTAPI_URL}/products/${id}`, {
method: 'PUT',
body: JSON.stringify(Object.fromEntries(formData)),
headers: { 'Content-Type': 'application/json' },
});
revalidatePath('/products'); // 一覧ページのキャッシュを消す
revalidatePath(`/products/${id}`); // 詳細ページのキャッシュを消す
}
revalidateTag — より細かいキャッシュ制御
// fetchにタグをつける
const res = await fetch(`${process.env.FASTAPI_URL}/products`, {
next: {
revalidate: 3600,
tags: ['products'], // タグをつける
},
});
// Server Actionでタグを指定して無効化
'use server';
import { revalidateTag } from 'next/cache';
export async function updateProduct(id: number, data: ProductUpdate) {
await fetch(`${process.env.FASTAPI_URL}/products/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
revalidateTag('products'); // 'products'タグがついたキャッシュを全て無効化
}
タグを使うと「商品に関連するキャッシュを全部消す」という操作ができる。revalidatePathよりも粒度の細かい制御ができる。
戦略の使い分け基準
実際に使っていて固まってきた判断基準。
データの更新頻度と必要な鮮度で決める:
リアルタイム性が必要
→ SSR(cache: 'no-store')
例: 在庫数、注文状況、ログイン後のパーソナライズページ
数分〜数時間で変わる
→ ISR(next: { revalidate: N })
例: ニュース一覧、商品価格、ランキング
ほぼ変わらない
→ SSG(cache: 'force-cache')
例: 会社情報、利用規約、ブログ記事
ユーザー操作に応じてリアルタイムで変わる
→ CSR(useEffect + fetch)
例: リアルタイムチャート、チャット、ライブ検索
実際のページ構成例
// app/shop/page.tsx
// 商品一覧: ISR(商品情報は1時間に1回程度更新)
export default async function ShopPage() {
const res = await fetch(`${process.env.FASTAPI_URL}/products`, {
next: { revalidate: 3600, tags: ['products'] },
});
const products = await res.json();
return (
<div>
<h1>商品一覧</h1>
{/* 商品リストはISRで取得(Server Component) */}
<ProductList products={products} />
{/* カートはリアルタイムが必要なのでClient Component */}
<CartSummary />
</div>
);
}
// app/shop/[id]/page.tsx
// 商品詳細: ISR + Server Component + Client Component の混在
import { Suspense } from 'react';
// 商品情報をSSGで生成
export async function generateStaticParams() {
const res = await fetch(`${process.env.FASTAPI_URL}/products`);
const products = await res.json();
return products.map((p: Product) => ({ id: String(p.id) }));
}
export default async function ProductDetailPage({
params,
}: {
params: { id: string };
}) {
// 商品情報はISR
const res = await fetch(
`${process.env.FASTAPI_URL}/products/${params.id}`,
{ next: { revalidate: 3600, tags: ['products'] } }
);
const product = await res.json();
return (
<div>
{/* 商品基本情報: ISR */}
<ProductInfo product={product} />
{/* レビュー: 別途ISR(更新頻度が違う) */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewList productId={product.id} />
</Suspense>
{/* 在庫数: SSR(リアルタイムが必要) */}
<Suspense fallback={<StockSkeleton />}>
<StockStatus productId={product.id} />
</Suspense>
{/* カートに追加: Client Component */}
<AddToCartButton product={product} />
</div>
);
}
同じページでもコンポーネントによって戦略を変えられる。Suspenseで囲むとコンポーネントのレンダリングを分割できる。
Suspenseでストリーミング
// app/dashboard/page.tsx
import { Suspense } from 'react';
// 重いコンポーネントを遅延ロードする
async function SalesChart() {
// 3秒かかる重い処理
const data = await fetch(`${process.env.FASTAPI_URL}/analytics/sales`, {
cache: 'no-store',
}).then(r => r.json());
return <Chart data={data} />;
}
async function RecentOrders() {
const orders = await fetch(`${process.env.FASTAPI_URL}/orders/recent`, {
cache: 'no-store',
}).then(r => r.json());
return <OrderTable orders={orders} />;
}
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-6">
{/* 重いコンポーネントをSuspenseで包む */}
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
<Suspense fallback={<OrderTableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
Suspenseを使うとHTMLがストリーミングで送られる。ページ全体の完成を待たずにブラウザが表示を始める。重いAPIが1つあっても他の部分はすぐ表示されるのでUXが上がる。
PHPとの比較
PHP(Laravel)のデータ取得:
→ コントローラーで全部取得してからBladeに渡す
→ ページ全体がSSR(変更できない)
→ 重い処理があるとページ全体が遅くなる
Next.js App Routerのデータ取得:
→ コンポーネント単位でデータ取得
→ SSR/SSG/ISR/CSRを混在できる
→ Suspenseで重い処理を後回しにできる
PHPだとページ全体の表示速度が「一番遅いDB/APIクエリ」に引っ張られていた。Next.jsのSuspenseを使うと重い処理を後回しにして他の部分を先に表示できる。
fetch以外のデータ取得
// DBに直接アクセス(Server Component)
import { db } from '@/lib/database';
export default async function UsersPage() {
// fetchを使わずORM/DBを直接呼べる(Server Componentなので)
const users = await db.query('SELECT * FROM users');
return <UserList users={users} />;
}
// 並列でデータを取得(Promise.allで高速化)
export default async function DashboardPage() {
// 直列に書くと合計待ち時間 = 各APIの合計
// const users = await fetchUsers();
// const orders = await fetchOrders();
// const stats = await fetchStats();
// 並列に実行すると合計待ち時間 = 最も遅いAPIの時間
const [users, orders, stats] = await Promise.all([
fetchUsers(),
fetchOrders(),
fetchStats(),
]);
return <Dashboard users={users} orders={orders} stats={stats} />;
}
Server Componentはasync/awaitを使えるのでPromise.allで並列化できる。LaravelのEloquentのEager Loadingに近い発想で、直列取得を並列にするだけでページが速くなる。
まとめ
| 戦略 | 書き方 | 使い所 |
|---|---|---|
| SSR | cache: 'no-store' |
ログイン後ページ・リアルタイム在庫 |
| SSG | cache: 'force-cache' |
静的コンテンツ・ブログ記事 |
| ISR | next: { revalidate: N } |
商品一覧・ニュース・ランキング |
| CSR | useEffect + fetch |
リアルタイムチャート・ライブ検索 |
| on-demand | revalidateTag() |
管理画面での更新後に即反映 |
「どの戦略を使うか」は「データがどのくらいの頻度で変わるか」と「古いデータを表示してもいいか」で決まる。最初は全部SSRにしてしまいがちだが、静的なコンテンツはSSGかISRにするだけでレスポンスが速くなる。