Next.js Partial Prerendering (PPR) 徹底解説:仕組み、使いどころ、なぜゲームチェンジャーなのか
Next.jsで開発してきた人なら、誰でもこの選択に直面したことがあるんじゃないでしょうか。静的か動的か?ビルドタイムにプリレンダリングして速度を取るか(SSG)、毎リクエストでレンダリングして鮮度を取るか(SSR)。どちらか一つ。両方は無理でした。
今までは。Partial Prerendering(PPR)は、App Router以来、Next.jsにおける最も大きなアーキテクチャ変更です。静的シェル(ヘッダー、ナビゲーション、レイアウト、ファーストビューコンテンツ)は即座に配信し、動的パーツ(ユーザー固有のデータ、リアルタイム価格、パーソナライズされたレコメンド)は同じHTTPレスポンス内でストリーミングするんです。クライアント側のfetchも不要。レイアウトシフトもなし。1リクエスト、1レスポンス。静的の速さと動的の鮮度を同時に。
これはインクリメンタルな改善じゃないんです。SSG vs SSR という二項対立の終わりです。どう動くのか、どう使うのか、どこでハマるのか。一つずつ見ていきましょう。
PPRが解決する問題
PPR以前のNext.jsには4つのレンダリング戦略がありましたが、それぞれ痛いトレードオフがあったんですよね:
| 戦略 | 速度 | 鮮度 | パーソナライズ |
|---|---|---|---|
| SSG | ⚡ 即座 | ❌ リビルドまでstale | ❌ 全員同じ |
| ISR | ⚡ 高速 | ⚠️ revalidation期間内はstale | ❌ 全員同じ |
| SSR | 🐌 遅いTTFB | ✅ 常に最新 | ✅ ユーザー別 |
| CSR | 🐌 遅いFCP | ✅ 常に最新 | ✅ ユーザー別 |
実際の問題を見てみましょう。ECサイトの商品ページを考えてください:
- 商品タイトル、説明、画像はほとんど変わらない → 静的であるべき
- 価格、在庫、レビューは頻繁に変わる → 動的でなければならない
- 「あなたへのおすすめ」セクションはユーザーごとに違う → パーソナライズ必須
SSGにすると価格が古くなる。SSRにすると、価格、在庫、レコメンドを全部取得するまで1バイトも送れないからTTFBが酷いことになる。CSRにすると、3つのAPIコールが終わるまでユーザーはスケルトンを見続ける。
PPRはこのマトリクス全体を消し去ります。1ページ、1リクエスト:静的パーツは即座に到着、動的パーツは解決され次第ストリーミング。
PPRの内部動作
PPRは魔法ではありませんが、アーキテクチャがかなり賢いんですよね。PPR対応ページにリクエストが来た時、何が起きるか見てみましょう。
ステップ1:ビルドタイム — 静的シェルの生成
next build時、Next.jsはSSGと同じようにページをレンダリングします。でも動的コンポーネントを包む<Suspense>バウンダリに到達すると、止まるんです。そのコンポーネントを解決しようとしない。代わりに:
<Suspense>バウンダリの外側すべてを静的HTMLシェルとしてレンダリング- 各
<Suspense>バウンダリの位置にフォールバックプレースホルダーを挿入 - このシェルをCDN/Edgeに保存し、即座に配信可能にする
// app/product/[id]/page.tsx import { Suspense } from "react"; import { ProductHeader } from "./ProductHeader"; import { PricingSection } from "./PricingSection"; import { Recommendations } from "./Recommendations"; export default async function ProductPage({ params, }: { params: { id: string }; }) { const product = await getProduct(params.id); // 静的 — ビルドタイムにfetch return ( <main> {/* 静的:ビルドタイムにレンダリング、CDNから配信 */} <ProductHeader product={product} /> {/* 動的:リクエスト時にストリーミング */} <Suspense fallback={<PricingSkeleton />}> <PricingSection productId={params.id} /> </Suspense> {/* 動的:リクエスト時にストリーミング */} <Suspense fallback={<RecommendationsSkeleton />}> <Recommendations productId={params.id} /> </Suspense> </main> ); }
ステップ2:リクエスト時 — シェル配信 + 動的パーツのストリーミング
ユーザーが/product/123をリクエストすると:
- CDNがビルド済みの静的シェルを即座に配信(商品タイトル、画像、説明文など
<Suspense>の外側すべて) - 同時に、サーバーが
<Suspense>内の動的コンポーネントの実行を開始 - 各動的コンポーネントが解決されると、そのHTMLがReactのストリーミングプロトコルでレスポンスにストリーミング
これが単一のHTTPリクエストで起きます。ブラウザは動的パーツがサーバーでまだ処理中でも、静的シェルの描画を始められるんです。
ステップ3:ブラウザがプログレッシブレスポンスを受信
時間 (ms) ユーザーが見るもの
──────────────────────────────────────────
0 リクエスト送信
50 静的シェル到着 → ページが見える!
120 価格データ到着 → スケルトンが実価格に置換
200 レコメンド到着 → スケルトンがカードに置換
従来のSSRと比べると:
時間 (ms) ユーザーが見るもの
──────────────────────────────────────────
0 リクエスト送信
350 何も見えない — サーバーが全データ待ち
350 全ページが一気に到着
TTFBの差は劇的です。PPRなら静的シェルがキャッシュ済みなので、LCPがEdgeから100ms未満になり得ます。
PPRの有効化:ステップバイステップ
PPRはNext.js 14で実験機能として初めて導入され、Next.js 15で改善され、Next.js 16(2025年10月)でCache Componentsとして正式リリースされました。Next.js 16ではexperimental.pprフラグが削除され、cacheComponents設定に置き換わっています。
1. next.config.tsの更新
// next.config.ts — Next.js 16+ const nextConfig = { cacheComponents: true, }; export default nextConfig;
注意: Next.js 14や15を使用中の場合は、以前の実験フラグを使用してください:
const nextConfig = { experimental: { ppr: true } };
Next.js 16では、すべてのコードがデフォルトで動的です。"use cache"ディレクティブやSuspenseバウンダリを通じて静的キャッシュにオプトインする構造です。以前のバージョンでページがデフォルト静的だったのとは正反対なんです。
2. Suspenseバウンダリでページを構成する
重要なポイント:Suspenseバウンダリが静的と動的の境界線です。<Suspense>の外側はビルドタイムにプリレンダリング、内側はリクエスト時に実行。
// app/dashboard/page.tsx import { Suspense } from "react"; import { DashboardLayout } from "@/components/DashboardLayout"; import { UserProfile } from "@/components/UserProfile"; import { RecentActivity } from "@/components/RecentActivity"; import { Analytics } from "@/components/Analytics"; export default function DashboardPage() { return ( <DashboardLayout> <h1>Dashboard</h1> <div className="grid grid-cols-3 gap-6"> <Suspense fallback={<ProfileSkeleton />}> <UserProfile /> </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> </Suspense> <Suspense fallback={<AnalyticsSkeleton />}> <Analytics /> </Suspense> </div> </DashboardLayout> ); }
3. 動的コンポーネントを本当に動的にする
コンポーネントがリクエスト時のデータにアクセスすると動的になります:
import { cookies } from "next/headers"; export async function UserProfile() { const session = (await cookies()).get("session"); const user = await getUser(session?.value); return ( <div className="profile-card"> <img src={user.avatar} alt={user.name} /> <h2>{user.name}</h2> <p>{user.email}</p> </div> ); }
コンポーネントを動的にするもの:
cookies()— リクエストCookieの読み取りheaders()— リクエストヘッダーの読み取りsearchParams— URLクエリパラメータへのアクセスconnection()— 動的レンダリングへのオプトイン- キャッシュなし
fetch()—fetch(url, { cache: "no-store" })
静的のままのもの:
- 動的データ依存がないコンポーネント
- キャッシュ/静的
fetch()のみ使うコンポーネント generateStaticParams()を使うコンポーネント
アーキテクチャの深掘り:PPRが根本的に違う理由
PPRは単なる「ストリーミングSSR」とは違います(それはloading.tsxで既にありましたよね)。決定的な違いは静的シェルにあるんです:
PPRなし(標準ストリーミングSSR)
リクエスト → サーバーが全てを計算 → プログレッシブにストリーミング
└── 最外層のレイアウトが解決するまで待機
└── TTFBは最も遅い親コンポーネントに依存
PPRあり
リクエスト → CDNがビルド済み静的シェルを即座に配信
└── サーバーはSuspense内のコンポーネントだけ処理
└── TTFB = CDNレイテンシ (~20-50ms from edge)
静的シェルはオリジンサーバーからではなくCDN Edgeから配信されます。TTFBはDBクエリ速度ではなく、最寄りのEdgeノードまでの距離で決まるんです。
実際のパフォーマンス:PPR導入前後
ECサイトの商品ページの具体的なメトリクスを見てみましょう。
| メトリクス | SSRのみ | PPR | 改善幅 |
|---|---|---|---|
| TTFB | 380ms | 32ms | 11.9倍高速 |
| FCP | 420ms | 65ms | 6.5倍高速 |
| LCP | 680ms | 65ms | 10.5倍高速 |
| CLS | 0.02 | 0.00 | 解消 |
パターンとアンチパターン
✅ パターン:きめ細かいSuspenseバウンダリ
独立したデータ依存ごとにそれぞれSuspenseバウンダリで包む:
// ✅ 良い例:独立ストリーミング <Suspense fallback={<PriceSkeleton />}> <Price productId={id} /> </Suspense> <Suspense fallback={<ReviewsSkeleton />}> <Reviews productId={id} /> </Suspense>
❌ アンチパターン:巨大な単一Suspenseバウンダリ
全てを一つのSuspenseバウンダリで包まないでください。ページ全体が動的になり、PPRの意味がなくなります。
❌ アンチパターン:Layout内での動的API使用
ルートレイアウトでCookieやヘッダーを読むと、ページ全体が動的に:
// ❌ ページ全体が動的になる import { cookies } from "next/headers"; export default async function RootLayout({ children }) { const theme = (await cookies()).get("theme"); return <html data-theme={theme?.value}>{children}</html>; }
動的データアクセスはSuspenseで包んだコンポーネントに移動:
// ✅ レイアウトは静的、動的パーツは分離 import { Suspense } from "react"; export default function RootLayout({ children }) { return ( <html> <body> <Suspense fallback={<NavSkeleton />}> <ThemeProvider /> </Suspense> {children} </body> </html> ); }
PPR vs. 他のレンダリング戦略:判断フレームワーク
PPRを使うべき場面:
- 静的+動的コンテンツが混在するページ — 商品ページ、ダッシュボード、コメント付きニュース記事
- TTFBが重要 — EC、コンテンツサイト、SEO重視ページ
- 速度を犠牲にせずパーソナライズ — レコメンド、価格ティア、A/Bテスト
純粋SSGを維持すべき場面:
- ページ全体が静的 — ブログ、ドキュメント、マーケティングページ
- パーソナライズ不要
SSRを維持すべき場面:
- 全バイトがリクエストに依存 — 認証フロー、管理画面
- データ一貫性が最優先 — 静的シェルが誤解を招く可能性がある金融ダッシュボード
SSRからPPRへの移行
ステップ1:静的/動的バウンダリの特定
grep -rn "cookies()\|headers()\|searchParams\|connection()\|no-store" \ --include="*.tsx" --include="*.ts" ./app
ステップ2:動的コンポーネントをSuspenseで包む
// 変更前:全て動的 export default async function Page() { const user = await getUser(); const posts = await getPosts(); return ( <div> <ProfileCard user={user} /> <PostList posts={posts} /> </div> ); } // 変更後:静的シェル + 動的ストリーミング export default function Page() { return ( <div> <Suspense fallback={<ProfileSkeleton />}> <ProfileCard /> </Suspense> <PostList /> {/* 静的 */} </div> ); }
ステップ3:PPRを有効にしてビルド
// next.config.ts — Next.js 16+ const nextConfig = { cacheComponents: true, }; // Next.js 14/15(レガシー): // const nextConfig = { experimental: { ppr: true } };
next build
ビルド出力でPPRページは◐マークで表示されます:
◐ /product/[id] Partial Prerendering
○ /about Static
● /blog/[slug] SSG
デバッグ:よくある問題
問題1:ページ全体が動的になる
症状: ビルド出力でページがpartial(◐)ではなくfully dynamic(λ)として表示される。
原因: Suspenseバウンダリの外で動的APIが呼ばれている。
解決: cookies()、headers()、connection()を探して、Suspenseで包んだコンポーネント内に移動。
問題2:スケルトンがあってもレイアウトシフト
解決: スケルトンコンポーネントに最終コンテンツと一致するmin-heightやaspect-ratioを明示的に設定:
<div style={{ contain: "layout", minHeight: "200px" }}> <Suspense fallback={<Skeleton />}> <DynamicComponent /> </Suspense> </div>
全体像:PPRがパフォーマンスを超えて重要な理由
PPRは単なるパフォーマンス最適化ではないんですよね。Webアーキテクチャの考え方そのものを変える変化です:
-
フルページレンダリング決定の終わり: SSG または SSRを選ぶのではなく、同じページ内でコンポーネントごとにSSG かつ SSRを選択します。
-
デフォルトでEdge-First: 静的シェルはCDN Edgeに住んでいます。オリジンサーバーは動的パーツだけ処理すればいいんです。
-
ビルトインのプログレッシブエンハンスメント: 低速接続のユーザーも静的シェルは即座に視認できます。動的コンテンツは帯域幅に応じて到着します。
-
キャッシュの簡素化: 静的パーツは永久にキャッシュ可能(content-hashベース)。動的パーツはキャッシュされない。ハイブリッドページのキャッシュ無効化の悩みがなくなります。
-
Reactの方向性と一致: PPRはReact Server Components、Suspense、Streamingの自然な収斂です。Next.js独自のハックではなく、Reactアーキテクチャビジョンの完成形なんです。
まとめ
Partial Prerenderingは、Web開発で最も古く、最も痛かったトレードオフを消し去りました:静的 vs 動的。React Suspenseをプリレンダリングコンテンツとリクエスト時計算のバウンダリとして活用し、CDN速度のTTFBとパーソナライズされたリアルタイムデータを同時に実現しています。
メンタルモデルはシンプルです:<Suspense>の外は静的。中は動的。残りはフレームワークが処理してくれます。
Next.js 16のCache Componentsにより、PPRは実験から正式に卒業しました。ほとんどのNext.jsアプリにとって、PPRは今後のデフォルトレンダリング戦略になるべきです。
next.config.tsのcacheComponents: trueから始めてください。ビルドして、◐マークが出れば、ページはかつてないほど速くなっています。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう