SSR・SSG・ISR・PPR —— レンダリング戦略の選び方【Next.js 16版】
結論から
- SSG=ビルド時にHTMLを作る、SSR=リクエストごとに作る。この2つが基本形で、残りは全部その中間。
- 迷ったらSSG。SSGで無理なところだけSSRに落とす。 逆順で考えると必ずコストが膨らむ。
- Next.js 16では「ページ単位で1つ選ぶ」時代が終わった。Cache Componentsを有効にすると、既定の挙動がPPR(Partial Prerendering)——1ページの中で静的な部分と動的な部分を混在させる方式になる。
- 従来の用語(SSG/SSR/ISR)はもう「ページの属性」ではなく**「そのデータをキャッシュするかどうか」の結果**として決まる。ここが16で一番アタマを切り替えるべきポイント。
1. まず基本の4パターン
| 方式 | HTMLを作るタイミング | TTFB | SEO | 向いているもの |
|---|---|---|---|---|
| CSR | ブラウザ上(JS実行後) | 速いが中身は空 | 弱い | 管理画面、ログイン後のダッシュボード |
| SSG | ビルド時に1回 | 最速(CDN配信) | 最強 | ブログ、LP、規約ページ |
| SSR | リクエストのたび | 遅い(DB待ち) | 強い | ログイン後の個別ページ、検索結果 |
| ISR | ビルド時+一定間隔で再生成 | 最速 | 最強 | 求人一覧、商品ページ、ニュース |
サーバーサイド出身者向けに言い換えると:
- SSG = 静的HTMLを事前に書き出してNginxから配る
- SSR = LaravelのBladeを毎リクエストレンダリングする
-
ISR = ページキャッシュ +
stale-while-revalidate - CSR = APIだけ返してVue/Reactで描く
概念自体は目新しくない。Next.jsの価値は「これをページ単位・コンポーネント単位で切り替えられる」ことにある。
2. SSG:デフォルトの第一候補
App Routerでは、リクエスト依存の情報(cookie、header、searchParams)に触らなければ自動的に静的化される。
// app/about/page.tsx
// 何も書かなくても、ビルド時にHTMLが生成される
export default function AboutPage() {
return <h1>会社概要</h1>;
}
外部データを使う場合も同様。
// app/blog/page.tsx
export default async function BlogPage() {
const posts = await getPosts(); // キャッシュ可能な取得なら静的化される
return (
<ul>
{posts.map((p) => <li key={p.id}>{p.title}</li>)}
</ul>
);
}
動的ルートのSSG:generateStaticParams
/blog/[slug] のようなURLをビルド時に量産する。Pages Routerの getStaticPaths の後継。
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params; // 16ではparamsが非同期。awaitが必要
const post = await getPost(slug);
return <article>{post.body}</article>;
}
params が Promise になっているのは16の破壊的変更。cookies() / headers() / searchParams も同様に非同期化された。
注意:ページ数が数万を超えるサイトで全件を generateStaticParams に渡すと、ビルドが数十分コースになる。この対策が後述のISRになる。
3. SSR:リクエストごとに作る
リクエスト固有の情報に触った瞬間、そのページは動的になる。
// app/mypage/page.tsx
import { cookies } from 'next/headers';
export default async function MyPage() {
const cookieStore = await cookies(); // ← ここで動的レンダリング確定
const token = cookieStore.get('session')?.value;
const user = await getUser(token);
return <p>ようこそ、{user.name}さん</p>;
}
明示的に強制することもできる。
export const dynamic = 'force-dynamic';
SSRは「速さを捨てて鮮度を買う」選択。無自覚に使うとTTFBが悪化し、Next.jsを使う旨味が消える。以下のケースに限定したい。
- ログイン状態で内容が変わる
- 在庫数・残席数など秒単位の鮮度が要る
- 検索結果のように組み合わせが無限にある
4. ISR:SSGの弱点を埋める現実解
「静的で配りたいが、コンテンツは日々更新される」——実務で一番多いパターン。
従来型(cacheComponents無効時)
// app/jobs/[id]/page.tsx
export const revalidate = 3600; // 1時間ごとにバックグラウンド再生成
export async function generateStaticParams() {
const jobs = await getPopularJobs();
return jobs.map((job) => ({ id: job.id }));
}
更新をトリガーで即座に反映したいときはオンデマンド無効化。
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function publishJob(id: string) {
await db.job.update({ where: { id }, data: { published: true } });
revalidatePath(`/jobs/${id}`);
revalidateTag('job-list');
}
管理画面から更新 → 該当ページだけ吹き飛ばす、という運用ができる。Laravelの Cache::forget() を叩くのと同じ感覚。
ISRの落とし穴
stale-while-revalidate なので、更新直後の1人目には古いHTMLが返る。 再生成はバックグラウンドで走り、2人目から新しくなる。「更新したのに反映されない」という問い合わせの9割はこれ。オンデマンド無効化を併用するか、仕様として説明しておくこと。
5. Next.js 16の本命:PPR(Partial Prerendering)
ここからが16の新しい話。
従来はページ単位でSSG/SSR/ISRのどれか1つを選ばされた。結果、「ページの99%は静的なのに、ヘッダーのログイン名だけ動的だからページ全体をSSR」という不毛な妥協が発生していた。
PPRはこれを壊す。1ページの中で静的な殻(App Shell)を即座にCDNから返し、動的な穴だけをストリーミングで埋める。
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true, // PPRが既定の挙動になる
partialPrefetching: true, // ISR相当の挙動に必要
};
export default nextConfig;
// app/jobs/[id]/page.tsx
import { Suspense } from 'react';
export default async function JobPage({ params }) {
const { id } = await params;
return (
<>
{/* 静的:ビルド時にHTML化され、CDNから即返る */}
<JobDetail id={id} />
{/* 動的:Suspense境界の中で後からストリーミング */}
<Suspense fallback={<ApplyButtonSkeleton />}>
<ApplyStatus jobId={id} />
</Suspense>
</>
);
}
async function JobDetail({ id }: { id: string }) {
'use cache'; // ← 静的な殻に含めてよい、という宣言
const job = await db.job.findUnique({ where: { id } });
return <h1>{job.title}</h1>;
}
ルールはシンプル:
-
"use cache"を付けたものは静的な殻に入る - キャッシュされないデータ取得は
<Suspense>で包む - 包み忘れるとビルドエラーになる(実行時に黙って遅くなるより遥かにマシ)
3番目が重要で、cacheComponents: true への移行が「フラグを立てるだけ」で済まない理由がここにある。既存アプリなら段階的にやること。
大量ページ × ISR の新しい書き方
16では generateStaticParams に全件を渡さなくてよくなった。人気ページだけ事前生成し、残りは初回アクセス時にApp Shellを即返してバックグラウンドで完成させる。
export async function generateStaticParams() {
const popular = await getPopularJobs(100);
return popular.map((job) => ({ id: job.id })); // 100件だけ事前生成
}
未指定のURLも普通に配信され、初回訪問後にキャッシュへ昇格する。Pages Routerの fallback: true に相当する挙動が既定になったイメージ。router.isFallback は不要。
なお Cache Components有効時、generateStaticParams は最低1件を返す必要がある。空配列はビルドエラー。
6. SEOで踏みやすい罠
PPRでは、ボット・クローラーはUser-Agentで判定され、殻ではなく完全なHTMLを受け取る(レンダリング完了を待ってから返す)。人間には速く、クローラーには完全なHTMLを、という設計。
ここに罠がある。殻の生成がビルド時にしか存在しない値に依存していると、人間には表示できてクローラーにはエラー、という状態が起こりうる。求人サイトのようにSEOが売上に直結する案件では、リリース前に必ずクローラーUAで実際に叩いて確認すること。
curl -A "Googlebot" https://example.com/jobs/123 | head -50
7. 選択フローチャート
そのページはログイン必須か?
├─ Yes → SSR(またはCSRでAPI取得)
└─ No
└─ コンテンツは更新されるか?
├─ ほぼ不変(規約・LP) → SSG
└─ 更新される
└─ 秒単位の鮮度が要るか?
├─ Yes(在庫・残席) → PPRで該当箇所だけ動的に
└─ No(記事・求人) → ISR(+ オンデマンド無効化)
現実の実装では、1ページの中でこれが混在する。 「このページはSSR」ではなく「このコンポーネントのこのデータはキャッシュする / しない」という粒度で考えるのが16以降の正解。
8. 移行するなら
既存のNext.js 15アプリを16のCache Componentsに移すときの順番。
- まず16へ上げる(
npx @next/codemod@canary upgrade latest)。この時点ではcacheComponentsは入れない -
params/cookies()/headers()の非同期化に対応してビルドを通す -
next build --webpackで通ることを確認してからTurbopackに切り替え - 最後に
cacheComponents: true。ビルドエラーを潰しながら"use cache"と<Suspense>を足していく
4番目だけで数日〜数週間かかる規模もある。まとめてやらないこと。