概要
個人開発のバス釣り用語辞典サイト「BassTerms」(Next.js App Router、output: "export"によるフル静的サイト)で、ホームページのCore Web Vitals(特にLCP)が悪化していたので原因を調査・修正しました。
原因は2つで、どちらもoutput: "export"特有の落とし穴でした。
-
next/imageに頼っていたロゴ画像が、実は2048×2048・約4MBの無圧縮PNGのまま配信されていた - ホームページの一覧が499件を全件クライアントレンダリングしていて、DOM生成・レイアウトのコストが描画遅延の主因になっていた
この記事では、それぞれの原因の見つけ方と修正内容、Lighthouseの実測値をまとめます。
環境
- Next.js 16(App Router、
output: "export") -
images.unoptimized: true(静的エクスポートではnext/imageの自動最適化が使えないため必須設定) - Lighthouse(ローカルの
out/をserveしてモバイル・--throttling-method=simulateで計測)
原因1: next/imageはunoptimized: trueだと何もしてくれない
output: "export"ではnext/imageのビルド時最適化(リサイズ・再圧縮・WebP変換)が使えないため、next.configでimages.unoptimized: trueにする必要があります。これは公式にも案内されている設定ですが、「じゃあ画像の最適化は誰がやるのか」というと、それは開発者自身という点を見落としていました。
Lighthouseのnetwork-requests監査でリクエストをサイズ順に並べたところ、犯人が一目瞭然でした。
const items = report.audits['network-requests'].details.items;
items.sort((a, b) => (b.transferSize || 0) - (a.transferSize || 0));
// => 1位: /images/brand/logo.png 3,984 KB
// 2位: / 80 KB
// 3位: JSチャンク 70 KB
// ...
実際のファイルを確認すると、ヘッダーで28〜64pxでしか表示していないロゴが、2048×2048のフル解像度PNGのまま配信されていました。
$ file public/images/brand/logo.png
public/images/brand/logo.png: PNG image data, 2048 x 2048, 8-bit/color RGBA, non-interlaced
修正: sharpで表示サイズに合わせて事前変換する
Next.jsが依存に含んでいるsharpをそのまま使い、表示用のアイコンサイズ(128×128、退避に十分なRetina想定)・WebPに変換しました。
const sharp = require("sharp");
sharp("public/images/brand/logo.png")
.resize(128, 128)
.webp({ quality: 90 })
.toFile("public/images/brand/logo-icon.webp");
// => 4,079,639 bytes -> 9,152 bytes (-99.8%)
ヘッダー・404ページの参照先をこの新しいファイルに差し替え、OGP画像生成(next/ogのImageResponse、サーバー側処理でブラウザには配信されない)だけは高解像度が必要なので元のPNGを使い続けています。
効果(ロゴ修正)
| 指標 | 修正前 | 修正後 |
|---|---|---|
| 総転送量 | 10,938 KiB | 6,963 KiB(-36%) |
| TBT | 200ms | 50ms(-75%) |
| Speed Index | 11.6s | 8.0s(-31%) |
output: "export"の構成では、画像最適化サービス(Vercelのビルトイン最適化やCloudflare Imagesなど)を挟まない限り、「表示サイズに合わせて事前にリサイズ・圧縮しておく」のはビルドパイプライン側の責務になるという当たり前の事実を、数字で思い知らされました。
原因2: LCP要素はロゴではなく、499件目の描画コストだった
ロゴを直しても、LighthouseのLCP・FCPの絶対秒数はほぼ変化しませんでした。ここで役に立ったのがlcp-breakdown-insight監査です。
console.log(report.audits['lcp-breakdown-insight'].details.items[0]);
{
"type": "table",
"items": [
{ "subpart": "timeToFirstByte", "duration": 10.562 },
{ "subpart": "elementRenderDelay", "duration": 2299.992 }
]
}
LCP候補要素はロゴではなく、用語一覧カードの説明文テキストでした。elementRenderDelay(要素が描画可能になるまでの遅延)が2.3秒もあり、ここがボトルネックだと分かります。
原因はホームページの実装にありました。
// 修正前: filtered配列を無条件に全件map
{filtered.map(({ term }) => <TermCard key={term.slug} term={term} />)}
用語データが499件あり、検索・カテゴリ絞り込みをしていない初期状態でも全件を"use client"コンポーネントとしてレンダリングしていました。SSGなのでHTML自体は静的に生成されますが、HTMLの解析・レイアウト計算のコストはDOMノード数にそのまま比例します。実測でホームページのHTMLは1,694,432バイト、<div>要素は2,011個ありました。
修正: 初期表示を60件に制限し、「もっと見る」で追加表示する
検索・カテゴリ絞り込みは従来通り全499件を対象にしたいので、filtered(絞り込み結果)自体は変えず、表示件数だけをuseStateで制御する形にしました。
const INITIAL_VISIBLE_COUNT = 60;
const LOAD_MORE_STEP = 60;
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT);
// 検索語・カテゴリ・お気に入り絞り込みが変わったら表示件数をリセット
useEffect(() => {
setVisibleCount(INITIAL_VISIBLE_COUNT);
}, [normalizedQuery, categories, matchMode, favoritesOnly]);
const visibleTerms = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length;
{visibleTerms.map(({ term }) => <TermCard key={term.slug} term={term} />)}
{hasMore && (
<button onClick={() => setVisibleCount((c) => c + LOAD_MORE_STEP)}>
もっと見る
</button>
)}
絞り込み条件が変わったときに表示件数をリセットするuseEffectがポイントで、これがないと「タックルで絞り込んだのに、直前の『すべて』表示で押した『もっと見る』の件数を引き継いで表示が飛ぶ」という不自然な挙動になります。
効果
| 指標 | 修正前(ロゴ修正後基準) | 修正後 |
|---|---|---|
| ホームページHTML | 1,694,432 バイト | 436,778 バイト(-74%) |
<div>要素数 |
2,011 | 256 |
| Lighthouseパフォーマンススコア | 57 | 74 |
| FCP | 8.0s | 1.7s |
| Speed Index | 8.0s | 1.7s |
| elementRenderDelay | 2,300ms | 314ms |
ロゴ修正では動かなかったFCP・Speed Indexまで大きく改善し、こちらが実質的なボトルネックだったことが裏付けられました。
おまけ: Lighthouseの最上位largest-contentful-paintの値を鵜呑みにしない
今回、ロゴ修正の前後でLighthouseのlargest-contentful-paint監査の表示値(46.9 s)が完全に同じ値のまま変化しませんでした。総転送量やTBTは明確に改善しているのに、です。
さらに499件対応の修正後も、largest-contentful-paintは38.1 sという値を表示した一方、同じレポート内のlcp-breakdown-insightはTTFB 7.7ms+描画遅延314ms≒322msという内訳を示していて、両者が2桁以上ズレています。
このズレは、ローカル・CI等のサンドボックス化された実行環境でLighthouseを動かす際に起こりうる既知の測定アーティファクトです。最上位の絶対秒数だけを見て一喜一憂せず、lcp-breakdown-insightのような内訳や、TBT・Speed Index・総転送量といった相対比較しやすい指標を併用するのが安全だと分かりました。本番相当の実訪問者データ(Search ConsoleのCore Web Vitalsレポートなど、Chrome UX Reportベースの指標)で最終確認するのが確実です。
まとめ
-
output: "export"+images.unoptimized: trueの構成では、画像の最適化は完全に自己責任。sharpで表示サイズに合わせて事前変換しておく - 一覧系ページを作るときは「検索・フィルタは全件対象、初期描画だけ制限する」設計にしておくとDOMサイズを抑えられる
- Lighthouseの絶対的なLCP秒数がサンドボックス環境で信用できないことがある。
lcp-breakdown-insightで内訳を確認する癖をつけておくと安心
BassTermsはhttps://bassterms.com/で公開しています。よければ見てみてください。