•22 min read

Next.js15のPartialPrerendering(PPR):ハイブリッドストリーミング、キャッシュライフ、Suspenseアーキテクチャ

Next.js15のPartialPrerendering(PPR):ハイブリッドストリーミング、キャッシュライフ、Suspenseアーキテクチャ

Next.js 15では、従来の静的サイト生成(SSG)とサーバーサイドレンダリング(SSR)という二分法を超え、基盤となるアーキテクチャの転換としてPartial Prerendering(PPR)が導入されました。PPRはReactのSuspenseとストリーミング機能を活用し、ハイブリッドなアプローチを提供します。即座に提供される高速な静的シェルと、データが利用可能になり次第ストリーミングされる動的な「穴」を組み合わせるものです。このガイドでは、PPRの仕組み、キャッシュへの影響、および実践的な実装戦略について詳しく解説します。

Audio Briefing
0:00 / 0:00

Partial Prerendering (PPR) の理解

PPRは、Next.jsのページがどのようにレンダリングされ、提供されるかを根本的に再定義します。すべてのデータが解決されるまでHTMLの送信を待つ(SSR)のではなく、またはビルド時にページ全体をプリレンダリングする(SSG)のではなく、PPRはページの静的セグメントと動的セグメントを識別します。

ビルド中、Next.jsはアプリケーションの静的部分、通常は動的データやユーザー固有のコンテキストに依存しないコンポーネントを識別します。これらの静的部分が「静的シェル」を形成します。多くの場合Suspense境界で囲まれた動的部分は、「穴」として扱われます。

リクエストが来たとき:

  1. 静的シェルはキャッシュから即座に提供されます(SSGと同様)。これにより、ほぼ瞬時のTime To First Byte(TTFB)とFirst Contentful Paint(FCP)が実現します。
  2. 同時に、サーバーは動的な穴のデータをフェッチします。
  3. 動的データが解決されると、Next.jsは対応するHTMLをクライアントにストリーミングし、Suspenseフォールバックを置き換えます。

このハイブリッドなストリーミングアプローチにより、高速な初期ロードを確保しつつ、動的コンテンツの鮮度を維持します。

PPRの有効化

PPRはNext.js 15の実験的な機能です。有効にするには、next.config.mjsを設定します。

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    ppr: true,
    // Other experimental flags if needed
  },
  // ... other Next.js configurations
};

export default nextConfig;

ppr: trueを使用すると、Next.jsはSuspense境界を含むページにPPR最適化を自動的に適用しようとします。

Suspenseの役割

React SuspenseはPPRの要です。非同期データに依存するUIのパーツのローディング状態を宣言的に指定できます。PPRのコンテキストでは、Suspense境界がストリーミングされる動的な「穴」を区切ります。

商品詳細ページを考えてみましょう。

// app/products/[slug]/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from './ProductDetails';
import { RelatedProducts } from './RelatedProducts';
import { Reviews } from './Reviews';
import { Skeleton } from '@/components/ui/skeleton'; // A simple skeleton component

export default async function ProductPage({ params }: { params: { slug: string } }) {
  // Static shell content
  const staticProductInfo = await getStaticProductInfo(params.slug); // Example: product name, description

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-4">{staticProductInfo.name}</h1>
      <p className="text-lg mb-6">{staticProductInfo.description}</p>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
        {/* Dynamic hole 1: Product Details (e.g., price, stock, images) */}
        <Suspense fallback={<ProductDetailsSkeleton />}>
          <ProductDetails slug={params.slug} />
        </Suspense>

        {/* Dynamic hole 2: Related Products */}
        <Suspense fallback={<RelatedProductsSkeleton />}>
          <RelatedProducts slug={params.slug} />
        </Suspense>

        {/* Dynamic hole 3: User Reviews */}
        <Suspense fallback={<ReviewsSkeleton />}>
          <Reviews slug={params.slug} />
        </Suspense>
      </div>
    </div>
  );
}

// Example data fetching functions (un-memoized for PPR)
async function getStaticProductInfo(slug: string) {
  // Simulate fetching static product data
  await new Promise(resolve => setTimeout(resolve, 50));
  return { name: `Product ${slug.toUpperCase()}`, description: `A detailed description for product ${slug}.` };
}

// Component for dynamic product details
async function ProductDetails({ slug }: { slug: string }) {
  // Simulate fetching dynamic product details (e.g., real-time price, stock)
  await new Promise(resolve => setTimeout(resolve, 1000));
  return (
    <div className="col-span-2 bg-gray-100 p-4 rounded-lg">
      <h2 className="text-2xl font-semibold mb-2">Details</h2>
      <p>Price: $99.99</p>
      <p>Stock: In Stock</p>
      {/* More dynamic details */}
    </div>
  );
}

// Component for related products
async function RelatedProducts({ slug }: { slug: string }) {
  await new Promise(resolve => setTimeout(resolve, 1500));
  return (
    <div className="bg-gray-100 p-4 rounded-lg">
      <h2 className="text-2xl font-semibold mb-2">Related Products</h2>
      <ul>
        <li>Related Item A</li>
        <li>Related Item B</li>
      </ul>
    </div>
  );
}

// Component for reviews
async function Reviews({ slug }: { slug: string }) {
  await new Promise(resolve => setTimeout(resolve, 2000));
  return (
    <div className="col-span-3 bg-gray-100 p-4 rounded-lg">
      <h2 className="text-2xl font-semibold mb-2">Customer Reviews</h2>
      <p>No reviews yet.</p>
    </div>
  );
}

// Skeleton components for fallbacks
function ProductDetailsSkeleton() {
  return (
    <div className="col-span-2 bg-gray-100 p-4 rounded-lg animate-pulse">
      <Skeleton className="h-6 w-3/4 mb-2" />
      <Skeleton className="h-4 w-1/2 mb-1" />
      <Skeleton className="h-4 w-1/3" />
    </div>
  );
}

function RelatedProductsSkeleton() {
  return (
    <div className="bg-gray-100 p-4 rounded-lg animate-pulse">
      <Skeleton className="h-6 w-3/4 mb-2" />
      <Skeleton className="h-4 w-full mb-1" />
      <Skeleton className="h-4 w-full" />
    </div>
  );
}

function ReviewsSkeleton() {
  return (
    <div className="col-span-3 bg-gray-100 p-4 rounded-lg animate-pulse">
      <Skeleton className="h-6 w-3/4 mb-2" />
      <Skeleton className="h-4 w-full" />
    </div>
  );
}

この例では:

  • staticProductInfoのh1とpタグは静的シェルの一部です。
  • ProductDetails、RelatedProducts、およびReviewsはSuspenseで囲まれており、動的な穴となります。それぞれのSkeletonコンポーネントがフォールバックとして機能します。

ユーザーが/products/widgetをリクエストすると、商品名と説明のHTMLが即座に届きます。その後、ブラウザはデータが利用可能になり次第、詳細、関連商品、レビューを段階的に受信してレンダリングします。

ハイブリッドストリーミング:HTTPレスポンス

PPRの魔法は、そのHTTPストリーミングレスポンスにあります。単一のモノリシックなHTMLドキュメントではなく、サーバーはマルチパートレスポンスを送信します。

  1. 初期HTML(静的シェル): レスポンスの最初の部分には、Suspenseフォールバックを含む静的HTMLが含まれます。これはContent-Type: text/htmlヘッダーとともに送信されます。
  2. HTMLフラグメントのストリーミング: レスポンスのその後の部分は、解決された動的コンテンツを含む<template>タグとしてストリーミングされます。これらのフラグメントには、対応するSuspenseフォールバックを新しいコンテンツでハイドレートして置き換えるようReactに指示する<script>タグが伴います。

これは単にHTMLを送信するだけでなく、ページを段階的に強化するための実行可能な指示をクライアントに送信することです。

Advertisement

キャッシュライフプロファイルとデータフェッチ

PPRはcacheLifeプロファイルを導入し、動的コンテンツがどれくらいの期間新鮮であると見なされ、いつ再検証されるべきかについてきめ細かな制御を可能にします。これは、パフォーマンスとデータの鮮度のバランスを取る上で非常に重要です。

cacheLifeプロファイル

Next.js 15では、データフェッチ関数に新しいcacheLifeオプションが導入され、PPRの穴内の動的コンテンツがキャッシュされる期間を指定できます。これは、ビルド後に通常は不変である静的シェルのキャッシュとは異なります。

// lib/data.ts
import { unstable_cache as cache } from 'next/cache';

export const getProductDetails = cache(
  async (slug: string) => {
    console.log(`Fetching product details for ${slug}...`);
    await new Promise(resolve => setTimeout(resolve, 1000));
    return { price: 99.99, stock: Math.floor(Math.random() * 100) };
  },
  ['product-details'], // Cache key
  {
    tags: ['product-details'], // Invalidation tags
    // cacheLife: 60, // Cache for 60 seconds
    // cacheLife: 'revalidate', // Revalidate on every request (default for dynamic)
    cacheLife: 'static', // Cache indefinitely (like SSG)
  }
);

export const getRelatedProducts = cache(
  async (slug: string) => {
    console.log(`Fetching related products for ${slug}...`);
    await new Promise(resolve => setTimeout(resolve, 1500));
    return ['Related A', 'Related B', 'Related C'];
  },
  ['related-products'],
  {
    tags: ['related-products'],
    cacheLife: 3600, // Cache for 1 hour
  }
);

cacheLifeオプションはいくつかの値を取ることができます。

  • number(秒): 時間ベースの再検証を指定します。この期間が過ぎると、キャッシュは古くなったと見なされ、次のリクエストで再検証されます。
  • 'revalidate': 動的データのデフォルトの動作です。データはリクエストごとに再検証されます。これはページレベルのrevalidateオプションにおけるfetch(..., { cache: 'no-store' })またはrevalidate = 0と同等です。
  • 'static': データはSSGと同様に無期限にキャッシュされます。これは、変更が非常にまれなデータや実質的に静的なデータに適しています。

重要: cacheLifeはデータ自体に適用され、HTMLフラグメントには適用されません。動的な穴のHTMLフラグメントは、データがフェッチされた後に生成されます。cacheLifeが設定されている場合、データフェッチ関数は、利用可能で新鮮であればキャッシュされたデータを使用します。

メモ化されていないデータフェッチパターン

PPRでよくある落とし穴は、データフェッチの過剰なメモ化です。従来のReactコンポーネントでは、不要な再レンダリングや再フェッチを防ぐためにuseMemoやuseCallbackを使用するかもしれません。しかし、PPRでは、動的な穴のサーバーサイドレンダリングフェーズは、明示的にキャッシュされない限り、リクエストごとにデータをフェッチするように設計されています。

Suspense境界内で、意図的にcacheをcacheLifeプロファイルで使用している場合を除き、後続のリクエストでデータフェッチ関数が実行されないようにするパターンは避けてください。

// app/products/[slug]/ProductDetails.tsx
import { getProductDetails } from '@/lib/data';

export async function ProductDetails({ slug }: { slug: string }) {
  // This fetch will run on every request for the dynamic hole
  // unless getProductDetails itself is wrapped in unstable_cache with a cacheLife.
  const details = await getProductDetails(slug);

  return (
    <div className="col-span-2 bg-gray-100 p-4 rounded-lg">
      <h2 className="text-2xl font-semibold mb-2">Details</h2>
      <p>Price: ${details.price}</p>
      <p>Stock: {details.stock}</p>
    </div>
  );
}

getProductDetailsがunstable_cacheで囲まれていない場合、動的な穴をトリガーするすべてのリクエストで実行されます。これは、真に動的なデータにとって望ましい動作であることがよくあります。キャッシュしたい場合は、適切なcacheLifeとともにunstable_cacheを使用してください。

TTFBと完全動的SSRのベンチマーク

PPRの主なパフォーマンス上の利点は、特にデータ依存性が遅いページの場合、従来のSSRと比較してTTFBとFCPが大幅に改善されることです。

機能従来のSSRPartial Prerendering (PPR)
TTFB高い(すべてのデータを待つ)低い(静的シェルが即座に提供される)
FCP高い(すべてのデータを待つ)低い(静的シェルが即座にレンダリングされる)
LCP最も遅いデータに依存最も遅い重要な動的データに依存
データの鮮度常に新鮮(リクエストごと)動的な穴ごとに設定可能(cacheLife)
複雑さよりシンプルなメンタルモデルSuspenseとcacheLifeの慎重な管理が必要
キャッシングページレベルのrevalidateまたはno-store静的シェルは無期限にキャッシュされ、動的な穴はcacheLife経由
ストリーミング基本的なHTMLストリーミング(プログレッシブコンテンツなし)プログレッシブコンテンツを備えた高度なHTMLストリーミング
ビルド時間最小限(プリレンダリングなし)高い(静的部分を識別し、シェルをプリレンダリングする)

ベンチマークシナリオ: 静的なヘッダー/フッターと3つの動的セクションを持つページを考えます。各セクションはデータのフェッチに1秒かかります。

  • 従来のSSR: TTFBは〜3秒(すべてのデータフェッチの合計)+サーバーレンダリング時間。
  • PPR: TTFBは〜50ms(静的シェル)+サーバーレンダリング時間。動的セクションは次の3秒間にストリーミングされます。

プレースホルダーがあっても、このユーザーへの即時フィードバックは、知覚されるパフォーマンスとユーザーエクスペリエンスを劇的に向上させます。

本番環境での注意点とトラブルシューティング

  1. Suspenseフォールバックとのハイドレーションミスマッチ:

    • 問題: 動的コンテンツがストリーミングされるときにWarning: Prop 'className' did not match. Server: "..." Client: "..."または同様のハイドレーションエラーが表示される。これは、Suspenseフォールバックが、動的コンテンツが到着する前に初期のクライアントサイドレンダリングとは異なるHTMLをレンダリングする場合によく発生します。
    • 修正: Suspenseフォールバック(例:スケルトンローダー)が、動的コンテンツが準備できるまでサーバーとクライアントの両方で同じHTMLをレンダリングするようにしてください。クライアントサイドのみのロジックやフォールバック内のランダムなIDは避けてください。react-loading-skeletonのようなライブラリを使用している場合は、一貫して設定されていることを確認してください。
  2. PPRにもかかわらず初期ロードが遅い:

    • 問題: ppr: trueを使用しているにもかかわらず、TTFBがまだ高い。
    • 修正:
      • Suspense境界を確認する: 真に動的な部分はSuspenseで囲まれていますか?ページのルートまたは大きなセクションが中断されていない場合、Next.jsはすべてのデータを待つ可能性があります。
      • ルートレベルのawait: page.tsxがSuspense境界の外側で遅いデータフェッチを直接awaitする場合、静的シェルをブロックします。そのようなフェッチはSuspenseで囲まれたコンポーネントに移動してください。
      • 静的コンテンツに対するcacheLife: 'revalidate': 静的である可能性のあるデータに誤ってcacheLife: 'revalidate'またはrevalidate = 0を設定すると、静的シェルがキャッシュから提供されなくなります。静的データフェッチがcacheLife: 'static'を使用しているか、真に静的でビルドの一部である場合はunstable_cacheで囲まれていないことを確認してください。
  3. 古いデータにつながる不適切なcacheLifeの使用:

    • 問題: ユーザーが動的セクションで古いデータを見ている。
    • 修正: unstable_cache設定を確認してください。cacheLifeが高すぎる場合(例:急速に変化する株価に対して3600秒)、データはキャッシュから提供されます。実際の鮮度要件を反映するようにcacheLifeを調整してください。リアルタイムデータの場合は、cacheLife: 'revalidate'を使用するか、unstable_cacheを完全に省略してください。オンデマンドの無効化にはrevalidateTagまたはrevalidatePathを使用できることを忘れないでください。
  4. cacheLife: 'revalidate'による過剰なサーバー負荷:

    • 問題: PPRページへのすべてのリクエストが動的な穴の再フェッチをトリガーするため、バックエンドサービスが過負荷になっている。
    • 修正: これはcacheLife: 'revalidate'の期待される動作です。データがすべてのリクエストで絶対にリアルタイムである必要がない場合は、妥当な鮮度を維持しつつバックエンドの負荷を軽減するために、小さなcacheLife(例:5または10秒)を導入することを検討してください。バックエンドサービスにも堅牢なキャッシングレイヤーを実装してください。
  5. 開発環境でPPRが機能しない:

    • 問題: next devでPPRの利点(ストリーミング、高速TTFB)が明らかにならない。
    • 修正: PPRの完全な機能、特に静的シェルのキャッシングとストリーミングは、主に本番ビルド(next buildとnext start)向けに最適化されています。Suspenseは開発環境でも機能しますが、キャッシングとストリーミングの最適化はそれほど顕著ではないか、完全にアクティブではない場合があります。PPRのパフォーマンスは常に本番環境に似た環境でテストしてください。
Advertisement

よくある質問

Q1: PPRをgenerateStaticParamsと一緒に使用できますか?

A1: はい、できます。generateStaticParamsは、ビルド時に指定されたすべてのパスの静的シェルをプリレンダリングします。これらのパスのいずれかへのリクエストが来たとき、プリレンダリングされた静的シェルが即座に提供され、動的な穴がストリーミングされます。これにより、SSGの利点(既知のパスの高速初期ロード)とSSRの利点(動的コンテンツの鮮度)が組み合わされます。

Q2: PPRはrevalidatePathとrevalidateTagとどのように連携しますか?

A2: revalidatePathとrevalidateTagは主にunstable_cacheによって管理されるデータキャッシュを無効にします。unstable_cacheをtagsとcacheLifeと一緒に使用する場合、revalidateTag('your-tag')を呼び出すと、そのキャッシュされたデータは古くなったとマークされます。そのデータに依存する動的な穴への次のリクエストは、再フェッチをトリガーします。静的シェル自体は、ページ全体が再ビルドされない限り、ビルド後は一般的に不変です。

Q3: PPRは高度にパーソナライズされたページ(例:ユーザーダッシュボード)に適していますか?

A3: PPRは、高度にパーソナライズされたページでも有益です。静的シェルには、一般的なレイアウト要素(ヘッダー、ナビゲーション、空のダッシュボード構造)を含めることができます。パーソナライズされたデータ(ユーザー名、特定の指標、最近のアクティビティ)は、動的な穴にストリーミングされます。これにより、すべてのパーソナライズされたデータを待つよりも高速な初期レンダリングが提供されます。ただし、ページのすべての部分が動的でユーザー固有である場合、静的シェルの利点は薄れ、従来のSSRの方が管理が簡単になる可能性があります。

Q4: PPRはSEOにどのような影響を与えますか?

A4: PPRは一般的にSEOに優しいです。検索エンジンのクローラーは通常JavaScriptを実行し、コンテンツがレンダリングされるのを待ちます。静的シェルが即座に配信されるため、コアコンテンツはすぐに利用可能になります。後でストリーミングされる動的コンテンツも、クローラーによってレンダリングされるとインデックスされます。重要なのは、Suspenseフォールバックが意味のあるコンテンツまたは明確なインジケーターを提供し、動的コンテンツが最終的に正しくレンダリングされることを確認することです。高速なTTFBとFCPは、ポジティブなSEOシグナルです。

Q5: PPRはクライアントサイドのJavaScriptバンドルサイズにどのように影響しますか?

A5: PPR自体は、クライアントサイドのJavaScriptバンドルサイズを直接増減させません。主にサーバーサイドレンダリングとストリーミングプロセスを最適化します。ただし、SuspenseとReactのストリーミングアーキテクチャを使用するということは、クライアントサイドのReactランタイムが、ストリーミングされたフラグメントをハイドレートし、段階的にレンダリングするために不可欠であることを意味します。Next.jsの自動コード分割により、各コンポーネントに必要なJavaScriptのみがロードされます。

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement