
AI 活用
公開 読了まで 約14分

重いエディタやグラフを lazy() で後から読むようにしたのに、Lighthouse の「使用していない JavaScript の削減」の項目が消えない。Next.js(App Router)で案件を回していると、一度は出会う場面だと思います。
先に結論です。原因の多くは、遅らせたつもりのモジュールを、別のファイルが普通の import で読んでいることです。Next.js 16.4 では、Turbopack の Bundle Analyzer(next analyze)でその経路をたどれます。分析の結果はスナップショットとして保存して前後を比べられ、AI エージェント用のスキル next-bundle-optimizer(試験的な機能)に調査を任せることもできます。この記事では、手元で実際に起こした失敗を例に、バンドルサイズを減らす「犯人」を見つけて、減ったことを確かめるまでを順に書きます。
確かめた環境は、Next.js 16.4.5、React 19.3.0、Node.js 22.23.4 です。
「Next.js Bundle Analyzer」で調べると、今も @next/bundle-analyzer の記事がたくさん出てきます。公式のドキュメントでは、これは webpack 用のプラグインで、Turbopack 用の Bundle Analyzer とは別の道具として分けて説明されています。Next.js 16 の初期設定のビルドは Turbopack なので、まずは組み込みの next analyze を使います。
| Turbopack 用(next analyze) | webpack 用(@next/bundle-analyzer) | |
|---|---|---|
| 使える条件 | ビルドが Turbopack。16.4 以降は next analyze、16.1〜16.3 は next experimental-analyze(試験的) | ビルドが webpack |
| 始め方 | npx next analyze | プラグインを next.config に入れて ANALYZE=true npm run build |
| 分かること | ルートごと、クライアントとサーバーごとの中身、モジュールが入った経路(import chain) | パッケージとその依存ごとの大きさ |
| 結果の保存と比較 | --output で保存。16.4 からスナップショットの比較と next analyze export | (公式のドキュメントに説明なし) |
16.1 で試験的な機能として出たときは next experimental-analyze という名前で、16.4 から next analyze になりました。古い記事の手順は、16.4 以降なら next analyze に読み替えてください。
Next.js の公式ブログは、lazy() で遅らせたモジュールでも、同じバンドルの中のどれか1つのモジュールが普通に import していれば、最初に読み込まれてしまうと説明しています。手元で、これをそのまま起こしてみました。
重いコンポーネントは、約237KBの文字列を持つ作例です。ラベルの定数も、このファイルから export しています。
// components/HeavyComponent.tsx(NG の状態)
'use client';
export const HEAVY_LABEL = 'heavy-editor';
const DICTIONARY = '...約237KBの文字列...';
export default function HeavyComponent() {
return <pre>{DICTIONARY.slice(0, 80)}</pre>;
}
これを、ボタンを押したときだけ読むようにします。next/dynamic を使う場合も、後から読むモジュールを普通の import で読む別のファイルがあれば、同じことが起こりえます。
// components/HeavyComponentLoader.tsx
'use client';
import { lazy, Suspense, useState } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
export function HeavyComponentLoader() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(true)}>エディタを開く</button>
{open && (
<Suspense fallback={<p>読み込み中…</p>}>
<HeavyComponent />
</Suspense>
)}
</div>
);
}
ここまでは正しく書けています。ところが同じページのツールバーが、ラベルの文字列1つだけ欲しくて、重いコンポーネントのファイルから import していました。
// components/Toolbar.tsx(NG)
'use client';
import { HEAVY_LABEL } from './HeavyComponent';
export function Toolbar() {
return <nav aria-label={HEAVY_LABEL}>ツールバー</nav>;
}
next build の後、ビルドでできた .next/server/app/index.html の <script> を数えると、重いコンポーネントの入ったチャンク(約237KB)が最初に読み込まれる側に入っていました。定数を別のファイルに分けると、このチャンクは「ボタンを押したときに読む」側へ移りました。
// components/labels.ts(OK)
export const HEAVY_LABEL = 'heavy-editor';
// components/Toolbar.tsx(OK)
'use client';
import { HEAVY_LABEL } from './labels';

最初に読み込まれる JS の合計は、手元の作例で約807KBから約570KBに減りました(圧縮前のファイルの大きさ。差はほぼ重いチャンク1つ分です。ビルドする場所によって数十 B ほど変わります)。大きさは作例のものなので、数字そのものより「1行の import で、遅らせたはずのものが丸ごと戻ってくる」ことを覚えておいてください。
確認に使ったのは、HTML の <script> を大きさつきで並べる短いスクリプトです。ビルドしたページで、最初に読む JS を手早く見たいときに使えます。
# check-initial-js.py(Python 3)
# 使い方: python3 check-initial-js.py .next/server/app/index.html
import os, re, sys
html_path = sys.argv[1]
next_dir = html_path.split('/server/')[0]
html = open(html_path, encoding='utf-8').read()
for src in sorted(set(re.findall(r'/_next/static/chunks/[\w\-.]+\.js', html))):
path = os.path.join(next_dir, src.replace('/_next/', ''))
print(f"{os.path.getsize(path):>8,} B {src}")
このやり方で見られるのは、ビルドのときに HTML を作るページ(静的なページ)だけです。リクエストのたびに描くページは、ブラウザの開発者ツールのネットワークの欄か、次の Bundle Analyzer で確かめます。
実際の案件では、どのファイルが犯人かは分かりません。そこで Bundle Analyzer を使います。
# 画面で見る(ブラウザで開く)
npx next analyze
# 画面を開かずに結果を保存する。名前を付けたスナップショットにしておく
npx next analyze --output --snapshot before
# 保存したスナップショットを JSON Lines で書き出す(16.4 以降)
npx next analyze export --snapshot before > bundle-graph.jsonl
16.4 の画面には、大きいクライアントのルートの一覧、大きい順に並べられる表、スナップショットどうしの比較、初回の描画に必要なモジュールだけに絞る表示が加わりました。まず一覧でいちばん重いルートを開き、表で大きいモジュールを見つけ、クリックして import chain(そのモジュールがどこから読まれているか)を見る、という順番が分かりやすいです。
書き出した JSONL は、自分のスクリプトでも調べられます。手元の 16.4.5 の出力(schema_version は 1)では、モジュールごとの行に、普通の import(sync)と後から読む import()(async)の行き先が分かれて入っていました。これを使うと「このファイルを普通に読んでいるのは誰か」が一覧にできます。
// who-imports.mjs(Node.js 22)
// 使い方: node who-imports.mjs bundle-graph.jsonl HeavyComponent.tsx
import { readFileSync } from 'node:fs';
const [file, target] = process.argv.slice(2);
const records = readFileSync(file, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
for (const r of records) {
if (r.type !== 'module' || !r.ident.includes('[app-client]') || r.path.includes(target)) continue;
const hit = (r.dependencies?.sync ?? []).filter((d) => d.includes(target));
if (hit.length) console.log(`${r.path}\n -> ${hit.join('\n -> ')}`);
}
先ほどの NG の状態では [project]/components/Toolbar.tsx が出て、直した後は何も出なくなりました。ただし、この書き出しの形は公式に決まった仕様として説明されているものではないので、Next.js を上げたら出力の形を見直してください。
経路をたどる作業は、ルートやモジュールが多いほど人の手では追いきれません。16.4 では、この調査を AI エージェントに任せるスキル next-bundle-optimizer が、試験的な機能として公開されました。Turbopack のモジュールのつながりを使うので、Turbopack でビルドすることが前提です。
npx skills add vercel/next.js --skill next-bundle-optimizer
入れたら、エージェントで /next-bundle-optimizer を実行します。動き方の要点は次のとおりです。
/next-bundle-optimizer fix。修正の後は分析をやり直し、スナップショットを比べて効果を測ります。import で戻ってくるモジュールのほか、Server Component に移せる処理、大きな依存の重複したバージョン、tree shaking(使わないコードを外す処理)が効かずに入っているコード。next analyze --output をサンドボックスの外で実行するように、とあります。エージェントの実行環境の制限で止まるときは、ここを疑ってください。名前のよく似た、コミュニティ製の nextjs-bundle-optimizer という別のスキルもあります。入れるときは、配布元が vercel/next.js であることを確かめてください。

はじめての案件では、まず監査だけを頼み、報告を読んでから直すものを選ぶと安心です。指示文は、たとえば次のようにします。
/next-bundle-optimizer
トップページ(/)の初回読み込みを調べて、報告だけしてください。コードは変えないでください。
候補ごとに、遅らせられる大きさ、原因の import の経路、直し方、操作したときに困る点を書いてください。
Vercel は、v0 と vercel.com でこのスキルを使い、ページによって数KB〜数百KB、合わせて2MB近く(いずれも圧縮後)の JavaScript を減らしたり後回しにしたりしたと報告しています。そのときも、変更ごとにプルリクエストを作り、プレビュー環境で確かめてから出したそうです。手元の案件でも、次の順で確かめると安全です。
--snapshot before と after を残し、画面の比較で減り方を見ます。next/dynamic で作ったチャンクが Server Component の描画で最初の読み込みに入ることがあります。実際に最初に読まれるものは、アプリを動かして確かめてください。@next/bundle-analyzer ではなく組み込みの Bundle Analyzer を使う(16.4 以降は next analyze、16.1〜16.3 は next experimental-analyze)。lazy() で遅らせても、どれか1つのファイルが普通に import すると最初に読まれる。定数や型は軽いファイルに分ける。next analyze export で自分のスクリプトでも調べられる。next-bundle-optimizer(試験的)は初期設定が監査だけ。直すのは1つずつ、前後を比べ、操作して確かめる。MODS BASE では、Next.js のサイトの表示の速さの見直しや、制作の協業のご相談をお受けしています。気になる点があれば、お気軽にお問い合わせください。
MODS BASE
MODS BASEは、長野県上伊那郡辰野町を拠点に、WEB 制作、WEB 集客の運用代行、UI/UX デザイン・プロダクト開発、ブランディング・グラフィック、AI 導入支援を手がけるデザイン・開発スタジオです。全国のお客様にオンラインで対応しています。
WEB 制作
何を伝え、どう動いてもらうか。UX の設計から考えるホームページ制作。
( Related )