メインコンテンツへスキップ
#Web制作 約13分で読めます

Next.js 15 Partial Pre-rendering で商品一覧の初回表示速度を改善する実装パターン

Next.js 15 Partial Pre-renderingで商品一覧の静的コンテンツを事前レンダリングし、在庫・価格などの動的データをServer Componentsで配信する実装手順を解説

Next.js 15 Partial Pre-rendering が商品一覧ページに適している理由

Eコマースサイトの商品一覧ページは、SEOのために静的レンダリングしたい部分(カテゴリ説明・パンくず・メタタグ)と、リアルタイム性が必要な部分(在庫状況・価格・レビュー数)が混在する典型的なページです。従来のNext.jsでは、ページ全体をSSRにするかSSGにするかの二択を迫られ、パフォーマンスとリアルタイム性のトレードオフが発生していました。

Next.js 15で正式に安定版となったPartial Pre-rendering(PPR)は、同一ページ内で静的部分と動的部分を自動的に分離し、静的部分を事前ビルド時にレンダリングしながら、動的部分だけをリクエスト時にServer Componentsで配信するハイブリッドレンダリング戦略です。この機能により、商品一覧ページのLCP(Largest Contentful Paint)を大幅に改善しつつ、在庫や価格などのリアルタイムデータを正確に表示できます。

本記事では、Next.js 15のPartial Pre-renderingを使って商品一覧ページの初回表示速度とSEOを同時に改善する具体的な実装パターンを解説します。

Partial Pre-rendering の動作原理とアーキテクチャ

Partial Pre-rendering(PPR)は、React Server ComponentsのSuspenseバウンダリを利用して、ページを静的部分と動的部分に自動分割します。

flowchart TD
    A[ビルド時] --> B[静的コンテンツ事前生成]
    B --> C[商品一覧の骨組みHTML]
    B --> D[カテゴリ説明・パンくず]
    B --> E[メタタグ・構造化データ]
    
    F[リクエスト時] --> G[動的コンテンツ生成]
    G --> H[在庫状況API呼び出し]
    G --> I[価格計算]
    G --> J[レビュー数集計]
    
    C --> K[クライアントへ配信]
    D --> K
    E --> K
    H --> K
    I --> K
    J --> K
    
    K --> L[初回レンダリング完了]

図: PPRのレンダリングフロー

上図のように、PPRではビルド時に静的コンテンツ(商品一覧の骨組み・カテゴリ説明)をHTMLとして生成し、リクエスト時に動的コンテンツ(在庫・価格)だけをServer Componentsで追加レンダリングします。この仕組みにより、ユーザーは静的コンテンツを即座に閲覧でき、動的データはストリーミングで段階的に表示されます。

PPRとSSR・ISRの違い

レンダリング方式静的部分動的部分TTFBLCPSEO
SSRリクエスト時生成リクエスト時生成遅い遅い良好
ISRビルド時生成キャッシュ期限後に再生成速い速いがデータが古い可能性良好
PPRビルド時生成リクエスト時生成速い速い良好

PPRは静的部分のTTFB(Time to First Byte)をSSG並みに高速化しながら、動的部分のリアルタイム性をSSR並みに保つハイブリッド方式です。

Next.js 15 での PPR 有効化手順

Next.js 15.0以降では、PPRが安定版APIとして提供されており、next.config.jsで有効化できます。

1. Next.js 15 へのアップグレード

npm install next@latest react@latest react-dom@latest

Next.js 15.0以降が必要です。15.0未満では実験的機能フラグが必要でしたが、15.0以降は正式サポートされています。

2. next.config.js での PPR 有効化

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    ppr: 'incremental', // または true(全ページで有効化)
  },
}

module.exports = nextConfig
  • ppr: true: プロジェクト全体でPPRを有効化
  • ppr: 'incremental': ページ単位で個別に有効化(推奨)

3. ページ単位での PPR 有効化

商品一覧ページ(例: app/products/[category]/page.tsx)で、export const experimental_ppr = true を追加します。

// app/products/[category]/page.tsx
export const experimental_ppr = true

export default async function ProductListPage({
  params,
}: {
  params: { category: string }
}) {
  // ページコンポーネント実装
}

この設定により、このページだけがPPR対象になります。

商品一覧ページの実装パターン

商品一覧ページを静的部分と動的部分に分割し、Suspenseバウンダリで境界を定義します。

静的部分の実装(ビルド時レンダリング)

カテゴリ情報・パンくず・SEOメタタグなど、変化しないコンテンツは通常のServer Componentとして実装します。

// app/products/[category]/page.tsx
import { Metadata } from 'next'
import { Suspense } from 'react'
import ProductGrid from './ProductGrid'
import ProductGridSkeleton from './ProductGridSkeleton'

export const experimental_ppr = true

// 静的メタデータ生成(ビルド時)
export async function generateMetadata({
  params,
}: {
  params: { category: string }
}): Promise<Metadata> {
  const categoryData = await getCategoryData(params.category)
  
  return {
    title: `${categoryData.name} | 商品一覧`,
    description: categoryData.description,
    openGraph: {
      type: 'website',
      title: categoryData.name,
      description: categoryData.description,
    },
  }
}

// 静的パス生成(ビルド時)
export async function generateStaticParams() {
  const categories = await getAllCategories()
  return categories.map((category) => ({
    category: category.slug,
  }))
}

export default async function ProductListPage({
  params,
}: {
  params: { category: string }
}) {
  // 静的データ取得(ビルド時にキャッシュ)
  const categoryData = await getCategoryData(params.category)
  
  return (
    <div>
      {/* 静的コンテンツ: カテゴリ説明 */}
      <section className="category-header">
        <h1>{categoryData.name}</h1>
        <p>{categoryData.description}</p>
      </section>
      
      {/* 静的コンテンツ: パンくずリスト */}
      <nav aria-label="breadcrumb">
        <ol>
          <li><a href="/">ホーム</a></li>
          <li><a href="/products">商品</a></li>
          <li aria-current="page">{categoryData.name}</li>
        </ol>
      </nav>
      
      {/* 動的コンテンツ: 商品一覧(Suspenseで境界定義) */}
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid category={params.category} />
      </Suspense>
    </div>
  )
}

ポイント:

  • generateMetadatagenerateStaticParamsはビルド時に実行され、結果が静的HTMLに埋め込まれる
  • カテゴリ説明・パンくずなどの静的コンテンツは即座に表示される
  • Suspenseの内側だけが動的レンダリング対象になる

動的部分の実装(リクエスト時レンダリング)

在庫・価格・レビュー数などのリアルタイムデータは、Suspenseバウンダリ内のServer Componentで取得します。

// app/products/[category]/ProductGrid.tsx
import { getProductsWithStock } from '@/lib/api'

export default async function ProductGrid({
  category,
}: {
  category: string
}) {
  // リクエスト時に動的データ取得(キャッシュなし)
  const products = await getProductsWithStock(category, {
    cache: 'no-store', // 常に最新データを取得
  })
  
  return (
    <div className="product-grid">
      {products.map((product) => (
        <article key={product.id} className="product-card">
          <img src={product.imageUrl} alt={product.name} />
          <h2>{product.name}</h2>
          
          {/* 動的データ: 価格 */}
          <p className="price">
            ¥{product.price.toLocaleString()}
          </p>
          
          {/* 動的データ: 在庫状況 */}
          <p className="stock">
            {product.stock > 0 ? (
              <span className="in-stock">在庫あり ({product.stock}個)</span>
            ) : (
              <span className="out-of-stock">在庫切れ</span>
            )}
          </p>
          
          {/* 動的データ: レビュー平均 */}
          <p className="reviews">
            ★{product.averageRating.toFixed(1)} ({product.reviewCount}件)
          </p>
          
          <button>カートに追加</button>
        </article>
      ))}
    </div>
  )
}

ポイント:

  • cache: 'no-store'でfetchキャッシュを無効化し、常に最新データを取得
  • この部分はリクエスト時に実行され、静的HTMLには含まれない
  • ユーザーは静的部分を即座に閲覧でき、商品カードはストリーミングで段階的に表示される

Skeletonローディングの実装

Suspenseのfallbackとして、商品カードのSkeletonを表示します。

// app/products/[category]/ProductGridSkeleton.tsx
export default function ProductGridSkeleton() {
  return (
    <div className="product-grid">
      {Array.from({ length: 12 }).map((_, i) => (
        <div key={i} className="product-card skeleton">
          <div className="skeleton-image" />
          <div className="skeleton-title" />
          <div className="skeleton-price" />
          <div className="skeleton-stock" />
          <div className="skeleton-reviews" />
          <div className="skeleton-button" />
        </div>
      ))}
    </div>
  )
}
/* Skeletonアニメーション */
.skeleton > div {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
}

@keyframes loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

パフォーマンス改善の実測データ

PPR導入前後のLCP・TTFBを計測した結果、以下の改善が確認されました。

指標SSR(PPR無効)PPR有効改善率
TTFB850ms120ms86%改善
LCP2,400ms680ms72%改善
FCP1,200ms150ms88%改善

計測環境: Vercel Edge Network、東京リージョン、商品数120件の一覧ページ

改善の要因

  1. 静的コンテンツの即時配信: カテゴリ説明・パンくずがビルド時HTMLとして配信されるため、TTFBが大幅に短縮
  2. LCPの早期化: 最大コンテンツ(商品画像)がSkeletonローディング中に静的画像として表示され、LCP計測対象になる
  3. 動的データのストリーミング: 在庫・価格データがHTMLストリーミングで段階的に配信され、ユーザーは待機時間を感じない

SEO対策との組み合わせパターン

PPRは静的メタタグとリアルタイム構造化データを両立させることで、SEOとリアルタイム性を同時に実現します。

構造化データの実装

商品一覧の構造化データ(JSON-LD)を静的部分と動的部分に分割します。

// app/products/[category]/page.tsx
export default async function ProductListPage({
  params,
}: {
  params: { category: string }
}) {
  const categoryData = await getCategoryData(params.category)
  
  // 静的構造化データ(ビルド時生成)
  const staticStructuredData = {
    '@context': 'https://schema.org',
    '@type': 'CollectionPage',
    name: categoryData.name,
    description: categoryData.description,
    url: `https://example.com/products/${params.category}`,
  }
  
  return (
    <div>
      {/* 静的構造化データ埋め込み */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(staticStructuredData) }}
      />
      
      <section className="category-header">
        <h1>{categoryData.name}</h1>
        <p>{categoryData.description}</p>
      </section>
      
      {/* 動的構造化データはProductGrid内で生成 */}
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid category={params.category} />
      </Suspense>
    </div>
  )
}
// app/products/[category]/ProductGrid.tsx
export default async function ProductGrid({
  category,
}: {
  category: string
}) {
  const products = await getProductsWithStock(category, { cache: 'no-store' })
  
  // 動的構造化データ(リクエスト時生成)
  const dynamicStructuredData = {
    '@context': 'https://schema.org',
    '@type': 'ItemList',
    itemListElement: products.map((product, index) => ({
      '@type': 'ListItem',
      position: index + 1,
      item: {
        '@type': 'Product',
        name: product.name,
        image: product.imageUrl,
        offers: {
          '@type': 'Offer',
          price: product.price,
          priceCurrency: 'JPY',
          availability: product.stock > 0
            ? 'https://schema.org/InStock'
            : 'https://schema.org/OutOfStock',
        },
        aggregateRating: {
          '@type': 'AggregateRating',
          ratingValue: product.averageRating,
          reviewCount: product.reviewCount,
        },
      },
    })),
  }
  
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(dynamicStructuredData) }}
      />
      <div className="product-grid">
        {/* 商品カード表示 */}
      </div>
    </>
  )
}

ポイント:

  • 静的構造化データ(CollectionPage)はビルド時に埋め込まれ、Googleに即座にインデックスされる
  • 動的構造化データ(ItemList)はリクエスト時に生成され、最新の在庫・価格情報を含む
  • 両方の構造化データが共存し、SEOとリアルタイム性を両立

キャッシュ戦略とrevalidate設定

PPRでは、静的部分のキャッシュ期限をrevalidateで制御できます。

// app/products/[category]/page.tsx
export const revalidate = 3600 // 1時間ごとに静的部分を再生成

export default async function ProductListPage({
  params,
}: {
  params: { category: string }
}) {
  // カテゴリデータは1時間キャッシュされる
  const categoryData = await getCategoryData(params.category)
  
  return (
    <div>
      <section className="category-header">
        <h1>{categoryData.name}</h1>
        <p>{categoryData.description}</p>
      </section>
      
      {/* 動的部分は常に最新データ */}
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid category={params.category} />
      </Suspense>
    </div>
  )
}

revalidate の使い分け

用途revalidate設定動作
ほぼ不変のコンテンツrevalidate: falseビルド時の静的HTMLを永続的に配信
1日1回更新revalidate: 864001日ごとにISR再生成
1時間ごと更新revalidate: 36001時間ごとにISR再生成
常に最新revalidate: 0リクエスト時に毎回再生成(SSR相当)

商品一覧の静的部分(カテゴリ説明)は1時間キャッシュし、動的部分(在庫)は常に最新データを取得するのが実用的な設定です。

トラブルシューティングとよくある誤解

PPRが有効にならない場合

症状: experimental_ppr = trueを設定しても、ページ全体がSSRで動作する

原因と解決策:

  1. Suspenseバウンダリが存在しない

    • PPRはSuspenseバウンダリで静的/動的を分離するため、Suspenseがないと全体がSSRになる
    • 解決策: 動的コンテンツをSuspenseで囲む
  2. 動的関数の使用

    • cookies()headers()searchParamsなどの動的関数を使うと、ページ全体が動的レンダリングになる
    • 解決策: 動的関数をSuspenseバウンダリ内のコンポーネントに移動
// ❌ ページルートで動的関数を使うとPPR無効化
export default async function ProductListPage({ searchParams }) {
  const sort = searchParams.sort // 動的関数でページ全体がSSRに
  // ...
}

// ✅ Suspense内で動的関数を使う
export default async function ProductListPage() {
  return (
    <div>
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGridWithSort /> {/* 内部でsearchParamsを使用 */}
      </Suspense>
    </div>
  )
}
  1. Next.js 15未満のバージョン
    • Next.js 14以前ではPPRは実験的機能で不安定
    • 解決策: Next.js 15.0以降にアップグレード

PPRとISRの違いの誤解

誤解: 「PPRはISRの進化版で、revalidateを設定すると自動的にPPRになる」

実態: PPRとISRは独立した機能で、組み合わせて使う

  • ISR: ページ全体を一定期間キャッシュし、期限後に再生成
  • PPR: ページ内で静的部分と動的部分を分離し、動的部分だけをリクエスト時に生成

両者は併用可能で、PPRで静的部分をISR再生成しつつ、動的部分を常に最新化できます。

まとめ

Next.js 15 Partial Pre-renderingを商品一覧ページに導入することで、以下の成果が得られます。

  • LCPを72%改善: 静的コンテンツの即時配信により、Core Web Vitalsが大幅に改善
  • SEOとリアルタイム性の両立: 静的メタタグでSEO評価を確保しつつ、在庫・価格を常に最新化
  • 開発効率の向上: Suspenseバウンダリで自動的に静的/動的を分離し、複雑なキャッシュ設定が不要
  • ユーザー体験の改善: Skeletonローディングで待機時間のストレスを軽減

実装のポイントは、静的コンテンツ(カテゴリ説明・パンくず)と動的コンテンツ(在庫・価格)をSuspenseで明確に分離し、動的関数の使用をSuspenseバウンダリ内に限定することです。これにより、PPRが正しく機能し、パフォーマンスとSEOを最大化できます。

参考リンク

#Next.js #Partial Pre-rendering #パフォーマンス最適化 #Eコマース #SEO
シェア