32blog.comのビルドが遅くなってきた。記事数が増えるにつれてビルド時間が伸び、Vercelのデプロイに2分以上かかるようになった。
ローカルでの開発体験も悪化していた。コード変更のたびに next build を確認するのが億劫になるほど遅かった。バンドルサイズも気になりはじめ、Lighthouseのスコアに影響が出ていた。
この記事では、Next.jsのビルドを高速化してバンドルサイズを削減するための実践的な手法をまとめる。設定の詳細と、実際にどれくらい改善したかの数値も合わせて紹介する。
バンドルアナライザーで何が重いかを把握する
最適化は計測から始まる。まず何がバンドルサイズを食っているかを可視化する。
npm install --save-dev @next/bundle-analyzer
// next.config.ts
import withBundleAnalyzerInit from "@next/bundle-analyzer";
const withBundleAnalyzer = withBundleAnalyzerInit({
enabled: process.env.ANALYZE === "true",
});
const nextConfig = {
// 設定をここに書く
};
export default withBundleAnalyzer(nextConfig);
# アナライザーを起動してビルド
ANALYZE=true npm run build
ブラウザでインタラクティブなバンドルマップが開く。面積が大きいブロックほどバンドルサイズが大きい。ここで「こんなに重いの?」と思うものを優先的に最適化する。
よく見つかる重い依存関係は以下のようなものだ。
moment.js(3MB超え。date-fnsやdayjsで代替可能)lodash(ツリーシェイキングが効いていない場合)@mui/material(使っているコンポーネントだけimportする)- アイコンライブラリ(全体をimportしている)
@next/bundle-analyzer の代替: next-bundle-analyzer
より詳細な情報が欲しい場合は bundle-buddy や bundlephobia も使える。パッケージ単体のサイズを調べるなら bundlephobia.com に名前を入れると一発でわかる。
不要な依存関係をツリーシェイキングで削除する
バンドルサイズを下げる最も効果的な方法は、使っていないコードを含めないことだ。
lodashをnamed importに変える
// ❌ これはlodash全体をバンドルする(70KB超え)
import _ from "lodash";
const result = _.groupBy(items, "category");
// ✅ 使う関数だけimportする(数KB)
import groupBy from "lodash/groupBy";
const result = groupBy(items, "category");
または lodash-es を使うとESモジュール形式でツリーシェイキングが効く。
npm install lodash-es
npm install --save-dev @types/lodash-es
// lodash-esはツリーシェイキングが効く
import { groupBy, sortBy } from "lodash-es";
date-fnsへの移行
moment.js を使っているなら date-fns への移行を強くすすめる。
npm uninstall moment
npm install date-fns
// moment.js(ビルド後 70KB前後)
import moment from "moment";
const formatted = moment().format("YYYY/MM/DD");
// date-fns(使う関数のみバンドル、数KB)
import { format } from "date-fns";
import { ja } from "date-fns/locale";
const formatted = format(new Date(), "yyyy/MM/dd", { locale: ja });
アイコンライブラリの個別import
// ❌ 全アイコンをimportしてしまう(数MB)
import * as FaIcons from "react-icons/fa";
// ✅ 必要なアイコンだけnamed importする(ツリーシェイキング対応)
import { FaGithub, FaTwitter } from "react-icons/fa";
lucide-react はデフォルトでツリーシェイキングに対応しているので、通常の named import で問題ない。
// lucide-reactは named import でOK
import { Github, Twitter, ExternalLink } from "lucide-react";
画像最適化でページ読み込みを改善する
Next.jsの next/image は使っているだけで最適化される部分が多いが、設定次第でさらに効果を高められる。
next/imageの基本設定
// next.config.ts
const nextConfig = {
images: {
// 許可する外部ドメインを指定
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
// WebPへの自動変換
formats: ["image/avif", "image/webp"],
// デバイスサイズに合わせたimgの生成
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
},
};
正しいpriority設定
LCP(Largest Contentful Paint)に影響するファーストビューの画像には priority を付ける。
// app/page.tsx
import Image from "next/image";
export default function HeroSection() {
return (
<section>
{/* LCPになる画像にはpriority必須 */}
<Image
src="/hero.webp"
alt="ヒーロー画像"
width={1200}
height={600}
priority // preloadされてLCPが改善する
/>
</section>
);
}
// スクロールしないと見えない画像はpriority不要
<Image
src="/article-thumbnail.webp"
alt="記事のサムネイル"
width={400}
height={300}
// lazy loading がデフォルトで効く
/>
静的画像のimport
ローカルの静的画像は import すると自動でwidth/heightが推測される。
import heroImage from "@/public/hero.webp";
import Image from "next/image";
export function Hero() {
return (
// width/heightの指定が不要(importした画像から自動取得)
<Image src={heroImage} alt="ヒーロー" priority />
);
}
Turbopackでローカル開発を高速化する
Next.js 15からTurbopackが安定版になった。開発サーバーの起動とHMRが劇的に速くなる。
// package.json
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start"
}
}
next build はまだWebpack(またはTurbopack experimental)が使われるが、next dev だけでもTurbopackを使うと体感が大きく変わる。
Turbopackへの移行でよく発生する問題は以下の通り。
# Turbopackキャッシュが壊れた場合
rm -rf .next
npm run dev
// webpack固有の設定(Turbopack非対応)を使っている場合
// next.config.ts で条件分岐する
const nextConfig = {
webpack: (config, { isServer }) => {
// Turbopack使用時はこのブロックが実行されない
if (!isServer) {
config.resolve.fallback.fs = false;
}
return config;
},
};
静的生成とキャッシュ戦略で表示速度を上げる
Next.jsのレンダリング戦略を適切に選ぶことで、ページの初期表示速度が変わる。
PPR(Partial Prerendering)を活用する(実験的)
// next.config.ts(Next.js 15以降)
const nextConfig = {
experimental: {
ppr: true,
},
};
// app/blog/page.tsx
import { Suspense } from "react";
// 静的な部分(記事リスト)とダイナミックな部分(ユーザー情報)を混在させる
export default function BlogPage() {
return (
<main>
{/* 静的な部分は即座に表示 */}
<h1>記事一覧</h1>
<StaticArticleList />
{/* ダイナミックな部分はストリーミング */}
<Suspense fallback={<div>読み込み中...</div>}>
<DynamicUserRecommendations />
</Suspense>
</main>
);
}
fetchのキャッシュを明示的に管理する
// lib/api.ts
// 長期間変わらないデータ(revalidate: 3600 = 1時間)
export async function getCategories() {
const res = await fetch("https://api.example.com/categories", {
next: { revalidate: 3600, tags: ["categories"] },
});
return res.json();
}
// 頻繁に変わるデータ(revalidate: 60 = 1分)
export async function getLatestPosts() {
const res = await fetch("https://api.example.com/posts?limit=10", {
next: { revalidate: 60, tags: ["posts"] },
});
return res.json();
}
// リアルタイムデータ(キャッシュなし)
export async function getLivePrice() {
const res = await fetch("https://api.example.com/price", {
cache: "no-store",
});
return res.json();
}
generateStaticParamsでビルド時に生成する
動的ルートを持つページは、ビルド時に静的HTMLを生成しておくと表示が速い。
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
// 未生成のパスはオンデマンドで生成(デフォルトのdynamicParams: true)
export const dynamicParams = true;
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{/* 記事の内容 */}</article>;
}
ビルド時間を短縮するための設定
型チェックを並列化する
next build のデフォルトでは型チェックが含まれる。CIではビルドと型チェックを分離すると並列実行できる。
# package.json のscripts
{
"scripts": {
"build": "next build",
"type-check": "tsc --noEmit",
"ci": "npm run type-check && npm run build"
}
}
GitHub Actionsでは並列ジョブで実行する。
# .github/workflows/ci.yml
jobs:
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run type-check
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run build
Next.jsのキャッシュを活用する
# .github/workflows/ci.yml(ビルドキャッシュ付き)
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: |
.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-v1
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-
.next/cache をCIでキャッシュすると、コードの変更が少ない場合のビルド時間が大幅に短縮される。32blog.comでは2分30秒のビルドが50秒に短縮された。
まとめ
Next.jsのビルド最適化でやることをまとめる。
| 施策 | 効果 | 難易度 |
|---|---|---|
| バンドルアナライザーで計測 | 問題の可視化 | 低 |
| moment.js → date-fns | バンドル -70KB以上 | 中 |
| lodashのnamed import | バンドル -50KB以上 | 低 |
| next/imageのpriority設定 | LCP改善 | 低 |
| Turbopack(dev) | 開発体験向上(ビルド時間-70%) | 低 |
| fetchのrevalidate明示 | 無駄なSSRを削減 | 中 |
| CIキャッシュ | CI/CDビルド時間-60% | 中 |
| generateStaticParams | TTFB改善 | 中 |
計測せずに最適化しても意味がない。まずバンドルアナライザーでボトルネックを把握し、インパクトの大きい順に対応していく。難易度が低くて効果が大きいものから着手するのが基本だ。
Turbopackの導入とCIキャッシュは設定コストが低い割に体感改善が大きいので、まずここから始めるのがおすすめだ。