Next.js: レンダリング戦略: SSR、SSG & ISR
最終更新:2026-08-26
Next.js のレンダリング戦略を使うと、「ビルド時に準備完了」か「オンデマンド」かを選択できます。重要なのは適切なモードを選ぶことです。
1. 学習目標
- SSR ストリーミングレンダリングと Suspense 境界でのリアルタイムファーストバイト
- SSG 静的サイト生成と
generateStaticParams()による動的ルートの事前生成 - ISR 増分静的再生成と
revalidateタイムウィンドウ force-dynamicとforce-staticのルートセグメント設定revalidatePath()/revalidateTag()によるオンデマンド ISR トリガー
2. あるテクニカルマネージャーの実話
(1) 課題: CMSページの読み込みに6秒かかり、運用チームは1日50回更新する
Charlie は TaskFlow チームのテクニカルリードです。会社の e コマース CMS には 10,000 の商品ページがあり、各ページには説明、価格、在庫、画像が含まれています。最も深刻な問題は次のとおりです:
| 問題 | データ |
|---|---|
| 単一ページの SSR 応答時間 | 6秒 (リクエストごとにデータベースクエリ) |
| 1日の運用更新回数 | 50回以上 (価格/在庫/プロモーション) |
| サーバー CPU | 常時 85%以上 |
| キャッシュポリシー | ❌ なし — 毎回リアルタイムレンダリング |
運用チームは15分ごとに価格を更新しますが、SSR では毎回新しくデータベースにクエリする必要があります — 10,000 ページ × 6秒 = 1日あたり 60,000 秒の CPU オーバーヘッドです。
(2) ISR による解決策
ISR でビルド時に静的ページを生成し、N秒ごとに再検証、そしてオンデマンドで即時更新します。
// app/products/[id]/page.tsx — ISR 増分静的再生成
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { id: true } })
return products.map(p => ({ id: String(p.id) }))
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({ where: { id: Number(params.id) } })
return <ProductView product={product} />
}
// ルートセグメント設定
export const revalidate = 300 // 5分ごとに再検証
(3) 成果
| 指標 | 純粋な SSR | ISR |
|---|---|---|
| 応答時間 | 6秒 | < 50 ms (静的 HTML) |
| サーバー CPU | 85% | < 10% |
| 運用更新 | ❌ 6秒待つ必要あり | ✅ 即時 (オンデマンド再検証) |
| CDN キャッシュ | ❌ 非対応 | ✅ 全ページキャッシュ対応 |
| データベース QPS | 10,000/時間 | ~50/時間 |
3. SSR (サーバーサイドレンダリング)
SSR (サーバーサイドレンダリング) はリクエストごとにサーバー上で HTML をレンダリングします。Next.js 16 は React 18 のストリーミング SSR を使用します — ページ全体のレンダリング完了を待つ代わりに、ページを複数の Suspense 境界に分割し、クライアントに順次送信します。
sequenceDiagram
participant Client as ブラウザ
participant Server as Next.js サーバー
participant DB as データベース
Client->>Server: GET /dashboard
Server->>Client: 静的 HTML シェルを送信 (即時)
Server->>DB: データクエリ (並列)
DB-->>Server: データのサブセットを返す
Server->>Client: Suspense 境界1 をストリーミング送信
DB-->>Server: さらにデータを読み込み
Server->>Client: Suspense 境界2 をストリーミング送信
Client->>Client: コンテンツを段階的にレンダリング
| 設定 | 構文 | 動作 |
|---|---|---|
| デフォルト (SSR ストリーミング) | 設定なし | 動的ページレンダリング、Suspense ストリーミング対応 |
force-dynamic |
export const dynamic = 'force-dynamic' |
毎回のリクエストで再レンダリング、キャッシュ無効化 |
force-static |
export const dynamic = 'force-static' |
ビルド時に静的コンパイル、動的動作を無効化 |
(1) dynamic = 'force-dynamic'
毎回のリクエストでページが再生成されることを保証し、高度なパーソナライズデータ (ユーザーダッシュボード、ショッピングカート) に適しています。
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'
export default async function DashboardPage() {
const user = await getCurrentUser() // リクエストごとに最新データを取得
return <DashboardView user={user} />
}
(2) dynamic = 'force-static'
ビルド時にページを強制的に静的レンダリングします。動的関数が含まれていても、それらの関数は事前計算されます。
// app/about/page.tsx
export const dynamic = 'force-static'
export default async function AboutPage() {
const version = await getVersion() // ビルド時に1回だけ計算
return <div>Version: {version}</div>
}
▶ サンプル: SSR ストリーミング読み込み体験 (難易度: ⭐)
AboutPage コンポーネントの UI をレンダリングします。
// app/streaming-demo/page.tsx
import { Suspense } from 'react'
export default function StreamingDemoPage() {
return (
<div>
<h1>Streaming SSR Demo</h1>
<p>このテキストは即座に表示されます (静的シェル)。</p>
<Suspense fallback={<div>遅いデータを読み込み中...</div>}>
<SlowComponent delay={3000} />
</Suspense>
<Suspense fallback={<div>速いデータを読み込み中...</div>}>
<SlowComponent delay={1000} />
</Suspense>
</div>
)
}
async function SlowComponent({ delay }: { delay: number }) {
await new Promise(resolve => setTimeout(resolve, delay))
return <div>Loaded after {delay}ms</div>
}
静的シェルを即座にレンダリングし、Suspense 境界内で動的コンテンツを読み込みます。
Fallback: Loading slow data...
表示テキスト: Streaming SSR Demo | This text appears immediately (static shell). | Loading slow data... | }>
4. SSG 静的サイト生成と generateStaticParams
SSG (静的サイト生成) はビルド時にすべての HTML ページを一度に生成します。コンテンツの変更頻度が低いシーン (ブログ、ドキュメント、マーケティングページ) に適しています。
(1) 静的ページ
// app/docs/page.tsx — ビルド時に生成
export default async function DocsPage() {
const docs = await fetch('https://cms.example.com/docs', {
cache: 'force-cache' // ビルド時に取得、永続キャッシュ
}).then(r => r.json())
return <ArticleView content={docs} />
}
(2) generateStaticParams() 動的ルートの事前生成
[id] や [slug] などの動的ルーティングを使用するページに使用します。可能なすべてのパスパラメータを返し、ビルド時に一度だけ生成します。
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://cms.example.com/posts').then(r => r.json())
return posts.map((post: any) => ({ slug: post.slug })) // → /blog/hello-world, /blog/nextjs-guide ...
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`).then(r => r.json())
return <article><h1>{post.title}</h1><div>{post.content}</div></article>
}
▶ サンプル: SSG 動的ルーティング事前生成 (難易度: ⭐⭐)
データを取得し、アイテムのリストをレンダリングします。
// app/products/[id]/page.tsx
type Product = { id: number; title: string; price: number }
export async function generateStaticParams() {
const products: Product[] = await fetch('https://fakestoreapi.com/products').then(r => r.json())
return products.slice(0, 5).map(p => ({ id: String(p.id) }))
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product: Product = await fetch(`https://fakestoreapi.com/products/${params.id}`).then(r => r.json())
return (
<div>
<h1>{product.title}</h1>
<p>Price: ${product.price}</p>
</div>
)
}
ビルドログ:
✓ Generating static pages (5/5) /products/1 /products/2 /products/3 /products/4 /products/5
ブラウザで各商品ページをレンダリング:
/products/1 → 商品タイトル + "Price: $109.95"
/products/2 → 商品タイトル + "Price: $22.30"
/products/3 → 商品タイトル + "Price: $55.99"
/products/4 → 商品タイトル + "Price: $15.99"
/products/5 → 商品タイトル + "Price: $695.00"
5. ISR 増分静的再生成
ISR (増分静的再生成) は SSG と SSR の中間に位置します。ビルド時に静的ページを生成し、revalidate ウィンドウが切れた後にバックグラウンドで再生成がトリガーされるため、ユーザーは常にキャッシュされたページを参照します。
graph LR
A[ビルド] --> B[静的 HTML を生成]
B --> C[CDN キャッシュ]
C --> D[ユーザーリクエスト]
D --> E{revalidate<br/>期限切れ?}
E -->|いいえ| F[キャッシュを返す]
E -->|はい| G[キャッシュを返す + バックグラウンド再生成]
G --> B
| モード | revalidate | 動作 | 初回訪問 | 更新遅延 |
|---|---|---|---|---|
| SSG | なし | ビルド時のみ生成 | そのまま | リビルドが必要 |
| ISR | revalidate: 60 |
60秒後にバックグラウンド更新 | リアルタイム | ≤ 60秒 |
| SSR | dynamic: 'force-dynamic' |
リクエストごとに更新 | 動的生成 | リアルタイム |
(1) ルートセグメント設定での再検証
// app/posts/[id]/page.tsx
export const revalidate = 3600 // 1時間ごとに再検証
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await fetch(`https://cms.example.com/posts/${params.id}`, {
next: { revalidate: 3600 } // fetch レベルでも対応
}).then(r => r.json())
return <PostView post={post} />
}
(2) オンデマンド ISR (オンデマンド再検証)
データが変更されたときに revalidatePath() または revalidateTag() を使用して即時再生成をトリガーします。
// app/admin/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updatePost(formData: FormData) {
const id = formData.get('id') as string
await db.post.update({ where: { id: Number(id) }, data: { title: formData.get('title') as string } })
revalidatePath(`/posts/${id}`) // 単一記事を更新
revalidatePath('/posts') // 一覧ページを更新
revalidateTag('posts') // posts タグを持つすべてのキャッシュを更新
}
▶ サンプル: ISR タイムウィンドウ (難易度 ⭐⭐)
updatePost コンポーネントの UI をレンダリングします。
// app/isr-demo/page.tsx
export const revalidate = 30 // 30秒で再検証
export default async function IsrDemoPage() {
const time = await fetch('http://worldtimeapi.org/api/timezone/Asia/Shanghai', {
next: { tags: ['time'] }
}).then(r => r.json())
return (
<div>
<h1>ISR Demo — 30秒ごとに再検証</h1>
<p>現在時刻: {time.datetime}</p>
<p>生成時刻: {new Date().toISOString()}</p>
</div>
)
}
データを取得し、結果をレンダリングします。
表示テキスト: ISR Demo — Revalidate every 30s | 現在時刻: {time.datetime}
▶ サンプル: API ルートのオンデマンド再検証 (難易度: ⭐⭐⭐)
上記の説明通りにページをレンダリングし、説明の動作に基づいて UI が更新されます。
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidate-secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
}
const body = await request.json()
if (body.type === 'path') {
revalidatePath(body.path)
} else if (body.type === 'tag') {
revalidateTag(body.tag)
}
return NextResponse.json({ revalidated: true })
}
# CMS Webhook からの呼び出し
curl -X POST https://example.com/api/revalidate \
-H "x-revalidate-secret: your-secret" \
-H "Content-Type: application/json" \
-d '{"type": "tag", "tag": "posts"}'
{"status":"ok","data":{}}
▶ サンプル: dynamic = 'force-static' で静的ページを設定 (難易度: ⭐)
上記の説明通りにページをレンダリングし、説明の動作に基づいて UI が更新されます。
// app/static-page/page.tsx — 強制静的化
export const dynamic = 'force-static'
export default async function StaticPage() {
const time = new Date().toISOString()
return (
<div>
<h1>Static Page (ビルド時に生成)</h1>
<p>このタイムスタンプは固定です: {time}</p>
<p>ページを更新しても時間は変わりません。</p>
</div>
)
}
レンダリング: Static Page (built at build time)
表示テキスト: Static Page (built at build time) | This timestamp is fixed: {time} | Refresh the page — the time never changes.
// app/dynamic-page/page.tsx — 強制動的化
export const dynamic = 'force-dynamic'
export default async function DynamicPage() {
const time = new Date().toISOString()
return (
<div>
<h1>Dynamic Page (リクエストごとにレンダリング)</h1>
<p>このタイムスタンプは毎回更新されます: {time}</p>
</div>
)
}
静的ページ: タイムスタンプは常に "2026-07-06T10:00:00.000Z" ← 変わらない
動的ページ: タイムスタンプは更新ごとに変化 ← 毎回異なる
6. 完全な例: 3モードブログシステム
// app/blog/layout.tsx
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return <div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>{children}</div>
}
// app/blog/page.tsx — ブログ一覧 (SSG + ISR)
export const revalidate = 300 // 5分 ISR
export default async function BlogListPage() {
const posts = await fetch('https://jsonplaceholder.typicode.com/posts', {
next: { tags: ['blog-posts'] }
}).then(r => r.json())
return (
<div>
<h1>Blog</h1>
<ul>{posts.slice(0, 10).map((p: any) => (
<li key={p.id}><a href={`/blog/${p.id}`}>{p.title}</a></li>
))}</ul>
<p>最終生成: {new Date().toISOString()}</p>
</div>
)
}
// app/blog/[id]/page.tsx — 記事詳細 (SSG + ISR)
export async function generateStaticParams() {
const posts = await fetch('https://jsonplaceholder.typicode.com/posts').then(r => r.json())
return posts.slice(0, 10).map((p: any) => ({ id: String(p.id) }))
}
export default async function BlogPostPage({ params }: { params: { id: string } }) {
const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`, {
next: { tags: [`post-${params.id}`] }
}).then(r => r.json())
const comments = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}/comments`, {
cache: 'no-store'
}).then(r => r.json())
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<h2>Comments ({comments.length})</h2>
<ul>{comments.map((c: any) => (
<li key={c.id}><strong>{c.name}:</strong> {c.body}</li>
))}</ul>
</article>
)
}
// app/blog/actions.ts — オンデマンド更新
'use server'
import { revalidateTag } from 'next/cache'
export async function refreshBlog() {
revalidateTag('blog-posts')
}
// app/blog/[id]/admin/page.tsx — 管理パネル (SSR)
export const dynamic = 'force-dynamic'
export default async function AdminPage({ params }: { params: { id: string } }) {
const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`).then(r => r.json())
return (
<form action={async (fd) => {
'use server'
await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`, { method: 'PATCH', body: JSON.stringify({ title: fd.get('title') }) })
revalidateTag(`post-${params.id}`)
}}>
<input name="title" defaultValue={post.title} />
<button type="submit">Update</button>
</form>
)
}
❓ よくある質問
generateStaticParams で大量のページを生成すると、ビルド時間が長くなりすぎませんか?export const dynamicParams = true (デフォルト) を使用して、未事前生成パスの動的レンダリングを許可します。<Suspense> 境界は独立したストリーミング出力単位です。サーバーは最初に静的 HTML シェルを送信し、その後各 Suspense 境界のコンテンツを段階的に送信します。ユーザーには「段階的な読み込み」効果が見え、すべてのデータが準備できるのを待つ必要はありません。force-dynamic と cache: 'no-store' の違いは何ですか?force-dynamic はルートセグメント設定 (ページ全体が動的) で、cache: 'no-store' はデータ取得設定 (個別の fetch がキャッシュされない) です。force-dynamic はそのページのすべてのデータキャッシュを無効にします。no-store と force-cache の組み合わせなど、キャッシュのきめ細かい調整を優先し、force-dynamic を万能薬として使わないことを推奨します。revalidatePath と revalidateTag ではどちらのパフォーマンスが優れていますか?revalidateTag の方が優れています。そのタグに一致するキャッシュエントリのみをクリアし、他のデータには影響しません。revalidatePath はパスに沿ったすべてのキャッシュエントリを走査する必要があり、オーバーヘッドが大きくなります。意味のあるタグ付けシステムを作成し、tags を使用してきめ細かく制御することを推奨します。📖 まとめ
- SSR ストリーミングレンダリングは、Suspense 境界を通じて即時ファーストバイトと段階的な読み込みを実現します
dynamic = 'force-static'/'force-dynamic'でページのレンダリングモードを制御しますgenerateStaticParams()は、ビルド時に動的ルーティングのすべてのページ組み合わせを事前生成します- ISR は
revalidate = Nを使用して静的ページの自動増分更新を実装します - オンデマンド ISR は
revalidatePath()/revalidateTag()を使用して、データ変更時に即時更新します - レンダリング戦略選択ピラミッド: SSG (不変) → ISR (たまに変更) → SSR (リアルタイム変更) → PPR (部分的に変更)
📝 練習問題
-
基本課題 (⭐):
app/ssg-demo/[id]/page.tsxを作成し、generateStaticParamsを使用して 5 つの静的ページ/products/1から/products/5を事前生成します (データは FakeStore API から取得)。ビルド後、各ページの HTML が静的ファイルであることを確認してください。 -
応用問題 (⭐⭐): ISR 時刻表示ページ
app/isr-clock/page.tsxを作成し、revalidate = 15を設定して World Time API で現在時刻を表示します。15秒ごとにページを更新して時刻が更新されていることを確認します。revalidatePath()を使用してページを即座に更新する Server ActionforceRefresh()を追加します。 -
発展問題 (⭐⭐⭐): 完全なブログシステムを構築します:
app/blog/page.tsx(ISR 一覧、120秒ごとに再検証)、app/blog/[slug]/page.tsx(SEO メタデータ + JSON-LD 構造化データ)、app/api/revalidate/route.ts(Webhook エンドポイント、CMS 変更通知を受信し、タグでキャッシュを更新)。Webhook 呼び出しのサンプルスクリプトを提供します。