•21 min read

Next.js 15 Partial Prerendering (PPR):静的シェルと動的ストリーミングの組み合わせ

Next.js 15 Partial Prerendering (PPR):静的シェルと動的ストリーミングの組み合わせ

Next.js 15で導入されたPartial Prerendering(PPR)は、動的なウェブアプリケーションのレンダリングと配信方法を根本的に変える、新しい最適化戦略です。PPRは単なる漸進的な改善ではなく、静的サイト生成(SSG)とサーバーサイドレンダリング(SSR)の最良の側面をReactのSuspense駆動型ストリーミング機能と融合させた、パラダイムシフトを意味します。このガイドでは、PPRのアーキテクチャ、主要なパフォーマンス指標への影響、および高並行性アプリケーションにおける実践的な実装の考慮事項について詳しく説明します。

Audio Briefing
0:00 / 0:00

Partial Prerenderingのアーキテクチャ概要

PPRは、ページの即時的な静的シェルを配信しながら、指定されたSuspense境界に動的コンテンツを同時にストリーミングするという原則に基づいて動作します。これは、多段階のレンダリングプロセスによって実現されます。

  1. ビルド時の静的シェル生成: next build中に、Next.jsはページの静的部分を特定し、軽量なHTMLシェルを生成します。このシェルには、基本的なレイアウト、ナビゲーション、およびSuspense境界でラップされていないコンテンツが含まれます。この静的HTMLはCDNから直接提供されます。
  2. サーバーサイドの動的コンテンツストリーミング: リクエストがサーバーに到達すると、Next.jsは動的コンポーネント(Suspense境界内のコンポーネント)をサーバー上でレンダリングします。すべてのデータが解決されるのを待つのではなく、これらのコンポーネントの解決済みHTMLを、静的シェルを配信したのと同じHTTP接続を介してクライアントのブラウザに直接ストリーミングします。
  3. クライアントサイドのハイドレーション: ストリーミングされたHTMLが到着すると、Reactはコンポーネントを段階的にハイドレートし、ページ全体をリロードすることなくインタラクティブにします。

このアプローチにより、CDNにキャッシュされた静的アセットを活用することで、非常に高速なTime to First Byte(TTFB)とFirst Contentful Paint(FCP)が保証され、クライアントサイドのデータフェッチのウォーターフォールなしで、完全に動的でパーソナライズされたエクスペリエンスが提供されます。

PPR vs. 従来のSSR/SSG/ISR

機能従来のSSR従来のSSG従来のISRNext.js 15 PPR
TTFB高い(サーバーがすべてのデータを待つ)低い(CDNキャッシュ)低い(CDNキャッシュ)低い(CDNキャッシュされた静的シェル)
FCP高い(サーバーがすべてのデータを待つ)低い(CDNキャッシュ)低い(CDNキャッシュ)低い(CDNキャッシュされた静的シェル)
動的コンテンツ完全な動的なし(クライアントサイドフェッチ)完全な動的(再検証)完全な動的(サーバーからストリーミング)
ビルド時間N/A(オンデマンド)高い(すべてのページ)高い(すべてのページ)中程度(静的シェル)
キャッシュ可能性低い(リクエストごと)高い(CDN)高い(CDN、再検証あり)高い(静的シェル)
複雑さ中程度低い中程度中程度~高い(Suspense境界)
Advertisement

パフォーマンスへの影響:TTFBとFCP

PPRの主な利点は、TTFBとFCPへの影響、特にEコマースの製品ページやSaaSダッシュボードのような複雑なアプリケーションにおいて顕著です。

Eコマースの製品ページ

製品詳細ページ(PDP)を考えてみましょう。コアレイアウト、製品画像、タイトル、静的な説明は一貫していることが多いです。動的な要素には、価格、在庫状況、パーソナライズされた推奨事項、ユーザーレビューなどがあります。

PPRの場合:

  1. 初期リクエスト: ブラウザは、製品画像、タイトル、レイアウトを含む静的HTMLシェルをCDNからほぼ瞬時に受け取ります。TTFBは最小限です。
  2. FCP: ユーザーは主要な製品情報を非常に迅速に確認できます。
  3. ストリーミング: 同時に、サーバーはリアルタイムの価格、在庫、推奨データフェッチします。各データが解決されると、対応するHTMLがページにストリーミングされます。
  4. ハイドレーション: ストリーミングされたコンテンツは段階的にハイドレートされ、「カートに追加」ボタンがインタラクティブになり、推奨事項がクリック可能になります。

これは、従来のSSR(ユーザーがすべての動的データが表示されるまで待機する)やSSG(動的データがクライアントサイドのフェッチを必要とし、レイアウトシフトやインタラクティブ性の遅延につながる)と比較して、優れたユーザーエクスペリエンスを提供します。

SaaSダッシュボード

SaaSダッシュボードは、リアルタイムデータ(例:ユーザー分析、売上高、システムヘルス)を表示する複数のウィジェットを特徴とすることがよくあります。

PPRの場合:

  1. 初期リクエスト: ダッシュボードのレイアウト、ナビゲーション、静的ヘッダーが静的シェルとして配信されます。
  2. FCP: ユーザーはダッシュボードの構造をすぐに確認できます。
  3. ストリーミング: 個々のデータウィジェット(例:「アクティブユーザー」、「今日の収益」、「システム負荷」)はSuspense境界でラップされます。サーバーは各ウィジェットのデータを個別にフェッチします。「アクティブユーザー」のデータが解決されると、そのHTMLがストリーミングされます。次に「今日の収益」などが続きます。
  4. ハイドレーション: 各ウィジェットは、コンテンツが到着してハイドレートされるとインタラクティブになります。

これにより、単一の遅いデータフェッチがダッシュボード全体のレンダリングをブロックするのを防ぎ、知覚されるパフォーマンスとユーザーエンゲージメントを向上させます。

Partial Prerenderingの実装

PPRはReactのSuspenseとasync/awaitコンポーネントを活用します。基本的な考え方は、ページの動的またはデータ依存の部分を<Suspense>境界でラップすることです。

// app/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from '@/components/ProductDetails';
import { ProductRecommendations } from '@/components/ProductRecommendations';
import { UserReviews } from '@/components/UserReviews';
import { Skeleton } from '@/components/Skeleton'; // A simple loading skeleton

interface ProductPageProps {
  params: {
    productId: string;
  };
}

export default async function ProductPage({ params }: ProductPageProps) {
  const productId = params.productId;

  // Static content rendered immediately
  const staticProductInfo = await getStaticProductInfo(productId);

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-4">{staticProductInfo.name}</h1>
      <img src={staticProductInfo.imageUrl} alt={staticProductInfo.name} className="w-full h-64 object-cover mb-4" />
      <p className="text-gray-700 mb-6">{staticProductInfo.description}</p>

      {/* Dynamic content wrapped in Suspense boundaries */}
      <section className="mb-8">
        <h2 className="text-2xl font-semibold mb-4">Product Details</h2>
        <Suspense fallback={<Skeleton height="h-48" />}>
          {/* ProductDetails is an async component that fetches dynamic data */}
          <ProductDetails productId={productId} />
        </Suspense>
      </section>

      <section className="mb-8">
        <h2 className="text-2xl font-semibold mb-4">Recommendations</h2>
        <Suspense fallback={<Skeleton height="h-32" />}>
          {/* ProductRecommendations is an async component */}
          <ProductRecommendations productId={productId} />
        </Suspense>
      </section>

      <section>
        <h2 className="text-2xl font-semibold mb-4">Customer Reviews</h2>
        <Suspense fallback={<Skeleton height="h-64" />}>
          {/* UserReviews is an async component */}
          <UserReviews productId={productId} />
        </Suspense>
      </section>
    </div>
  );
}

// Example of a static data fetch (could be from a CMS or build-time data)
async function getStaticProductInfo(productId: string) {
  // Simulate a fast, static data fetch
  return {
    name: `Product ${productId} - Static Title`,
    imageUrl: `/images/product-${productId}.jpg`,
    description: `This is a static description for product ${productId}. It provides general information that doesn't change frequently.`,
  };
}
// components/ProductDetails.tsx
import { delay } from '@/lib/utils'; // Utility for simulating network delay

interface ProductDetailsProps {
  productId: string;
}

export async function ProductDetails({ productId }: ProductDetailsProps) {
  // Simulate a dynamic data fetch (e.g., real-time price, stock)
  await delay(1500); // Simulate network latency
  const price = (Math.random() * 100 + 50).toFixed(2);
  const stock = Math.floor(Math.random() * 200);

  return (
    <div className="border p-4 rounded-lg bg-white shadow-sm">
      <p className="text-xl font-bold text-green-600 mb-2">Price: ${price}</p>
      <p className="text-lg text-gray-800">In Stock: {stock} units</p>
      <button className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
        Add to Cart
      </button>
    </div>
  );
}
// components/ProductRecommendations.tsx
import { delay } from '@/lib/utils';

interface ProductRecommendationsProps {
  productId: string;
}

export async function ProductRecommendations({ productId }: ProductRecommendationsProps) {
  await delay(2500); // Simulate longer network latency for recommendations
  const recommendations = [
    `Related Product A for ${productId}`,
    `Related Product B for ${productId}`,
    `Related Product C for ${productId}`,
  ];

  return (
    <div className="border p-4 rounded-lg bg-white shadow-sm">
      <ul className="list-disc pl-5">
        {recommendations.map((rec, i) => (
          <li key={i} className="text-gray-700">{rec}</li>
        ))}
      </ul>
    </div>
  );
}
// components/UserReviews.tsx
import { delay } from '@/lib/utils';

interface UserReviewsProps {
  productId: string;
}

export async function UserReviews({ productId }: UserReviewsProps) {
  await delay(3000); // Simulate even longer latency for reviews
  const reviews = [
    { user: 'Alice', rating: 5, comment: `Excellent product for ${productId}!` },
    { user: 'Bob', rating: 4, comment: `Good value, but delivery was slow.` },
  ];

  return (
    <div className="border p-4 rounded-lg bg-white shadow-sm">
      {reviews.map((review, i) => (
        <div key={i} className="mb-4 pb-4 border-b last:border-b-0">
          <p className="font-semibold">{review.user} - <span className="text-yellow-500">{'★'.repeat(review.rating)}</span></p>
          <p className="text-gray-700">{review.comment}</p>
        </div>
      ))}
    </div>
  );
}
// components/Skeleton.tsx
interface SkeletonProps {
  height?: string;
}

export function Skeleton({ height = 'h-24' }: SkeletonProps) {
  return (
    <div className={`animate-pulse bg-gray-200 rounded-md ${height}`}></div>
  );
}
// lib/utils.ts
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

この設定では:

  • ProductPage自体はasyncコンポーネントですが、getStaticProductInfoが高速であるかプリフェッチされていると仮定されるため、初期レンダリングは高速です。
  • ProductDetails、ProductRecommendations、およびUserReviewsもasyncコンポーネントであり、データフェッチをシミュレートしています。これらは<Suspense>でラップされており、データがロードされている間、即座のフォールバックUI(Skeleton)を提供します。
  • 静的シェルには、h1、img、およびpのProductPageからのタグが含まれます。Suspenseフォールバックも初期静的シェルの一部です。

移行の落とし穴とトラブルシューティング

既存のアプリケーションをPPRに移行したり、PPRで新しいアプリケーションを開発したりするには、データフェッチパターンとReactの並行機能について慎重に検討する必要があります。

PPRにおけるcookies()とheaders()

cookies()とheaders()関数はnext/headersからの動的関数です。静的シェルの一部であるコンポーネント内で使用されると、ページ全体がサーバー上で動的にレンダリングされるように強制され、そのページではPPRが無効になります。

問題: Suspense境界の外でcookies()またはheaders()を使用する。

// app/page.tsx (BAD EXAMPLE)
import { cookies } from 'next/headers';

export default function MyPage() {
  const cookieStore = cookies(); // This makes the whole page dynamic
  const theme = cookieStore.get('theme')?.value || 'light';

  return (
    <div className={`theme-${theme}`}>
      {/* ... rest of your page */}
    </div>
  );
}

解決策: 動的なヘッダー/クッキーの使用をSuspenseでラップされたコンポーネント内に隔離するか、真に動的なルートでのみ使用されるようにします。テーマについては、クライアントサイドの状態または専用のクライアントコンポーネントを検討してください。

// app/page.tsx (GOOD EXAMPLE)
import { Suspense } from 'react';
import { DynamicHeader } from '@/components/DynamicHeader';

export default function MyPage() {
  return (
    <div>
      <Suspense fallback={<div>Loading Header...</div>}>
        <DynamicHeader /> {/* DynamicHeader uses cookies() */}
      </Suspense>
      {/* ... rest of your static page content */}
    </div>
  );
}

// components/DynamicHeader.tsx
import { cookies } from 'next/headers';

export async function DynamicHeader() {
  const cookieStore = cookies();
  const userName = cookieStore.get('userName')?.value || 'Guest';
  return <header>Welcome, {userName}!</header>;
}

これにより、DynamicHeaderコンポーネントのみがサーバー上で動的にレンダリングされ、MyPageの残りの部分は静的シェルの一部として残ることができます。

動的データフェッチ

Suspense境界でラップされていないfetch Server Component内のasync呼び出しまたはデータベースクエリが遅い場合、初期の静的シェル生成がブロックされます。

問題: Suspenseの外での遅いデータフェッチ。

// app/dashboard/page.tsx (BAD EXAMPLE)
import { getCriticalMetrics } from '@/lib/api';

export default async function DashboardPage() {
  const metrics = await getCriticalMetrics(); // This fetch blocks the entire page

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Critical Metric: {metrics.value}</p>
      {/* ... other components, potentially in Suspense */}
    </div>
  );
}

getCriticalMetricsが遅い場合、ダッシュボード全体が待機し、初期レンダリングにおけるPPRの利点が失われます。

解決策: 潜在的に遅い、または動的なデータフェッチをすべてSuspense境界でラップします。

// app/dashboard/page.tsx (GOOD EXAMPLE)
import { Suspense } from 'react';
import { CriticalMetrics } from '@/components/CriticalMetrics';
import { SalesChart } from '@/components/SalesChart';

export default function DashboardPage() {
  return (
    <div className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">Dashboard Overview</h1>

      <section className="mb-8">
        <h2 className="text-2xl font-semibold mb-4">Key Performance Indicators</h2>
        <Suspense fallback={<Skeleton height="h-24" />}>
          <CriticalMetrics /> {/* Async component fetching data */}
        </Suspense>
      </section>

      <section>
        <h2 className="text-2xl font-semibold mb-4">Sales Trends</h2>
        <Suspense fallback={<Skeleton height="h-96" />}>
          <SalesChart /> {/* Another async component */}
        </Suspense>
      </section>
    </div>
  );
}

// components/CriticalMetrics.tsx
import { delay } from '@/lib/utils';

export async function CriticalMetrics() {
  await delay(2000); // Simulate slow API call
  const value = (Math.random() * 1000).toFixed(0);
  return <div className="p-4 border rounded-lg bg-green-50">Current Metric: {value}</div>;
}

これにより、Dashboard Overviewのタイトルとレイアウトが瞬時に配信され、メトリクスは利用可能になり次第ストリーミングされます。

use clientコンポーネントとPPR

クライアントコンポーネントは、サーバーコンポーネントと同じ方法でサーバーサイドストリーミングに参加しません。クライアントコンポーネントが動的データを必要とする場合、ハイドレーション後にクライアントでデータをフェッチするため、ウォーターフォールが発生する可能性があります。最適なPPRのためには、データフェッチをできるだけ上位のサーバーコンポーネントにプッシュします。

問題: クライアントコンポーネントがデータをフェッチする。

// components/ClientCounter.tsx (BAD EXAMPLE for PPR)
'use client';
import { useState, useEffect } from 'react';

export function ClientCounter() {
  const [count, setCount] = useState(0);
  const [dynamicValue, setDynamicValue] = useState(null);

  useEffect(() => {
    // This fetch happens on the client after hydration
    fetch('/api/dynamic-value')
      .then(res => res.json())
      .then(data => setDynamicValue(data.value));
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      {dynamicValue && <p>Dynamic Value: {dynamicValue}</p>}
    </div>
  );
}

dynamicValueが重要なコンテンツである場合、この方法でフェッチすると、クライアントコンポーネントがハイドレートされ、useEffectが実行されるまで表示が遅延します。

解決策: 親のサーバーコンポーネントから動的データをプロップとして渡します。

// app/page.tsx
import { Suspense } from 'react';
import { ServerFetchedClientCounter } from '@/components/ServerFetchedClientCounter';
import { getInitialCount } from '@/lib/api'; // Server-side data fetch

export default async function HomePage() {
  const initialCount = await getInitialCount(); // Fetch on server
  return (
    <div>
      <h1>Home</h1>
      <Suspense fallback={<div>Loading Counter...</div>}>
        <ServerFetchedClientCounter initialCount={initialCount} />
      </Suspense>
    </div>
  );
}

// components/ServerFetchedClientCounter.tsx
'use client';
import { useState } from 'react';

interface ServerFetchedClientCounterProps {
  initialCount: number;
}

export function ServerFetchedClientCounter({ initialCount }: ServerFetchedClientCounterProps) {
  const [count, setCount] = useState(initialCount); // Initial state from server

  return (
    <div className="border p-4 rounded-lg bg-blue-50">
      <p className="text-lg">Server-initialized Count: {count}</p>
      <button
        onClick={() => setCount(c => c + 1)}
        className="mt-2 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
      >
        Increment
      </button>
    </div>
  );
}

// lib/api.ts
import { delay } from './utils';

export async function getInitialCount() {
  await delay(1000); // Simulate server-side data fetch
  return 42;
}

ここでは、initialCountがサーバーでフェッチされ、プロップとして渡されるため、クライアントコンポーネントは追加のクライアントサイドフェッチなしで初期データを持つことができます。

Advertisement

よくある質問

1. PPRはSEOにどのように影響しますか?

PPRは一般的にSEOに有益です。重要なコンテンツを含む初期の静的シェルは、検索エンジンのクローラーにすぐに利用可能であり、高速なインデックス作成を保証します。後でストリーミングされる動的コンテンツもサーバーレンダリングされたHTMLの一部であるため、JavaScriptを実行するクローラー(Googlebotなど)は最終的に完全なコンテンツを確認できます。FCPとTTFBの改善も検索ランキングにプラスに貢献します。

2. PPRをgenerateStaticParamsと一緒に使用できますか?

はい、できます。generateStaticParamsはビルド時にルートをプリレンダリングするために使用されます。PPRと組み合わせると、Next.jsはこれらのプリレンダリングされたルートごとに静的シェルを生成します。Suspense境界内の動的コンテンツは、それらのルートにアクセスされたときにオンデマンドでストリーミングされます。これは、多くの類似したレイアウトを持つが、動的データが異なるページ(例:数千の製品ページ)に最適です。

3. 深くネストされたSuspense境界はパフォーマンスにどのような影響を与えますか?

深くネストされたSuspense境界は、ストリーミングプロセスの複雑さを増し、よりきめ細かく、しかし頻繁なネットワークフラッシュにつながる可能性があります。Reactはこのために最適化されていますが、明確なパフォーマンス上の利点なしに過度にネストすると、オーバーヘッドが発生する可能性があります。一般的には、データフェッチが発生する論理的なコンテンツ境界でSuspenseを使用し、すべての要素をラップするのではなく、独立してデータをフェッチする、または遅くなる可能性のあるコンポーネントをラップすることを優先することをお勧めします。

4. PPRはgetServerSidePropsやgetStaticPropsを置き換えますか?

App RouterのコンテキストにおけるPPRは、getServerSidePropsとgetStaticPropsの機能をサーバーコンポーネントに直接統合することで、それらの必要性を大幅に置き換えます。asyncサーバーコンポーネントはデータをフェッチでき、特定のサブツリーに対してgetServerSidePropsのように機能します。動的関数(cookies()、headers()、searchParams)が使用されず、revalidateオプションが設定されていない場合、コンポーネントはgetStaticPropsのように動作します。PPRは、これらのサーバーコンポーネントの配信をストリーミングによって最適化します。完全に静的で動的コンテンツを必要としないページの場合、asyncフェッチやSuspenseのないシンプルなサーバーコンポーネントは、引き続き完全に静的です。

5. PPRとSuspenseでのエラー処理はどのように機能しますか?

Suspense境界内のエラーは、Error Boundaryコンポーネントによって捕捉できます。コンポーネントのサーバーサイドストリーミング中にエラーが発生した場合、Error Boundaryは、そのセグメントのストリームが完了するかエラーになったときに、クライアント上でフォールバックUIをレンダリングします。これにより、単一のコンポーネントの障害がページ全体をクラッシュさせるのを防ぎます。Suspenseでラップされたコンポーネントの周りに堅牢なエラー境界を実装することが重要です。

// components/ErrorBoundary.tsx
'use client';
import React, { Component, ErrorInfo, ReactNode } from 'react';

interface ErrorBoundaryProps {
  children: ReactNode;
  fallback: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
}

export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(_: Error): ErrorBoundaryState {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error("Uncaught error:", error, errorInfo);
    // You can log the error to an error reporting service here
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

// Usage:
// <ErrorBoundary fallback={<div>Something went wrong loading recommendations.</div>}>
//   <Suspense fallback={<Skeleton height="h-32" />}>
//     <ProductRecommendations productId={productId} />
//   </Suspense>
// </ErrorBoundary>
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