32blogby StudioMitsu
nextjs12 min read

Next.jsビルド最適化完全ガイド

Next.jsのビルド時間とバンドルサイズを削減する実践的な手法を解説。32blog.comで実際に試した設定と結果をまとめた。

nextjsbuild最適化パフォーマンスバンドルサイズビルド時間
目次

32blog.comのビルドが遅くなってきた。記事数が増えるにつれてビルド時間が伸び、Vercelのデプロイに2分以上かかるようになった。

ローカルでの開発体験も悪化していた。コード変更のたびに next build を確認するのが億劫になるほど遅かった。バンドルサイズも気になりはじめ、Lighthouseのスコアに影響が出ていた。

この記事では、Next.jsのビルドを高速化してバンドルサイズを削減するための実践的な手法をまとめる。設定の詳細と、実際にどれくらい改善したかの数値も合わせて紹介する。

バンドルアナライザーで何が重いかを把握する

最適化は計測から始まる。まず何がバンドルサイズを食っているかを可視化する。

bash
npm install --save-dev @next/bundle-analyzer
typescript
// next.config.ts
import withBundleAnalyzerInit from "@next/bundle-analyzer";

const withBundleAnalyzer = withBundleAnalyzerInit({
  enabled: process.env.ANALYZE === "true",
});

const nextConfig = {
  // 設定をここに書く
};

export default withBundleAnalyzer(nextConfig);
bash
# アナライザーを起動してビルド
ANALYZE=true npm run build

ブラウザでインタラクティブなバンドルマップが開く。面積が大きいブロックほどバンドルサイズが大きい。ここで「こんなに重いの?」と思うものを優先的に最適化する。

よく見つかる重い依存関係は以下のようなものだ。

  • moment.js(3MB超え。date-fnsdayjs で代替可能)
  • lodash(ツリーシェイキングが効いていない場合)
  • @mui/material(使っているコンポーネントだけimportする)
  • アイコンライブラリ(全体をimportしている)

@next/bundle-analyzer の代替: next-bundle-analyzer

より詳細な情報が欲しい場合は bundle-buddybundlephobia も使える。パッケージ単体のサイズを調べるなら bundlephobia.com に名前を入れると一発でわかる。

不要な依存関係をツリーシェイキングで削除する

バンドルサイズを下げる最も効果的な方法は、使っていないコードを含めないことだ。

lodashをnamed importに変える

typescript
// ❌ これは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モジュール形式でツリーシェイキングが効く。

bash
npm install lodash-es
npm install --save-dev @types/lodash-es
typescript
// lodash-esはツリーシェイキングが効く
import { groupBy, sortBy } from "lodash-es";

date-fnsへの移行

moment.js を使っているなら date-fns への移行を強くすすめる。

bash
npm uninstall moment
npm install date-fns
typescript
// 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

typescript
// ❌ 全アイコンをimportしてしまう(数MB)
import * as FaIcons from "react-icons/fa";

// ✅ 必要なアイコンだけnamed importする(ツリーシェイキング対応)
import { FaGithub, FaTwitter } from "react-icons/fa";

lucide-react はデフォルトでツリーシェイキングに対応しているので、通常の named import で問題ない。

typescript
// lucide-reactは named import でOK
import { Github, Twitter, ExternalLink } from "lucide-react";

画像最適化でページ読み込みを改善する

Next.jsの next/image は使っているだけで最適化される部分が多いが、設定次第でさらに効果を高められる。

next/imageの基本設定

typescript
// 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 を付ける。

tsx
// 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>
  );
}
tsx
// スクロールしないと見えない画像はpriority不要
<Image
  src="/article-thumbnail.webp"
  alt="記事のサムネイル"
  width={400}
  height={300}
  // lazy loading がデフォルトで効く
/>

静的画像のimport

ローカルの静的画像は import すると自動でwidth/heightが推測される。

tsx
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が劇的に速くなる。

json
// package.json
{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start"
  }
}

next build はまだWebpack(またはTurbopack experimental)が使われるが、next dev だけでもTurbopackを使うと体感が大きく変わる。

Turbopackへの移行でよく発生する問題は以下の通り。

bash
# Turbopackキャッシュが壊れた場合
rm -rf .next
npm run dev
typescript
// 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)を活用する(実験的)

typescript
// next.config.ts(Next.js 15以降)
const nextConfig = {
  experimental: {
    ppr: true,
  },
};
tsx
// 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のキャッシュを明示的に管理する

typescript
// 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を生成しておくと表示が速い。

typescript
// 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ではビルドと型チェックを分離すると並列実行できる。

bash
# package.json のscripts
{
  "scripts": {
    "build": "next build",
    "type-check": "tsc --noEmit",
    "ci": "npm run type-check && npm run build"
  }
}

GitHub Actionsでは並列ジョブで実行する。

yaml
# .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のキャッシュを活用する

yaml
# .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%
generateStaticParamsTTFB改善

計測せずに最適化しても意味がない。まずバンドルアナライザーでボトルネックを把握し、インパクトの大きい順に対応していく。難易度が低くて効果が大きいものから着手するのが基本だ。

Turbopackの導入とCIキャッシュは設定コストが低い割に体感改善が大きいので、まずここから始めるのがおすすめだ。