Next.js v15系でのPPR動作を検証してみた

Next.js v15系でのPPR動作を検証してみた

2025.12.07

はじめに

みなさんこんにちは。クラウド事業本部コンサルティング部の浅野です。

2025年12月現在、Next.js の最新メジャーバージョンは 16 系です。
15 → 16 へのアップデートでは、キャッシュ動作まわりの仕様が大きく変更されており、キャッシュ戦略を厳密に設計しているプロジェクトでは単純なバージョンアップでは済まず、設定ファイルやアプリケーションコードの見直しが必要となります。

特に、15 系まで"実験的機能"として扱われていた PPR(Partial Pre-Rendering) は、16 系では正式に「Cache Component」として再設計・統合されました。

これによりキャッシュ管理の考え方自体が整理された一方で、古い PPR の挙動を前提に構築していたアプリケーションでは影響範囲が少なくありません。

そこで本記事では、Next.js 16 系の「Cache Component」を理解する前に、まず大元となっている「PPR」が実際にどう動いていたのかを改めて詳細に検証していきます。

PPR がどんなタイミングでレンダリングされ、どのようにキャッシュが保持・更新されていたのかを理解することは、16 系での Cache Component を使いこなすうえでも非常に重要です。

Next.js AppRouterのレンダリング方式

PPRの理解の前提には元々App Routerのサーバーコンポーネントに関して用意されていた2種類のレンダリング方式とStreaming SSRの理解が必要です。

そもそも、App Routerでは以下の2つのRendering方式が用意されておりそれぞれ特徴がありました。

  • Static Rendering(静的レンダリング)
  • Dynamic Rendering(動的レンダリング)

Static Rendering(静的レンダリング)

Static Rendering は、ビルド時(または ISR の再生成時)に HTML と RSC(React Server Components) をあらかじめレンダリングしておく方式です。Pages RouterではSSGやISRという呼称がこれに当たります。

特徴

  • HTML/RSC がビルド時に生成される
  • リクエスト時にはサーバーは データフェッチを一切行わず、キャッシュ済みの成果物を返すだけ
  • パフォーマンスが高く、CDN キャッシュと相性が良い
  • fetch() がレンダリング中に dynamic: force-cache を要求すると Static Rendering が選択される

主なユースケース

  • プロフィールページやお問い合わせ、よくある質問ページなど「更新頻度が低く事前生成できるページ」
  • API レスポンスがビルド時に確定しており、リアルタイムで変更が少ないページ(ISR用途)

実際のファイルと画面のイメージ

「dummyjson」というあらかじめ用意されたエンドポイントを叩くと様々なダミーJSONが返却されるサービスを使って「ランダムなTodo文章」を取得するだけの簡単なページとコンポーネントを用意しました。

https://dummyjson.com/

app/static-rendering/page.tsx
import StaticServerComponent from '@/components/StaticServerComponent'
import React from 'react'

export default function page() {
  return (
    <div className='flex flex-col gap-6'>
        <StaticServerComponent/>
    </div>
  )
}

fetch()のオプションとして{cache: "force-cache"}を付与しており、明示的にStatic Renderingにしています。

components/StaticServerComponent.tsx
import React from "react";

type Todo = {
  id: string;
  todo: string;
};

const StaticServerComponent = async () => {
  const res = await fetch("https://dummyjson.com/todos/random", {
    cache: "force-cache",
  });
  const todo: Todo = await res.json();
  return (
    <div>
      <p className="font-bold">Static Server Component</p>
      <dt>ID</dt>
      <dd>{todo.id}</dd>
      <dt>Todo</dt>
      <dd>{todo.todo}</dd>
    </div>
  );
};

export default StaticServerComponent;

実際の画面

このように、本来ランダムなTodoが返ってくるエンドポイントなのにビルド時にHTML化しているので「更新ボタン」を押しても情報は変わりません。配信速度は早いですが、このように動的コンテンツを読み込むページとしては内容がキャッシュされて変わらないので向いていません。

2025-12-07-nextjs15-ppr-rsc-01

Dynamic Rendering(動的レンダリング)

Dynamic Rendering は、リクエストごとにサーバーで HTML と RSC(React Server Components)を生成する方式です。Pages RouterではSSRという概念がこれに当たります。

特徴

  • リクエストのたびに RSC の生成とデータフェッチ が行われる
  • キャッシュが効きにくく、静的レンダリングよりパフォーマンスは低い
  • 以下の条件があると自動的にそのページはDynamic Renderingとして判定される
    • Cookie / Header などの動的関数の使用
    • { cache: "no-store"} fetchオプションが含まれる
    • キャッシュ不可の URL パラメータがある
    • Dynamic RenderingとStatic Renderingの要素が混在しているページ(PPR無効時)

主なユースケース

  • ブログの詳細ページやECサイトの在庫状況などリアルタイムで情報変更が必要なページ
  • 毎回状態が変わる動的値を参照するページ

実際のファイルと画面のイメージ

Static Renderingとの違いはfetch()のオプションとして{cache: "no-store"}を付与しており、明示的にDynamic Renderingにしているところです。ちなみに「Next.js v15系」ではデフォルトでキャッシュされない、つまり{cache: "no-store"}状態ですが今回はわかりやすいように明記しています。

app/dynamic-rendering/page.tsx
import DynamicServerComponent from '@/components/DynamicServerComponent'
import React from 'react'

export default function page() {
  return (
    <div className='flex flex-col gap-6'>
        <DynamicServerComponent/>
    </div>
  )
}
components/DynamicServerComponent.tsx
import React from "react";

type Todo = {
  id: number;
  todo: string;
};

const DynamicServerComponent = async () => {
  const res = await fetch("https://dummyjson.com/todos/random", {
    cache: "no-store",
  });
  const todo: Todo = await res.json();

  return (
    <div>
      <p className="font-bold">Dynamic Server Component</p>
      <dt>ID</dt>
      <dd>{todo.id}</dd>
      <dt>Todo</dt>
      <dd>{todo.todo}</dd>
    </div>
  );
};

export default DynamicServerComponent;


実際の画面

リクエスト毎にHTMLが裏側で生成されているので、「更新ボタン」を押すたびにランダムなTodoが表示されています。変更頻度が高い動的コンテンツを扱う場合はこのレンダリング方式が向いています。

2025-12-07-nextjs15-ppr-rsc-02

比較

個々の説明だけではイメージしにくいので、以下に、それぞれの仕組みと HTML と RSC(React Server Components)がいつ・どこで生成されるのか を整理した図を配置します。

2025-12-07-nextjs15-ppr-rsc-03

この図と合わせて抑えておきたいポイントは以下です。

  • RSCの取り扱い方
    Static Renderingの場合「RSC Payload」はサーバーディスク上にビルド時に生成されファイルとして残りますが、Dynamic Renderingの場合はリクエスト毎に都度インメモリで一時生成され、HTMLファイルと共にクライアント側にレスポンスされた後にサーバー上には残らずに破棄されます。

  • 基本的にはStatic Renderingに寄せるという考え方がある
    サーバーの負荷や高速配信、CDNとの互換性から基本的に全てのページとコンポーネントはできるだけ「Static Rendering」に寄せた方が良いです。

  • 混在している時はDynamic Renderingが優先
    PPRという概念が登場する前までは一つのページでStaticな要素とDynamicの要素が混在している場合は、そのページ全体がDynamic Renderingとして判定されていました。

ちなみにページ(ルート)単位でStatic / Dynamic どちらのRenderingとして解釈されているかはビルド時に以下のように表示されます。

pnpm build 
.
.
.
Route (app)                              Size     First Load JS
┌ ○ /                                    5.46 kB         105 kB
├ ○ /_not-found                          894 B           101 kB
├ ƒ /dynamic-rendering                   138 B           100 kB
└ ○ /static-rendering                    138 B           100 kB
+ First Load JS shared by all            99.9 kB
  ├ chunks/602bc678-eecccf42000cb22e.js  52.5 kB
  └ other shared chunks (total)          1.88 kB


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

Streaming SSR(ストリーミング SSR)

Static / Dynamic どちらの場合でも、App Router では内部的に「Streaming SSR」 が利用されます。

Streaming SSR は、RSC と HTML を“分割しながら”クライアントへストリーミング送信する仕組みです。

Streaming SSR のポイント

  • React によって RSC ツリーが複数の"チャンク"として順次生成される
  • 生成できた部分からすぐブラウザに送られるため TTFB が短くなる
  • クライアントは RSC プロトコルでサーバーからのデータを復元し、インタラクティブ部分のみハイドレーションを行います

これも説明だけではイメージしづらいので、実際のファイルと画面を示します。

Streamingの機能を使用したい場合は <Suspense> で囲ってあげる必要があります。
fallback propsとして、コンテンツの読み込み中に表示したいコンポーネントを定義することができます。

以下のように記載すると <StreamingStatic /> 部分は先ほどの「Static Rendering」になるように設定しておりビルド時にHTML化されているので、ページを更新しても即座に表示されます。

しかし <StreamingDynamic /> 部分は「Dynamic Rendering」として設定しており、リクエスト毎にサーバー側で処理が走ります。例では軽いコンテンツなので読み込みに時間がかかりませんが、膨大なデータを取得する処理では時間がかかるので、以下のように <Suspense> で囲うことでローディング中のUIを見せる記述が必要です。

<Suspense> 記述部分の説明として、内部の fetch() が終わりデータ取得処理が完了するまでは「Loading・・・」という表示がなされ、処理が終わると <StreamingDynamic /> 内部のコンテンツが表示されるという仕組みです。

app/streaming-ssr/page.tsx
import React, { Suspense } from "react";
import StreamingStatic from "@/components/StreamingStatic";
import StreamingDynamic from "@/components/StreamingDynamic";

export default function StreamingSSRPage() {
  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold mb-6">Streaming SSR</h1>

      <div className="space-y-6">
        <section>
          <StreamingStatic />
        </section>

        <section>
          <Suspense fallback={<p>Loading...</p>}>
            <StreamingDynamic />
          </Suspense>
        </section>
      </div>
    </main>
  );
}

components/StreamingStatic.tsx
import React from "react";

type Todo = {
  id: number;
  todo: string;
};

const StreamingStatic = async () => {
  const res = await fetch("https://dummyjson.com/todos/random", {
    cache: "force-cache",
  });
  const todo: Todo = await res.json();

  return (
    <div>
      <p className="font-bold">Streaming Static</p>
      <dt>ID</dt>
      <dd>{todo.id}</dd>
      <dt>Todo</dt>
      <dd>{todo.todo}</dd>
    </div>
  );
};

export default StreamingStatic;


components/StreamingDynamic.tsx
import React from "react";

type Todo = {
  id: number;
  todo: string;
};

const StreamingDynamic = async () => {
  const res = await fetch("https://dummyjson.com/todos/random", {
    cache: "no-store",
  });
  const todo: Todo = await res.json();

  return (
    <div>
      <p className="font-bold">Streaming Dynamic</p>
      <dt>ID</dt>
      <dd>{todo.id}</dd>
      <dt>Todo</dt>
      <dd>{todo.todo}</dd>
    </div>
  );
};

export default StreamingDynamic;

実際の画面

2025-12-07-nextjs15-ppr-rsc-04

このように上のStatic部分は即座に表示されますが、Dynamic部分はデータローディング中は代わりのコンポーネントが表示され、取得が終わったタイミングでレンダリングされています。これがStreaming SSRの機能と仕組みです。

誤解を生むポイントとして「Streaming SSR = Dynamic Renderingに関わる話」ではないということです。Static Renderingに関しても <Suspense> の使用が可能です。

ではこのページのビルド時の判定はどうなっているのでしょうか。

以下のように「/streaming-ssr」ページは「Dynamic Rendering」の要素が1つ以上存在するので「Dynamic Rendering」として判定されています!

pnpm build 
.
.
.
Route (app)                              Size     First Load JS
┌ ○ /                                    5.46 kB         105 kB
├ ○ /_not-found                          894 B           101 kB
├ ƒ /dynamic-rendering                   141 B           100 kB
├ ○ /static-rendering                    141 B           100 kB
└ ƒ /streaming-ssr                       141 B           100 kB
+ First Load JS shared by all            99.9 kB
  ├ chunks/602bc678-eecccf42000cb22e.js  52.5 kB
  ├ chunks/723-5a160bacd8cc471e.js       45.6 k

ここがまさに今回のポイントです。長々と前提の説明をしたのはここを見せたかったからです。

PPR が登場する以前は、レンダリング方式が「ページ単位」で Static か Dynamic のどちらか一方に固定されていました。そのため、ページの大半が Static として最適化できる構造であっても、わずかでも Dynamic 要素が含まれているだけで、ページ全体が強制的に Dynamic Rendering と判断されてしまいました。結果として、CDN キャッシュを活用した高速配信が難しくなり、本来得られるはずのパフォーマンス向上機会を大きく失っていました。

今回の「/streaming-ssr」ルートの例ではページ全体がDynamic Renderingとして判定されているために、 <StreamingStatic> コンポーネントは静的な部分であるにも関わらずリクエスト毎に毎回HTMLを再生成しています。

この問題を解消するために出てきた概念が「PPR(Partial Pre-Rendering)」なのです!

PPRとは

一言で言うと、ページ中の「Static」と「Dynamic」コンポーネント毎に応じてRSCとHTMLのやりとりを細分化して効率よくレンダリングする技術です。

https://nextjs.org/docs/15/app/getting-started/partial-prerendering

以下が公式ドキュメントからの引用(日本語訳)です。

ユーザーがルートを訪問すると:
サーバーは静的コンテンツを含むシェルを送信し、初期読み込みを高速化します。
シェルは、非同期的に読み込まれる動的コンテンツ用の穴を残します。
動的ホールは並列にストリーミングされ、ページの全体的な読み込み時間が短縮されます。

イメージ画像(公式ドキュメント引用)

2025-12-07-nextjs15-ppr-rsc-05

紫色の部分は「静的部分」としてリクエスト毎に毎回HTMLを生成するのではなく、ビルド時にHTML化して渡してあげ、青色の部分は「動的部分」としてリクエスト毎にRSCとHTMLを組み合わせて再生成しましょう。ということです!

先ほどの「/streaming-ssr」と比べて得られるメリットは、そのページ全体のTTFB(Time to First Byte)が高速になることです。

TTFBはリクエストを送ってからサーバーから最初の1バイトを受信するまでの時間を指す指標です。TTFBが短いほど、その後のページ描画開始も早くなる傾向があります。先ほどの例の「/streaming-ssr」では <StreamingStatic> 部分も毎回HTML再生成部分として含められていたのでPPRを使用する例と比べて若干表示が遅くなります。

やってみた

前提の説明が非常に長くなりましたが、実際にNext.js v15環境で「PPR」を設定して動作確認してみました。

環境

  • Next.js 15.6.0-canary.58 (Turbopack)

Next.js 15系の場合「PPR」は実験的機能としてリリースされているため、まずconfigファイルで以下のように設定が必要です。

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    ppr: "incremental",
  },
};

export default nextConfig;

ppr: "incremental"と定義することでページ毎に「PPR」の有効/無効が設定可能になります。

app/ppr-enabled/page.tsx
import React, { Suspense } from "react";
import StreamingStatic from "@/components/StreamingStatic";
import StreamingDynamic from "@/components/StreamingDynamic";

export const experimental_ppr = true;

export default function PPRPage() {
  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold mb-6">PPR Enabled</h1>

      <div className="space-y-6">
        <section>
          <StreamingStatic />
        </section>

        <section>
          <Suspense fallback={<p>Loading...</p>}>
            <StreamingDynamic />
          </Suspense>
        </section>
      </div>
    </main>
  );
}

export const experimental_ppr = true;部分で「/ppr-enabled」ページに「PPR」を有効化しています。後の記述は先ほどの「/streaming-ssr」と全く同じです。

components/StreamingStatic.tsx
import React from "react";

type Todo = {
  id: number;
  todo: string;
};

const StreamingStatic = async () => {
  const res = await fetch("https://dummyjson.com/todos/random", {
    cache: "force-cache",
  });
  const todo: Todo = await res.json();

  return (
    <div>
      <p className="font-bold">Streaming Static</p>
      <dt>ID</dt>
      <dd>{todo.id}</dd>
      <dt>Todo</dt>
      <dd>{todo.todo}</dd>
    </div>
  );
};

export default StreamingStatic;


components/StreamingDynamic.tsx
import React from "react";

type Todo = {
  id: number;
  todo: string;
};

const StreamingDynamic = async () => {
  const res = await fetch("https://dummyjson.com/todos/random", {
    cache: "no-store",
  });
  const todo: Todo = await res.json();

  return (
    <div>
      <p className="font-bold">Streaming Dynamic</p>
      <dt>ID</dt>
      <dd>{todo.id}</dd>
      <dt>Todo</dt>
      <dd>{todo.todo}</dd>
    </div>
  );
};

export default StreamingDynamic;

ビルド時の表示

pnpm build
.
.
.
Route (app)
┌ ○ /
├ ○ /_not-found
├ ◐ /ppr-enabled
└ ƒ /streaming-ssr


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand

上記のように「/ppr-enabled」がPPRとして読み込まれています。
ビルド時点の「/streaming-ssr」と「/ppr-enabled」の成果物を見比べてみましょう。

.next/server/app/
  ├── ppr-enabled/
  │   ├── page.js                          # ページコード
  │   ├── page.js.map
  │   ├── page.js.nft.json
  │   ├── page/
  │   │   └── (マニフェスト類)
  │   └── page_client-reference-manifest.js
  ├── ppr-enabled.html                     # Static Shell(事前レンダリング済みHTML)
  ├── ppr-enabled.meta                     # メタデータ
  ├── ppr-enabled.prefetch.rsc             # プリフェッチ用RSCペイロード
  ├── ppr-enabled.segments/                # セグメントデータ
  │
  └── streaming-ssr/
      ├── page.js                          # ページコード
      ├── page.js.map
      ├── page.js.nft.json
      ├── page/
      │   └── (マニフェスト類)
      └── page_client-reference-manifest.js
      # ← .html, .meta, .prefetch.rsc, .segments/ が無い

「/streaming-ssr」の場合はDynamic Renderingなので事前に生成されたHTMLファイルやメタデータファイルなどがありません。

「/ppr-enabled」の場合はStatic部分はビルド時にレンダリングされるのでこの時点でppr-enabled.htmlppr-enabled.metaなどが存在しています。

ppr-enabled.html
  <!DOCTYPE html>
  <html lang="ja">
  <head>
    <meta charSet="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <link rel="stylesheet" href="/_next/static/chunks/ae99f313cc88ce1c.css" data-precedence="next"/>
    <link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/9f0eba19acd20ff3.js"/>
    <script src="/_next/static/chunks/2d37e715042d0bce.js" async=""></script>
    <script src="/_next/static/chunks/fcd5dfd801c3db2f.js" async=""></script>
    <script src="/_next/static/chunks/159504893438c1c0.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-95bc7b811708fdad.js" async=""></script>
    <script src="/_next/static/chunks/08d9f49112a65025.js" async=""></script>
    <script src="/_next/static/chunks/0aff4ff073f35e53.js" async=""></script>
    <link rel="preload" href="/_next/static/chunks/ae99f313cc88ce1c.css" as="stylesheet"/>
    <link rel="expect" href="#_R_" blocking="render"/>
    <title>PPR Demo</title>
    <script src="/_next/static/chunks/a6dad97d9634a72d.js" noModule=""></script>
  </head>
  <body>
    <div hidden=""><!--$--><!--/$--></div>
    <main class="p-8">
      <h1 class="text-2xl font-bold mb-6">PPR Enabled</h1>
      <div class="space-y-6">
        <!-- 静的部分: ビルド時にレンダリング済み -->
        <section>
          <div>
            <p class="font-bold">Streaming Static</p>
            <dt>ID</dt>
            <dd>240</dd>
            <dt>Todo</dt>
            <dd>Take a scenic hot air balloon ride</dd>
          </div>
        </section>
        <!-- 動的部分: Suspense fallback -->
        <section>
          <!--$?-->
          <template id="B:0"></template>
          <p>Loading...</p>
          <!--/$-->
        </section>
      </div>
    </main>
    <!--$--><!--/$-->
    <script>requestAnimationFrame(function(){$RT=performance.now()});</script>
    <script src="/_next/static/chunks/9f0eba19acd20ff3.js" id="_R_" async=""></script>
  </body>
  </html>

ビルド時に生成されるStatic Shellとは事前生成されたこのHTMLファイルのことです。

静的コンポーネント(StreamingStatic)の結果をHTMLに直接埋め込み、動的コンポーネント(StreamingDynamic)の部分は <template id="B:0"><p>Loading...</p> のSuspense fallbackで置き換えられており、リクエスト時にストリーミングで差し込まれます。

動的部分の定義(ppr-enabled.prefetch.rsc より抜粋)

ppr-enabled.prefetch.rsc
4:["$","div",null,{"children":[...,"children":"Streaming Static"}],["$","dd",null,{"children":240}],...]}]
6:P

4: は静的コンポーネントのレンダリング結果。
6:P の P は "Postponed" を意味し、動的コンポーネントがリクエスト時まで延期されていることを示しています。

実際の画面

2025-12-07-nextjs15-ppr-rsc-06

データ量が少ないので画面動作は「/streaming-ssr」との違いを体感できませんでした。

しかし「DevTool」で「WebserverからHTMLが返却されるまでの時間」項目を確認するとこのように明確な違いが出ていました。

「/streaming-ssr」の場合

Waiting for server response: 20.23ms

2025-12-07-nextjs15-ppr-rsc-07

「/ppr-enabled」の場合

Waiting for server response: 5.93ms

2025-12-07-nextjs15-ppr-rsc-08

最後に

今回はNext.js 15系におけるPPR(Partial Pre-Rendering)の挙動を検証しました。

PPRを有効にすることで、これまで「ページ単位」で固定されていたStatic/Dynamicのレンダリング方式を「コンポーネント単位」で細分化でき、静的部分はビルド時に事前生成しつつ、動的部分のみリクエスト時にストリーミングで差し込むことが可能になります。結果としてTTFBが改善され、CDNキャッシュとの相性も向上します。

ただし、冒頭でも触れたとおり、2025年12月現在の最新メジャーバージョンはNext.js 16系です。16系ではPPRの概念が「CacheComponent」として再設計・統合されており、設定方法やキャッシュの挙動が変更されています。本記事の内容はあくまで15系での検証であり、16系への移行を検討されている方は公式ドキュメントを併せてご確認ください。

今回は以上です。

この記事をシェアする

FacebookHatena blogX

関連記事