Next.js: 部分プリレンダリング (PPR)

最終更新:2026-08-26

PPR は Next.js 16 で最も画期的なレンダリングモードです — 1つのページで、静的シェルの超高速応答性と動的セクションのリアルタイムコンテンツを組み合わせることができます。

1. 学習目標



2. あるアーキテクトの実話

(1) 課題: ダッシュボードは完全に静的 (古いデータ) か、完全に動的 (遅い) のどちらか

Diana は TaskFlow チームのアーキテクトです。会社の SaaS ダッシュボードはジレンマに直面しています:

彼女が望むのは、ナビゲーションバー/サイドバー/レイアウトは静的 (ビルド時に生成し CDN でキャッシュ)、ユーザーデータ/通知は動的 (リアルタイム取得) という状態です。しかし、従来の SSG または SSR ではどちらか一方しか選べません — 1つのページに1つのレンダリングモードしか適用できないのです。

(2) PPR による解決策

PPR を使用して、ダッシュボードを静的シェル (レイアウト + ナビゲーション) と動的領域 (Suspense 境界) に分割します。

TSX
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { NavBar } from '@/components/NavBar'
import { UserGreeting } from '@/components/UserGreeting'
import { NotificationList } from '@/components/NotificationList'
import { Skeleton } from '@/components/Skeleton'

export default function DashboardPage() {
  return (
    <div>
      <NavBar />                           {/* 静的シェル: ビルド時に生成 */}
      <Suspense fallback={<Skeleton />}>  {/* 動的境界 */}
        <UserGreeting />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <NotificationList />
      </Suspense>
    </div>
  )
}

(3) 成果

指標 純粋な SSR 純粋な SSG PPR
ファーストバイト時間 (TTFB) 4秒 50 ms 50 ms
データの新鮮さ ✅ 最新 ❌ ビルド時のスナップショット ✅ 動的領域はリアルタイム
サーバー CPU 85% < 5% < 15%
CDN キャッシュ ❌ 非対応 ✅ 全ページ ✅ 静的シェル
実装の複雑さ (Suspense を追加するだけ)


3. PPR の概念と設定

PPR (部分プリレンダリング) の核心概念は、ページが事前レンダリングされた静的シェル動的ストリーミング領域の両方を含むことができることです。静的部分はビルド時に生成され、動的部分はオンデマンドでレンダリングされます。

(1) 静的シェル + 動的境界

100%
graph TB
    subgraph "PPR ページ"
        A[静的シェル<br/>レイアウト + ナビゲーション]
        B[Suspense 境界1<br/>ユーザー情報 - 動的]
        C[Suspense 境界2<br/>通知一覧 - 動的]
        D[Suspense 境界3<br/>リアルタイムチャート - 動的]
    end

    A --> B
    A --> C
    A --> D

    style A fill:#d4edda
    style B fill:#cce5ff
    style C fill:#cce5ff
    style D fill:#cce5ff
コンポーネントタイプ レンダリングタイミング キャッシュ戦略 代表的なコンポーネント
静的シェル ビルド時 CDN キャッシュ レイアウト、NavBar、フッター、サイドバー、ロゴ
動的境界 リクエスト時 キャッシュしない (または短期キャッシュ) UserGreeting、NotificationList、リアルタイムチャート、検索

(2) PPR の有効化

PPR は Next.js 16 ではデフォルトで無効化されており、next.config.ts で有効にする必要があります:

TS
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    ppr: true  // PPR を有効化
  }
}

export default nextConfig
BASH
# 設定後、開発サーバーを起動
npm run dev
⚠️ 注意: PPR には Next.js 16.2 以降が必要です。有効化すると、dynamic = 'force-dynamic'cache: 'no-store' を含まないすべてのページが、デフォルトで PPR 最適化の恩恵を自動的に受けます。ビルド時に次のログが表示されます: ✓ PPR enabled for /dashboard

▶ サンプル: PPR と非 PPR の比較 (難易度: ⭐)

💻 出力:

TEXT 📖 参照専用
React コンポーネントがブラウザで説明された UI をレンダリングします。
TSX
// app/ppr-compare/page.tsx — PPR が有効化されると自動的に適用されます
import { Suspense } from 'react'

// 静的シェル部分 (ビルド時に生成)
export default function PprComparePage() {
  return (
    <div>
      <header style={{ background: '#f0f0f0', padding: 16 }}>
        <h1>PPR Demo</h1>
        <nav><a href="/">Home</a> | <a href="/about">About</a></nav>
      </header>

      {/* 動的境界: リクエストごとに新しく取得 */}
      <Suspense fallback={<div style={{ padding: 16 }}>ユーザーを読み込み中...</div>}>
        <RealtimeUser />
      </Suspense>

      {/* 静的境界: ビルド時にレンダリング */}
      <footer style={{ borderTop: '1px solid #ddd', padding: 16 }}>
        <p>Built at: {new Date().toISOString()}</p>
      </footer>
    </div>
  )
}

async function RealtimeUser() {
  const user = await fetch('https://api.example.com/me', { cache: 'no-store' })
    .then(r => r.json())
  return <div style={{ padding: 16 }}>Welcome, {user.name}</div>
}
💻 出力:

TEXT 📖 参照専用
レンダリング: 静的サイドバー + Suspense 境界内の動的ユーザープロフィールとプロジェクト統計を持つダッシュボード。


4. Suspense: 境界としての動的境界

PPR の核心原則: すべての <Suspense> でラップされたコンテンツは動的にレンダリングされる境界です。Suspense でラップされていないセクションは、ビルド時に事前レンダリングされる静的シェルです。

(1) 境界ルール

100%
graph LR
    A[ページコンポーネント] --> B[静的コンテンツ<br/>Suspense なし]
    A --> C[Suspense 境界]
    C --> D[動的子コンポーネント<br/>リクエストごとに再レンダリング]
    A --> E[別の Suspense]
    E --> F[個別の動的ゾーン]
ラップ状態 PPR の動作
<Suspense> でラップ 動的 — リクエスト時にレンダリング、リアルタイムコンテンツ ユーザー情報、在庫データ
<Suspense> なし 静的 — ビルド時にレンダリング、CDN でキャッシュ ナビゲーションバー、フッター、ロゴ

(2) 不要な Suspense を避ける

コンポーネントがリアルタイムデータを必要としない場合は、Suspense でラップしないでください — そうすることで静的シェルの一部になります。

TSX
// app/dashboard/page.tsx
export default function DashboardPage() {
  return (
    <div>
      {/* ✅ 静的: サイドバーは常に同じ、リアルタイム更新不要 */}
      <Sidebar />

      {/* ✅ 動的: リアルタイム取得が必要 */}
      <Suspense fallback={<LoadingSpinner />}>
        <RealtimeData />
      </Suspense>

      {/* ❌ 不要な Suspense: このコンポーネントには動的データがない */}
      <Suspense fallback={<LoadingSpinner />}>
        <StaticAboutSection />  {/* ラップ不要 */}
      </Suspense>
    </div>
  )
}

▶ サンプル: 複数の Suspense 境界の読み込み順序 (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
静的シェルを即座にレンダリングし、Suspense 境界内で動的コンテンツを読み込みます。
Fallback: }>
表示テキスト: }> | }>
TSX
// app/ppr-timing/page.tsx
import { Suspense } from 'react'

export default function PprTimingPage() {
  return (
    <div>
      <h1>PPR Timing Demo</h1>
      {/* 静的シェル: 即座に表示 */}
      <p>このテキストは即座に表示されます (静的シェル)</p>

      {/* 動的境界1: 2秒後に表示 */}
      <Suspense fallback={<div>⏳ セクション1を読み込み中...</div>}>
        <DelayedSection label="Section 1" delay={2000} />
      </Suspense>

      {/* 動的境界2: 4秒後に表示、境界1とは独立 */}
      <Suspense fallback={<div>⏳ セクション2を読み込み中...</div>}>
        <DelayedSection label="Section 2" delay={4000} />
      </Suspense>
    </div>
  )
}

async function DelayedSection({ label, delay }: { label: string; delay: number }) {
  await new Promise(resolve => setTimeout(resolve, delay))
  return <div>✅ {label} loaded after {delay}ms</div>
}
💻 出力:

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


5. PPR vs ISR vs SSR パフォーマンス比較

3つのレンダリングモードにはそれぞれユースケースがあり、PPR は「部分的に静的 + 部分的に動的」というシナリオのギャップを埋めます。

指標 SSR ISR PPR
レンダリングタイミング リクエストごと ビルド + バックグラウンドオンデマンド ビルド (静的シェル) + リクエスト (動的コンテンツ)
キャッシュポリシー CDN 非キャッシュ 全ページ CDN キャッシュ 静的シェル CDN + 動的領域非キャッシュ
リアルタイム性 ✅ 最新 ⚠️ 最大遅延 = revalidate ✅ 動的領域はリアルタイム
サーバー負荷 (動的部分のみレンダリング)
ユースケース パーソナライズ・認証ページ コンテンツサイト・ブログ ダッシュボード・ハイブリッドページ
初回表示時間 遅い (サーバーレンダリング待ち) 速い (キャッシュ) 速い (静的シェル即時)

(1) 選択推奨

100%
graph TB
    A[このページには何が必要?] --> B{リアルタイムデータ?}
    B -->|全ページリアルタイム| C[SSR]
    B -->|一部の領域がリアルタイム| D[PPR]
    B -->|リアルタイムデータなし| E{更新頻度?}
    E -->|頻繁| F[ISR]
    E -->|ほぼ不変| G[SSG]

    style D fill:#d4edda

▶ サンプル: PPR ダッシュボードコンポーネント設計 (難易度: ⭐⭐⭐)

💻 出力:

TEXT 📖 参照専用
レンダリング戦略の図: 静的シェル + 動的 Suspense 境界。
TSX
// app/dashboard-ppr/page.tsx — PPR 実践設計
import { Suspense } from 'react'

// ======== 静的シェルコンポーネント ========
function DashboardHeader() {
  return (
    <header style={{ background: '#1a1a2e', color: 'white', padding: '16px 24px' }}>
      <h1 style={{ margin: 0 }}>TaskFlow Dashboard</h1>
    </header>
  )
}

function Sidebar() {
  return (
    <nav style={{ width: 240, background: '#f5f5f5', padding: 16, minHeight: 'calc(100vh - 64px)' }}>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        <li><a href="/">🏠 Home</a></li>
        <li><a href="/projects">📁 Projects</a></li>
        <li><a href="/tasks">✅ Tasks</a></li>
        <li><a href="/analytics">📊 Analytics</a></li>
        <li><a href="/settings">⚙️ Settings</a></li>
      </ul>
    </nav>
  )
}

// ======== 動的境界コンポーネント ========
async function UserProfile() {
  const user = await fetch('https://api.example.com/me', { cache: 'no-store' }).then(r => r.json())
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
      <img src={user.avatar} alt="" style={{ borderRadius: '50%', width: 40, height: 40 }} />
      <div>
        <strong>{user.name}</strong>
        <p style={{ margin: 0, fontSize: 12, color: '#666' }}>{user.role}</p>
      </div>
    </div>
  )
}

async function ProjectStats() {
  const stats = await fetch('https://api.example.com/stats', { next: { revalidate: 60 } }).then(r => r.json())
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}>
      <StatCard label="Active Projects" value={stats.activeProjects} color="#4caf50" />
      <StatCard label="Pending Tasks" value={stats.pendingTasks} color="#ff9800" />
      <StatCard label="Completed" value={stats.completed} color="#2196f3" />
    </div>
  )
}

function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
  return (
    <div style={{ border: `1px solid ${color}`, borderRadius: 8, padding: 16, textAlign: 'center' }}>
      <p style={{ fontSize: 28, fontWeight: 'bold', color, margin: 0 }}>{value}</p>
      <p style={{ margin: 0, color: '#666', fontSize: 14 }}>{label}</p>
    </div>
  )
}

export default function DashboardPprPage() {
  return (
    <div style={{ display: 'flex' }}>
      <Sidebar />
      <main style={{ flex: 1, padding: 24 }}>
        <DashboardHeader />
        <Suspense fallback={<div>プロフィールを読み込み中...</div>}>
          <UserProfile />
        </Suspense>
        <div style={{ height: 24 }} />
        <Suspense fallback={<div>統計を読み込み中...</div>}>
          <ProjectStats />
        </Suspense>
      </main>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
データをサーバーサイドで取得し、ページに結果をレンダリングします。
表示内容: TaskFlow Dashboard


6. 完全な例: PPR e コマースダッシュボード

TSX
// app/ppr-ecommerce/page.tsx — PPR e コマースダッシュボード
import { Suspense } from 'react'
import { cookies } from 'next/headers'

// ======== 静的シェル ========
function StoreHeader() {
  return (
    <header style={{ background: '#2c3e50', color: 'white', padding: '12px 24px', display: 'flex', justifyContent: 'space-between' }}>
      <strong>Store Dashboard</strong>
      <span>Built: {new Date().toISOString().split('T')[0]}</span>
    </header>
  )
}

function Navigation() {
  return (
    <nav style={{ background: '#34495e', padding: '8px 24px', display: 'flex', gap: 24, color: 'white' }}>
      <a href="/ppr-ecommerce" style={{ color: 'white' }}>Overview</a>
      <a href="/ppr-ecommerce/orders" style={{ color: 'white' }}>Orders</a>
      <a href="/ppr-ecommerce/products" style={{ color: 'white' }}>Products</a>
    </nav>
  )
}

// ======== 動的コンポーネント ========
async function LiveOrderFeed() {
  const orders = await fetch('https://api.example.com/orders/recent', {
    cache: 'no-store'
  }).then(r => r.json())

  return (
    <div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
      <h2>Live Orders ({orders.length})</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Total</th></tr></thead>
        <tbody>
          {orders.map((o: any) => (
            <tr key={o.id}>
              <td>#{o.id}</td>
              <td>{o.customer}</td>
              <td><StatusBadge status={o.status} /></td>
              <td>${o.total}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    pending: '#ff9800', shipped: '#2196f3', delivered: '#4caf50', cancelled: '#f44336'
  }
  return <span style={{ background: colors[status] ?? '#ccc', color: 'white', padding: '2px 8px', borderRadius: 12, fontSize: 12 }}>{status}</span>
}

async function RevenueWidget() {
  const revenue = await fetch('https://api.example.com/revenue/today', {
    next: { revalidate: 300 }
  }).then(r => r.json())

  return (
    <div style={{ background: 'linear-gradient(135deg, #667eea, #764ba2)', color: 'white', borderRadius: 8, padding: 24 }}>
      <h2 style={{ margin: 0, fontSize: 14, opacity: 0.8 }}>本日の売上</h2>
      <p style={{ fontSize: 36, fontWeight: 'bold', margin: '8px 0' }}>${revenue.total}</p>
      <p style={{ margin: 0, fontSize: 12, opacity: 0.8 }}>↑ {revenue.growth}% vs 昨日</p>
    </div>
  )
}

async function TopProducts() {
  const products = await fetch('https://api.example.com/products/top', {
    next: { tags: ['top-products'] }
  }).then(r => r.json())

  return (
    <div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
      <h2>Top Products</h2>
      <ol>{products.slice(0, 5).map((p: any) => (
        <li key={p.id}>{p.name} — {p.sold} sold</li>
      ))}</ol>
    </div>
  )
}

// ======== ページ (PPR 混合) ========
export default function PprEcommercePage() {
  return (
    <div>
      <StoreHeader />
      <Navigation />
      <main style={{ padding: 24, display: 'grid', gap: 24, gridTemplateColumns: '2fr 1fr' }}>
        <div>
          <Suspense fallback={<div>注文を読み込み中...</div>}>
            <LiveOrderFeed />
          </Suspense>
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
          <Suspense fallback={<div>売上を読み込み中...</div>}>
            <RevenueWidget />
          </Suspense>
          <Suspense fallback={<div>商品を読み込み中...</div>}>
            <TopProducts />
          </Suspense>
        </div>
      </main>
    </div>
  )
}

▶ サンプル: ビルド成果物の検証 (難易度: ⭐)

BASH
# ビルド後の静的シェル出力を確認
npm run build

# .next/server/app/ppr-ecommerce にスクロールして確認
ls .next/server/app/ppr-ecommerce/
💻 出力:

TEXT 📖 参照専用
page.html          ← 静的シェル HTML (ナビゲーションバー、レイアウト)
page.rsc           ← RSC Payload (静的セクション)
page_stream.html   ← ストリーミング部分注入ポイント

▶ サンプル: PPR 静的シェル + 動的データ検証 (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
上記の説明通りにページをレンダリングし、説明の動作に基づいて UI が更新されます。
TSX
// app/ppr-verify/page.tsx
import { Suspense } from 'react'

function StaticHeader() {
  return (
    <header style={{ borderBottom: '2px solid #333', padding: 16, marginBottom: 16 }}>
      <h1>PPR Verification Page</h1>
      <p>ビルドタイムスタンプ: {new Date().toISOString()}</p>
      <nav><a href="/">Home</a> | <a href="/ppr-verify">Refresh</a></nav>
    </header>
  )
}

async function DynamicContent() {
  const res = await fetch('http://worldtimeapi.org/api/timezone/Etc/UTC', {
    cache: 'no-store'
  }).then(r => r.json())

  return (
    <div style={{ background: '#e3f2fd', padding: 16, borderRadius: 8 }}>
      <h2>ライブサーバー時刻</h2>
      <p style={{ fontSize: 24 }}>{res.datetime}</p>
    </div>
  )
}

export default function PprVerifyPage() {
  return (
    <div>
      <StaticHeader />
      <Suspense fallback={<div style={{ padding: 16 }}>⏳ ライブ時刻を読み込み中...</div>}>
        <DynamicContent />
      </Suspense>
      <footer style={{ marginTop: 32, color: '#666' }}>
        <p>ヘッダーは静的シェル (キャッシュ)。動的コンテンツはリクエストごとに更新されます。</p>
      </footer>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
データをサーバーサイドで取得し、ページに結果をレンダリングします。
表示内容: PPR Verification Page

❓ よくある質問

Q PPR とストリーミング SSR の違いは何ですか?
A ストリーミング SSR では、レンダリングはリクエスト時のみ行われ、各 Suspense 境界は「動的」です。PPR の静的シェルはビルド時に生成されます — ユーザーには CDN にキャッシュされた HTML が表示され、サーバーでのリアルタイムレンダリングではありません。PPR = 静的シェル + ストリーミング動的領域です。
Q PPR を有効にするとすべてのページが PPR に切り替わりますか?
A はい、デフォルトでは dynamic = 'force-dynamic' のないすべてのルートが PPR を使用します。ただし、「静的シェル + 動的領域」の効果が得られるのは <Suspense> 境界を含むページのみです。Suspense のないページは純粋に静的のままです (SSG 動作)。
Q PPR の動的領域はネストできますか?
A はい。Suspense 境界はネストできます — 内側の Suspense は外側のものに依存します。ただし、フラットな構造を維持することを推奨します (1ページあたり 2〜5 の Suspense 境界)。過剰なネストは複雑な読み込み順序につながる可能性があります。
Q PPR 静的シェルのデータはどのように更新されますか?
A 静的シェルのデータはビルド時に固定されます。レイアウトにユーザー名をレンダリングすると、ビルド時のスナップショットになります。解決策: ユーザー固有のデータを動的 Suspense 境界内に配置し、静的シェルは常に変わらないコンテンツ (ロゴ、ナビゲーションリンク、レイアウト構造) 用に予約します。
Q PPR は他のレンダリングモードと共存できますか?
A はい。同じアプリケーション内で、PPR ページ (ダッシュボード)、SSG ページ (マーケティング)、ISR ページ (ブログ)、SSR ページ (管理パネル) を持つことができます。レンダリングモードはルートレベルで設定され、各ルートセグメントは独立して設定されます。
Q PPR は開発モードでどのように動作しますか?
A PPR は開発モードでも動作しますが、各リクエストはゼロから再ビルドされるため、静的シェルの利点 (CDN キャッシュ) はローカルでは感じられません。PPR のパフォーマンス向上は本番ビルド後にのみ実感できます。npm run build && npm run start を実行して本番モードをテストできます。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐): next.config.ts で PPR を有効にし、静的 <header> セクションと動的 <Suspense> セクション (API を呼び出して現在時刻を取得) を含む app/ppr-basic/page.tsx を作成します。ビルド出力に静的 HTML が含まれていることを確認してください。

  2. 応用問題 (⭐⭐): 少なくとも3つの Suspense 境界 (ユーザー情報、通知一覧、リアルタイム統計) を含む app/ppr-dashboard/page.tsx を作成します。各境界には個別の fallback があります。ナビゲーションバーとサイドバーが静的シェルであることを確認します。ビルド後、page.html にナビゲーションバーが含まれているが動的コンテンツは含まれていないことを確認してください。

  3. 発展問題 (⭐⭐⭐): PPR マルチページダッシュボード app/ppr-portal/ を構築します — 3つのサブページ: 概要、注文、分析。これらのページは静的レイアウト (上部ナビゲーション + サイドバー) を共有し、各ページには 2〜4 の動的 Suspense 境界が含まれます。ユーザーが動的領域内でデータを送信し、revalidateTag() をトリガーして対応する動的領域を更新する Server Actions を追加します。

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%