ECサイトの商品ページにStreaming SSRを入れてFCPが4割縮んだ話
Next.js 15のStreaming SSRをECサイトの商品詳細ページに導入し、Suspense境界の設計でFCPを約4割改善した実装と計測の記録
受託で運用しているECサイトの商品詳細ページ、Lighthouseを回すたびにFCPが1.8秒前後をうろうろしていた。商品画像・スペック表・在庫情報・レビュー・レコメンド——全部サーバーで揃えてから一括レンダリングしていたので、在庫APIが遅い日はページ全体が道連れになる。典型的な「一番遅いfetchに引きずられる」やつだった。
Next.js 15のApp RouterにはReactのStreaming SSRが組み込まれている。ページを一枚のHTMLとして一気に返すのではなく、準備できた部分からチャンクで送り始める仕組み。これを商品詳細ページに適用したところ、FCPが体感で4割ほど縮んだ。その過程で踏んだ判断と失敗を書いておく。
改善前の構造と問題
もとのページコンポーネントはこうなっていた(簡略化)。
// app/products/[id]/page.tsx(改善前)
export default async function ProductPage({ params }) {
const { id } = await params
const product = await getProduct(id) // ~200ms
const stock = await getStock(id) // ~400ms(外部API)
const reviews = await getReviews(id) // ~600ms
const recommendations = await getRecommendations(id) // ~300ms
return (
<div>
<ProductHeader product={product} />
<StockBadge stock={stock} />
<ProductSpecs product={product} />
<ReviewList reviews={reviews} />
<Recommendations items={recommendations} />
</div>
)
}
awaitが直列に並んでいるので、全部解決するまでHTMLは1バイトも返らない。合計で1.5秒ほどサーバーで待たされ、TTFBがそのまま膨らむ。FCPは手元の計測で1.8秒前後。モバイル回線だと2秒を超えることもあった。
Suspense境界をどこに引いたか
Streaming SSRの効果は、Suspense境界の引き方でほぼ決まる。ページ全体を1つの<Suspense>で囲んでも意味がない——結局全部揃うまでフォールバックが出続けるだけ。
考え方はシンプルで、「データソースが独立しているコンポーネントごとに分ける」。商品詳細ページの場合、こう整理した。
| セクション | データソース | 応答時間の目安 | Suspense境界 |
|---|---|---|---|
| 商品名・画像・価格 | 商品DB | 速い(200ms前後) | 境界の外(静的シェル) |
| 在庫状態 | 外部在庫API | 遅い(400ms前後) | 独立境界 |
| スペック表 | 商品DB(同上) | 速い | 境界の外 |
| レビュー一覧 | レビューDB | やや遅い(600ms前後) | 独立境界 |
| おすすめ商品 | レコメンドAPI | 中程度(300ms前後) | 独立境界 |
商品名・画像・価格はECの商品ページで最も重要な要素であり、これがFCPとLCPを決める。ここをSuspense境界の外に置いて静的シェルとして即座に送る。在庫・レビュー・レコメンドは遅れてストリーミングされても体験上の問題はない。
改善後のコード
// app/products/[id]/page.tsx(改善後)
import { Suspense } from 'react'
export default async function ProductPage({ params }) {
const { id } = await params
const product = await getProduct(id) // これだけawait
return (
<div>
<ProductHeader product={product} />
<ProductSpecs product={product} />
<Suspense fallback={<StockSkeleton />}>
<StockBadge productId={id} />
</Suspense>
<Suspense fallback={<ReviewSkeleton />}>
<ReviewList productId={id} />
</Suspense>
<Suspense fallback={<RecommendationSkeleton />}>
<Recommendations productId={id} />
</Suspense>
</div>
)
}
変更点は3つ。
getProductだけページレベルでawaitする。これは200ms前後で返るので、静的シェルの遅延は最小限- 在庫・レビュー・レコメンドは各コンポーネント内部でfetchし、それぞれ独立した
<Suspense>で囲む - 各フォールバックにスケルトンUIを置く。サイズを本番のコンテンツと揃えるのが重要——ここが雑だとCLSが跳ねる
各コンポーネントの中身はこんな感じ。
// components/StockBadge.tsx
export async function StockBadge({ productId }: { productId: string }) {
const stock = await getStock(productId) // このawaitでSuspendする
return (
<div className="flex items-center gap-2">
<span className={stock.available ? 'text-green-600' : 'text-red-600'}>
{stock.available ? '在庫あり' : '在庫なし'}
</span>
{stock.available && <span>残り{stock.quantity}点</span>}
</div>
)
}
Server Componentなので'use client'は不要。awaitでサスペンドし、解決したらReactがそのチャンクをストリーミングしてフォールバックと差し替える。
計測結果
改善前後でLighthouseを複数回回した結果(デスクトップ、ローカル開発サーバーでの計測)。
| 指標 | 改善前 | 改善後 |
|---|---|---|
| FCP | 1.8秒前後 | 1.0〜1.1秒あたり |
| TTFB | 1.5秒前後 | 0.3秒前後 |
| LCP | 2.1秒前後 | 1.2秒前後 |
FCPがざっくり4割縮んだ。TTFBの改善が一番大きく、「全fetchを待つ1.5秒」が「商品データだけ待つ0.3秒」に変わったのが効いている。LCPも改善したのは、商品画像がSuspense境界の外にあるので最初のチャンクに含まれるため。
ただし、これはローカルでの計測であり、本番のCDNやネットワーク状況では数値が変わる。実際の本番ではCloudflare経由で配信していて、体感的にも明らかに速くなったが、正確な数値はフィールドデータを見ないと何とも言えない。
踏んだ落とし穴
スケルトンのサイズ不一致でCLSが悪化
最初、レビューセクションのスケルトンを適当な高さで作ったら、実際のレビューが展開されたときにページ全体がガクッとずれた。CLSが0.15を超えてPageSpeed Insightsに怒られる始末。
対策は単純で、スケルトンにmin-heightを設定して実コンテンツとサイズを揃えるだけ。レビューのように件数で高さが変わるものは、平均的な高さをmin-heightで確保しつつ、多少のズレは許容する設計にした。
function ReviewSkeleton() {
return (
<div className="min-h-[320px] animate-pulse space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-24 bg-gray-200 rounded" />
))}
</div>
)
}
paramsのawaitがシェル全体をブロック
Next.js 15ではparamsがPromiseになった。最初、ページコンポーネントの先頭でconst { id } = await paramsしてからgetProduct(id)を呼んでいたが、これ自体は問題ない。問題はparamsのawaitとgetProductのawaitが直列になることで、静的シェルの送出がわずかに遅れる点。
より良いパターンは、paramsの解決が必要なコンポーネントだけをSuspense内に入れ、パラメータに依存しないナビゲーションやレイアウトは静的シェルとして先に送ること。今回の商品ページでは商品名の表示にidが必須なので大きな差にはならなかったが、カテゴリ一覧のような「パラメータを遅延解決できるページ」では効果がある。
Nginxのバッファリングでストリーミングが無効化
ステージング環境にデプロイしたら、ローカルでは効いていたストリーミングがまったく効かない。原因はNginxのレスポンスバッファリング。デフォルトでNginxはプロキシ先からのレスポンスを全部バッファしてからクライアントに返す。
next.config.jsでヘッダーを追加して解決した。
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Accel-Buffering', value: 'no' },
],
},
]
},
}
これは公式ドキュメントにも書いてある話だが、意外と見落としやすい。CDNやロードバランサーでも同じことが起きうるので、ストリーミングを導入したら必ずインフラ側の設定を確認したほうがいい。
loading.jsではなくSuspenseを直接使う理由
Next.jsにはloading.js(loading.tsx)というファイル規約がある。ページと同じディレクトリに置くだけで自動的にSuspense境界が設定されるので手軽。ただし、これはページ全体を1つの境界で囲むので、「商品名は即座に見せたいがレビューは遅れてもいい」という粒度の制御ができない。
ECの商品ページのように「ファーストビューに必要な情報」と「スクロールしないと見えない情報」が混在する場合、手動で<Suspense>を配置するほうが圧倒的に効果が出る。loading.jsはダッシュボードのような「データが揃うまで何も見せられない」ページ向き。
Streaming SSRが向かないケース
万能ではない。SEO上、ストリーミング開始後はHTTPステータスコードが変更できない。最初のチャンクを送った時点で200 OKが確定するため、データ取得の途中で404を返したい場合は、Suspense境界の前で存在チェックする必要がある。
export default async function ProductPage({ params }) {
const { id } = await params
const exists = await checkProductExists(id)
if (!exists) notFound() // ストリーミング開始前なので正しい404が返る
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductContent id={id} />
</Suspense>
)
}
また、すべてのfetchが100ms以下で返るような軽量ページでは、Suspense境界を増やしてもオーバーヘッドが増えるだけで実測上の差はほぼない。遅いデータソースが混在しているページでこそ効く手法。
商品ページのような「速いデータと遅いデータが同居する画面」は、Streaming SSRの恩恵が最も大きいパターンだと思う。導入自体は<Suspense>で囲むだけなので、まずは一番遅いAPIを呼んでいるセクションから試してみるのがいい。