Next.js: レンダリング戦略: SSR、SSG & ISR

最終更新:2026-08-26

Next.js のレンダリング戦略を使うと、「ビルド時に準備完了」か「オンデマンド」かを選択できます。重要なのは適切なモードを選ぶことです。

1. 学習目標



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秒ごとに再検証、そしてオンデマンドで即時更新します。

TSX
// 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 境界に分割し、クライアントに順次送信します。

100%
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'

毎回のリクエストでページが再生成されることを保証し、高度なパーソナライズデータ (ユーザーダッシュボード、ショッピングカート) に適しています。

TSX
// 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'

ビルド時にページを強制的に静的レンダリングします。動的関数が含まれていても、それらの関数は事前計算されます。

TSX
// 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 ストリーミング読み込み体験 (難易度: ⭐)

💻 出力:

TEXT 📖 参照専用
AboutPage コンポーネントの UI をレンダリングします。
TSX
// 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>
}
💻 出力:

TEXT 📖 参照専用
静的シェルを即座にレンダリングし、Suspense 境界内で動的コンテンツを読み込みます。
Fallback: Loading slow data...
表示テキスト: Streaming SSR Demo | This text appears immediately (static shell). | Loading slow data... | }>


4. SSG 静的サイト生成と generateStaticParams

SSG (静的サイト生成) はビルド時にすべての HTML ページを一度に生成します。コンテンツの変更頻度が低いシーン (ブログ、ドキュメント、マーケティングページ) に適しています。

(1) 静的ページ

TSX
// 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] などの動的ルーティングを使用するページに使用します。可能なすべてのパスパラメータを返し、ビルド時に一度だけ生成します。

TSX
// 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 動的ルーティング事前生成 (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
データを取得し、アイテムのリストをレンダリングします。
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
ビルドログ:
✓ Generating static pages (5/5)  /products/1  /products/2  /products/3  /products/4  /products/5
💻 出力:

TEXT 📖 参照専用
ブラウザで各商品ページをレンダリング:
  /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 ウィンドウが切れた後にバックグラウンドで再生成がトリガーされるため、ユーザーは常にキャッシュされたページを参照します。

100%
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) ルートセグメント設定での再検証

TSX
// 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() を使用して即時再生成をトリガーします。

TSX
// 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 タイムウィンドウ (難易度 ⭐⭐)

💻 出力:

TEXT 📖 参照専用
updatePost コンポーネントの UI をレンダリングします。
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
データを取得し、結果をレンダリングします。
表示テキスト: ISR Demo — Revalidate every 30s | 現在時刻: {time.datetime}

▶ サンプル: API ルートのオンデマンド再検証 (難易度: ⭐⭐⭐)

💻 出力:

TEXT 📖 参照専用
上記の説明通りにページをレンダリングし、説明の動作に基づいて UI が更新されます。
TSX
// 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 })
}
BASH
# 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"}'
💻 出力:

TEXT 📖 参照専用
{"status":"ok","data":{}}

▶ サンプル: dynamic = 'force-static' で静的ページを設定 (難易度: ⭐)

💻 出力:

TEXT 📖 参照専用
上記の説明通りにページをレンダリングし、説明の動作に基づいて UI が更新されます。
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
レンダリング: Static Page (built at build time)
表示テキスト: Static Page (built at build time) | This timestamp is fixed: {time} | Refresh the page — the time never changes.
TSX
// 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>
  )
}
💻 出力比較:

TEXT 📖 参照専用
静的ページ:  タイムスタンプは常に "2026-07-06T10:00:00.000Z"  ← 変わらない
動的ページ: タイムスタンプは更新ごとに変化                     ← 毎回異なる


6. 完全な例: 3モードブログシステム

TSX
// 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>
  )
}

❓ よくある質問

Q SSG と ISR の違いは何ですか?
A SSG はビルド時に一度だけ生成され、更新にはリビルドが必要です。ISR はビルド時に生成され、再検証ウィンドウが切れるとバックグラウンドで自動的に再生成されます。ISR = SSG + 自動更新です。ISR はコンテンツの更新頻度が高いシーンにより適しています。
Q generateStaticParams で大量のページを生成すると、ビルド時間が長くなりすぎませんか?
A はい。10,000 ページ × 1秒 = 10,000 秒 (約2.8時間) です。解決策: 人気のあるページだけを生成し (例: 上位100件)、未生成のページは初回訪問時にオンデマンド SSG を実行します (「オンデマンド ISR」と呼ばれます)。export const dynamicParams = true (デフォルト) を使用して、未事前生成パスの動的レンダリングを許可します。
Q SSR での Suspense 境界はどのように機能しますか?
A<Suspense> 境界は独立したストリーミング出力単位です。サーバーは最初に静的 HTML シェルを送信し、その後各 Suspense 境界のコンテンツを段階的に送信します。ユーザーには「段階的な読み込み」効果が見え、すべてのデータが準備できるのを待つ必要はありません。
Q force-dynamiccache: 'no-store' の違いは何ですか?
A force-dynamic はルートセグメント設定 (ページ全体が動的) で、cache: 'no-store' はデータ取得設定 (個別の fetch がキャッシュされない) です。force-dynamic はそのページのすべてのデータキャッシュを無効にします。no-storeforce-cache の組み合わせなど、キャッシュのきめ細かい調整を優先し、force-dynamic を万能薬として使わないことを推奨します。
Q revalidatePathrevalidateTag ではどちらのパフォーマンスが優れていますか?
A revalidateTag の方が優れています。そのタグに一致するキャッシュエントリのみをクリアし、他のデータには影響しません。revalidatePath はパスに沿ったすべてのキャッシュエントリを走査する必要があり、オーバーヘッドが大きくなります。意味のあるタグ付けシステムを作成し、tags を使用してきめ細かく制御することを推奨します。
Q ISR の「stale-while-revalidate」とは何を意味しますか?
A stale-while-revalidate モード: ユーザーがリクエストしたとき、キャッシュが期限切れの場合、システムは即座に古いキャッシュページを返しながら、バックグラウンドで再生成をトリガーします。次のユーザーには新しいバージョンが表示されます。この方法では、ユーザーがページ生成を待つ必要はありませんが、データに一時的な遅延が生じる可能性があります。

📖 まとめ


📝 練習問題

  1. 基本課題 (⭐): app/ssg-demo/[id]/page.tsx を作成し、generateStaticParams を使用して 5 つの静的ページ /products/1 から /products/5 を事前生成します (データは FakeStore API から取得)。ビルド後、各ページの HTML が静的ファイルであることを確認してください。

  2. 応用問題 (⭐⭐): ISR 時刻表示ページ app/isr-clock/page.tsx を作成し、revalidate = 15 を設定して World Time API で現在時刻を表示します。15秒ごとにページを更新して時刻が更新されていることを確認します。revalidatePath() を使用してページを即座に更新する Server Action forceRefresh() を追加します。

  3. 発展問題 (⭐⭐⭐): 完全なブログシステムを構築します: app/blog/page.tsx (ISR 一覧、120秒ごとに再検証)、app/blog/[slug]/page.tsx (SEO メタデータ + JSON-LD 構造化データ)、app/api/revalidate/route.ts (Webhook エンドポイント、CMS 変更通知を受信し、タグでキャッシュを更新)。Webhook 呼び出しのサンプルスクリプトを提供します。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%