32blogby Studio Mitsu

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

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

by omitsu17 min read
Next.jsbuildoptimizationperformancebundle-sizeturbopack
目次

Next.jsのビルド時間とバンドルサイズは、バンドルアナライザーで計測してからピンポイントで対処するのが鉄則だ。重い依存関係(lodash、moment.js)のツリーシェイキング、next/image の適切な設定、そしてNext.js 16でデフォルトになった Turbopack の活用が特に効果が大きい。

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

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

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

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

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

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

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

export default withBundleAnalyzer({
  enabled: process.env.ANALYZE === "true",
})(nextConfig);
bash
# アナライザーを起動してビルド
ANALYZE=true npm run build

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

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

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

パッケージサイズの簡易チェック

パッケージ単体のサイズを調べるなら bundlephobia.com に名前を入れると一発でわかる。インストールサイズ、gzipサイズ、ツリーシェイキング対応かどうかが表示される。

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

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

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],
  },
};

LCP画像のpreload設定

LCP(Largest Contentful Paint)に影響するファーストビューの画像はpreloadすべきだ。Next.js 16で priority プロパティは非推奨になり、preload に置き換わった。

tsx
// app/page.tsx
import Image from "next/image";

export default function HeroSection() {
  return (
    <section>
      {/* Next.js 16+: preloadで<head>に<link>を挿入してLCP改善 */}
      <Image
        src="/hero.webp"
        alt="ヒーロー画像"
        width={1200}
        height={600}
        preload
      />

      {/* Next.js 15以前では priority を使う */}
      {/* <Image src="/hero.webp" alt="ヒーロー" width={1200} height={600} priority /> */}
    </section>
  );
}

preloadの <link> なしで即座に読み込みたい場合は loading="eager"fetchPriority="high" を直接指定する。

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

静的画像の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 16のデフォルトバンドラー

Next.js 16 から、Turbopackは next devnext build の両方で安定版になり、デフォルトバンドラー になった。フラグは不要で、そのまま実行するだけでいい。

json
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  }
}

以前 --turbopack をスクリプトに書いていた場合は削除できる。TurbopackはWebpackと比較して、ローカルサーバー起動が最大76%高速、Fast Refreshが96%高速 だ。

カスタムwebpack設定がある場合

Next.js 16では、next.config.ts にカスタム webpack 設定がある場合、next build がデフォルトで失敗する。選択肢は3つある。

typescript
// 方法1: Turbopack互換の設定に移行する
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  turbopack: {
    resolveAlias: {
      // webpack の resolve.fallback を置き換える
      fs: { browser: "./empty.ts" },
    },
  },
};

export default nextConfig;
json
// 方法2: 本番ビルドだけWebpackを使う
{
  "scripts": {
    "dev": "next dev",
    "build": "next build --webpack",
    "start": "next start"
  }
}

よくあるTurbopackの問題

bash
# Turbopackキャッシュが壊れた場合
rm -rf .next
npm run dev

静的生成とキャッシュ戦略で表示速度を上げる

Next.jsのレンダリング戦略を適切に選ぶことで、ページの初期表示速度が変わる。

Cache ComponentsとPPR(Next.js 16)

Next.js 16では、旧来の experimental.ppr フラグが cacheComponents に置き換わった。Partial Prerendering(PPR)と新しい use cache ディレクティブを有効にする設定だ。

typescript
// next.config.ts(Next.js 16)
const nextConfig = {
  cacheComponents: true,
};

export default nextConfig;
tsx
// app/blog/page.tsx
import { Suspense } from "react";
import { cacheLife, cacheTag } from "next/cache";

// この関数の結果をカスタムライフタイムでキャッシュ
async function getCachedArticles() {
  "use cache";
  cacheLife("hours");
  cacheTag("articles");
  return await db.articles.findMany();
}

export default function BlogPage() {
  return (
    <main>
      {/* 静的な部分はキャッシュから即座に表示 */}
      <h1>記事一覧</h1>
      <StaticArticleList />

      {/* ダイナミックな部分はストリーミング */}
      <Suspense fallback={<div>読み込み中...</div>}>
        <DynamicUserRecommendations />
      </Suspense>
    </main>
  );
}

fetchのキャッシュを明示的に管理する

Next.js 15以降の重要な変更: fetchリクエストはデフォルトでキャッシュされなくなった。キャッシュしたい場合は cache: "force-cache"next: { revalidate } を明示的に指定する必要がある。

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();
}

// リアルタイムデータ(キャッシュなし — Next.js 15+ではこれがデフォルト動作)
export async function getLivePrice() {
  const res = await fetch("https://api.example.com/price");
  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: "22"
          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: "22"
          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秒に短縮された。

FAQ

TurbopackはNext.js 16の本番ビルドで使える?

使える。Next.js 16 から、Turbopackは next devnext build の両方で安定版になり、デフォルトバンドラーとして動作する。--turbopack フラグは不要だ。

Next.jsのバンドルサイズはどうやって確認する?

@next/bundle-analyzer をインストールして next.config.ts に追加し、ANALYZE=true npm run build を実行する。インタラクティブなツリーマップが開いて、各依存関係のサイズが一目でわかる。個別パッケージのサイズは bundlephobia.com で事前に確認できる。

next/imageの priority はまだ使える?

Next.js 16で priority は非推奨 になり、preload に置き換わった。Next.js 15以前では priority は引き続き動作する。Next.js 16以降では preload<head> にpreloadリンクを挿入するか、loading="eager" / fetchPriority="high" を使う。

experimental.ppr はNext.js 16でどうなった?

experimental.ppr フラグは削除された。代わりに cacheComponents: truenext.config.ts で設定する。これにより、新しい use cache ディレクティブと安定版の cacheLife / cacheTag APIが使えるようになる。

fetchリクエストはデフォルトでキャッシュされる?

されない。Next.js 15 からfetchのデフォルトが no-store(キャッシュなし)に変更された。キャッシュを使いたい場合は cache: "force-cache"next: { revalidate } を明示的に指定する必要がある。

moment.jsの代替はdate-fnsとdayjsのどちらがいい?

どちらでも問題ない。date-fns はツリーシェイキングに完全対応していて、使う関数だけがバンドルされる(moment.jsの約70KBに対して数KB)。dayjs は単一importで約2KBとさらに軽量で、momentに近いAPIを持つ。使う関数の数が多いなら date-fns、シンプルに使いたいなら dayjs が向いている。

CIキャッシュでどれくらいビルド時間が変わる?

GitHub Actionsで actions/cache を使って .next/cache をキャッシュすると、変更がないファイルの再コンパイルがスキップされる。効果はプロジェクト規模による。32blog.comではCIビルドが2分30秒から約50秒に短縮された(約60%削減)。

まとめ

Next.jsのビルド最適化でやることをまとめる。

施策効果難易度
バンドルアナライザーで計測問題の可視化
moment.js → date-fnsバンドル -70KB以上
lodashのnamed importバンドル -50KB以上
next/imageのpreload設定LCP改善
Turbopack(Next.js 16デフォルト)開発・ビルド高速化
fetchのrevalidate明示無駄なSSRを削減
CIキャッシュCI/CDビルド時間-60%
generateStaticParamsTTFB改善
use cache + cacheComponentsサーバーキャッシュの細かい制御

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

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

関連記事: