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の違い
| レンダリング方式 | 静的部分 | 動的部分 | TTFB | LCP | SEO |
|---|---|---|---|---|---|
| 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>
)
}
ポイント:
generateMetadataとgenerateStaticParamsはビルド時に実行され、結果が静的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有効 | 改善率 |
|---|---|---|---|
| TTFB | 850ms | 120ms | 86%改善 |
| LCP | 2,400ms | 680ms | 72%改善 |
| FCP | 1,200ms | 150ms | 88%改善 |
計測環境: Vercel Edge Network、東京リージョン、商品数120件の一覧ページ
改善の要因
- 静的コンテンツの即時配信: カテゴリ説明・パンくずがビルド時HTMLとして配信されるため、TTFBが大幅に短縮
- LCPの早期化: 最大コンテンツ(商品画像)がSkeletonローディング中に静的画像として表示され、LCP計測対象になる
- 動的データのストリーミング: 在庫・価格データが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: 86400 | 1日ごとにISR再生成 |
| 1時間ごと更新 | revalidate: 3600 | 1時間ごとにISR再生成 |
| 常に最新 | revalidate: 0 | リクエスト時に毎回再生成(SSR相当) |
商品一覧の静的部分(カテゴリ説明)は1時間キャッシュし、動的部分(在庫)は常に最新データを取得するのが実用的な設定です。
トラブルシューティングとよくある誤解
PPRが有効にならない場合
症状: experimental_ppr = trueを設定しても、ページ全体がSSRで動作する
原因と解決策:
-
Suspenseバウンダリが存在しない
- PPRはSuspenseバウンダリで静的/動的を分離するため、Suspenseがないと全体がSSRになる
- 解決策: 動的コンテンツをSuspenseで囲む
-
動的関数の使用
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>
)
}
- 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を最大化できます。