Next.js: 部分プリレンダリング (PPR)
最終更新:2026-08-26
PPR は Next.js 16 で最も画期的なレンダリングモードです — 1つのページで、静的シェルの超高速応答性と動的セクションのリアルタイムコンテンツを組み合わせることができます。
1. 学習目標
- PPR の概念: 1ページ = 静的シェル + 動的ストリーミングコンテンツ
- PPR の設定方法
- Suspense: 動的境界の中核原則
- PPR、ISR、SSR のパフォーマンス比較
- ダッシュボードシナリオ向けの実践的な PPR 設計
2. あるアーキテクトの実話
(1) 課題: ダッシュボードは完全に静的 (古いデータ) か、完全に動的 (遅い) のどちらか
Diana は TaskFlow チームのアーキテクトです。会社の SaaS ダッシュボードはジレンマに直面しています:
- SSG アプローチ: ページ全体がビルド時に生成されます — ナビゲーションバー、サイドバー、ユーザー情報がすべて古くなります。「おかえりなさい、Alice!」には3日前のデータが表示されます。
- SSR アプローチ: リクエストごとに新しくレンダリングします — データベースクエリと API 呼び出しが順次実行されるため、ページの読み込みに4秒かかります。
彼女が望むのは、ナビゲーションバー/サイドバー/レイアウトは静的 (ビルド時に生成し CDN でキャッシュ)、ユーザーデータ/通知は動的 (リアルタイム取得) という状態です。しかし、従来の SSG または SSR ではどちらか一方しか選べません — 1つのページに1つのレンダリングモードしか適用できないのです。
(2) PPR による解決策
PPR を使用して、ダッシュボードを静的シェル (レイアウト + ナビゲーション) と動的領域 (Suspense 境界) に分割します。
// 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) 静的シェル + 動的境界
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 で有効にする必要があります:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: true // PPR を有効化
}
}
export default nextConfig
# 設定後、開発サーバーを起動
npm run dev
dynamic = 'force-dynamic' や cache: 'no-store' を含まないすべてのページが、デフォルトで PPR 最適化の恩恵を自動的に受けます。ビルド時に次のログが表示されます: ✓ PPR enabled for /dashboard
▶ サンプル: PPR と非 PPR の比較 (難易度: ⭐)
React コンポーネントがブラウザで説明された UI をレンダリングします。
// 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>
}
レンダリング: 静的サイドバー + Suspense 境界内の動的ユーザープロフィールとプロジェクト統計を持つダッシュボード。
4. Suspense: 境界としての動的境界
PPR の核心原則: すべての <Suspense> でラップされたコンテンツは動的にレンダリングされる境界です。Suspense でラップされていないセクションは、ビルド時に事前レンダリングされる静的シェルです。
(1) 境界ルール
graph LR
A[ページコンポーネント] --> B[静的コンテンツ<br/>Suspense なし]
A --> C[Suspense 境界]
C --> D[動的子コンポーネント<br/>リクエストごとに再レンダリング]
A --> E[別の Suspense]
E --> F[個別の動的ゾーン]
| ラップ状態 | PPR の動作 | 例 |
|---|---|---|
✅ <Suspense> でラップ |
動的 — リクエスト時にレンダリング、リアルタイムコンテンツ | ユーザー情報、在庫データ |
❌ <Suspense> なし |
静的 — ビルド時にレンダリング、CDN でキャッシュ | ナビゲーションバー、フッター、ロゴ |
(2) 不要な Suspense を避ける
コンポーネントがリアルタイムデータを必要としない場合は、Suspense でラップしないでください — そうすることで静的シェルの一部になります。
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<div>
{/* ✅ 静的: サイドバーは常に同じ、リアルタイム更新不要 */}
<Sidebar />
{/* ✅ 動的: リアルタイム取得が必要 */}
<Suspense fallback={<LoadingSpinner />}>
<RealtimeData />
</Suspense>
{/* ❌ 不要な Suspense: このコンポーネントには動的データがない */}
<Suspense fallback={<LoadingSpinner />}>
<StaticAboutSection /> {/* ラップ不要 */}
</Suspense>
</div>
)
}
▶ サンプル: 複数の Suspense 境界の読み込み順序 (難易度: ⭐⭐)
静的シェルを即座にレンダリングし、Suspense 境界内で動的コンテンツを読み込みます。
Fallback: }>
表示テキスト: }> | }>
// 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>
}
静的シェルを即座にレンダリングし、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) 選択推奨
graph TB
A[このページには何が必要?] --> B{リアルタイムデータ?}
B -->|全ページリアルタイム| C[SSR]
B -->|一部の領域がリアルタイム| D[PPR]
B -->|リアルタイムデータなし| E{更新頻度?}
E -->|頻繁| F[ISR]
E -->|ほぼ不変| G[SSG]
style D fill:#d4edda
▶ サンプル: PPR ダッシュボードコンポーネント設計 (難易度: ⭐⭐⭐)
レンダリング戦略の図: 静的シェル + 動的 Suspense 境界。
// 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>
)
}
データをサーバーサイドで取得し、ページに結果をレンダリングします。
表示内容: TaskFlow Dashboard
6. 完全な例: PPR e コマースダッシュボード
// 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>
)
}
▶ サンプル: ビルド成果物の検証 (難易度: ⭐)
# ビルド後の静的シェル出力を確認
npm run build
# .next/server/app/ppr-ecommerce にスクロールして確認
ls .next/server/app/ppr-ecommerce/
page.html ← 静的シェル HTML (ナビゲーションバー、レイアウト)
page.rsc ← RSC Payload (静的セクション)
page_stream.html ← ストリーミング部分注入ポイント
▶ サンプル: PPR 静的シェル + 動的データ検証 (難易度: ⭐⭐)
上記の説明通りにページをレンダリングし、説明の動作に基づいて UI が更新されます。
// 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>
)
}
データをサーバーサイドで取得し、ページに結果をレンダリングします。
表示内容: PPR Verification Page
❓ よくある質問
dynamic = 'force-dynamic' のないすべてのルートが PPR を使用します。ただし、「静的シェル + 動的領域」の効果が得られるのは <Suspense> 境界を含むページのみです。Suspense のないページは純粋に静的のままです (SSG 動作)。npm run build && npm run start を実行して本番モードをテストできます。📖 まとめ
- PPR はページを静的シェル (ビルド時に生成) と動的境界 (リクエスト時にレンダリング) に分割します
<Suspense>境界は動的境界であり、Suspense のないコンテンツは静的シェルですnext.config.tsでexperimental.ppr = trueを設定して PPR を有効にします- PPR は SSR より 10〜100 倍高速に初回画面を読み込みます (静的シェルは CDN キャッシュ経由)。動的セクションはリアルタイムのままです
- 静的シェルに適したもの: ナビゲーションバー、サイドバー、フッター、ロゴ、静的テキスト
- 動的境界に適したもの: ユーザー情報、通知、リアルタイムチャート、検索、パーソナライズドコンテンツ
- PPR は SSG と SSR の間の最良の妥協点であり、大部分の混合コンテンツページに適しています
📝 練習問題
-
基本問題 (⭐):
next.config.tsで PPR を有効にし、静的<header>セクションと動的<Suspense>セクション (API を呼び出して現在時刻を取得) を含むapp/ppr-basic/page.tsxを作成します。ビルド出力に静的 HTML が含まれていることを確認してください。 -
応用問題 (⭐⭐): 少なくとも3つの Suspense 境界 (ユーザー情報、通知一覧、リアルタイム統計) を含む
app/ppr-dashboard/page.tsxを作成します。各境界には個別の fallback があります。ナビゲーションバーとサイドバーが静的シェルであることを確認します。ビルド後、page.htmlにナビゲーションバーが含まれているが動的コンテンツは含まれていないことを確認してください。 -
発展問題 (⭐⭐⭐): PPR マルチページダッシュボード
app/ppr-portal/を構築します — 3つのサブページ: 概要、注文、分析。これらのページは静的レイアウト (上部ナビゲーション + サイドバー) を共有し、各ページには 2〜4 の動的 Suspense 境界が含まれます。ユーザーが動的領域内でデータを送信し、revalidateTag()をトリガーして対応する動的領域を更新する Server Actions を追加します。