0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · レッスン

クライアントバンドルの分析と縮小

バンドルアナライザーで容量の大きい依存関係を見つけ、ユーザーへ配信するクライアントJavaScriptを削減します。

「クライアントバンドルの分析と縮小」はCoddyKit上の無料Next.js 15 Fullstack (App Router + Server Actions)レッスンです。 これはレッスン1/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはNext.js 15 Fullstack (App Router + Server Actions)学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Next.js 15 Fullstack (App Router + Server Actions)コースには全4レッスンが含まれています。

このレッスンの一部はまだ翻訳されておらず、英語で表示されています。

Why Client Bundle Size Matters

Every kilobyte of JavaScript your app ships to users has a cost: download time, parse time, and execution time. On a mid-range mobile device on a 4G connection, a 1 MB JS bundle can add 3–5 seconds of blocking time before the page becomes interactive.

Next.js 15 with the App Router already does a lot for you:

  • Server Components never ship to the client
  • Route-based code splitting is automatic
  • Tree shaking removes unused exports

But third-party libraries, accidental client imports, and large utility packages can silently inflate your bundle. This lesson shows you how to find and fix those problems systematically.

Installing @next/bundle-analyzer

The official Next.js bundle analyzer wraps webpack-bundle-analyzer and integrates cleanly with next.config.ts. Install it once as a dev dependency:

After installation, you wrap your Next.js config with the analyzer factory. It reads the ANALYZE environment variable so the report only opens when you explicitly request it — your normal builds are unaffected.

The analyzer generates two interactive HTML treemaps:

  • client.html — JavaScript sent to the browser
  • server.html — Node.js server bundle (useful but secondary)
// Terminal
// npm install --save-dev @next/bundle-analyzer

// next.config.ts
import type { NextConfig } from 'next';
import bundleAnalyzer from '@next/bundle-analyzer';

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
  openAnalyzer: true,
});

const nextConfig: NextConfig = {
  // your existing config
};

export default withBundleAnalyzer(nextConfig);

Running an Analysis Build

Add a convenience script to package.json so the analysis command is memorable and consistent across your team:

When you run npm run analyze, Next.js performs a full production build and then opens two browser tabs with the treemap reports. The size of each rectangle represents the parsed (uncompressed) byte size of that module inside your bundle.

Key things to look for in the client treemap:

  • Unexpectedly large rectangles from a single library
  • Libraries you only use on the server appearing in the client chunk
  • Multiple copies of the same library (version conflicts)
// package.json (relevant section)
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "analyze": "ANALYZE=true next build"
  }
}

Reading the Treemap Report

The treemap groups modules by chunk. Each production page in your App Router app typically has at least three chunk types:

  • app/layout chunk — shared across all routes; anything imported in your root layout lands here
  • page-specific chunks — code unique to one route
  • shared chunks — modules used by two or more pages, extracted automatically by webpack

Hover over any rectangle to see the full module path and its stat size (raw), parsed size (after minification), and gzipped size (what travels over the wire). Always optimize for gzipped size — it is the real user cost.

A common finding: a date library like moment or date-fns with all locales bundled taking 200–500 KB parsed in the shared chunk.

The Accidental Client Import Problem

In the App Router, components are Server Components by default. However, the moment a Server Component imports a module that itself has a 'use client' boundary somewhere in its dependency tree — or you accidentally import a server utility into a Client Component — things go wrong.

A more common problem is the reverse: importing a heavy library inside a 'use client' component that could instead live on the server. For example, a Markdown renderer, a syntax highlighter, or a PDF parser has no business running in the browser if it only renders static content.

The fix is straightforward: move the rendering logic into a Server Component and pass the result as a prop or slot.

// BEFORE: heavy library shipped to the client
'use client';
import { marked } from 'marked'; // ~50 KB parsed

export function BlogPost({ raw }: { raw: string }) {
  return <div dangerouslySetInnerHTML={{ __html: marked(raw) }} />;
}

// AFTER: render on the server, ship only HTML
// app/blog/[slug]/page.tsx  (Server Component — no 'use client')
import { marked } from 'marked';

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const raw = await fetchPostMarkdown(slug);
  const html = marked(raw) as string;
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

async function fetchPostMarkdown(slug: string): Promise<string> {
  // fetch from DB / CMS
  return `# Hello from ${slug}`;
}

Dynamic Imports and Lazy Loading

Next.js re-exports next/dynamic, a thin wrapper around React.lazy with SSR control built in. Use it to split off components that are:

  • Only visible after a user interaction (modals, drawers, tooltips)
  • Below the fold (charts, comment sections)
  • Large and route-specific

The ssr: false option is important for browser-only libraries (those that access window or document) — it prevents the server from even attempting to import them.

The loading prop provides a skeleton while the chunk downloads, preventing layout shift.

// app/dashboard/page.tsx
import dynamic from 'next/dynamic';

// Heavy chart library — only loaded when the chart is rendered
const RevenueChart = dynamic(
  () => import('@/components/RevenueChart'),
  {
    loading: () => <p>Loading chart...</p>,
    ssr: false, // recharts/d3 uses window internally
  }
);

// Modal — only loaded when user clicks "Export"
const ExportModal = dynamic(
  () => import('@/components/ExportModal')
);

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <RevenueChart />
      <ExportModal />
    </main>
  );
}

Tree Shaking: Named vs Default Imports

Webpack and Turbopack can eliminate unused code — but only when libraries are structured to allow it (ESM with named exports). Your import style also matters.

Importing an entire library namespace defeats tree shaking even for ESM libraries. Always prefer named imports targeting the specific function you need.

For libraries that still ship CommonJS (CJS) only, tree shaking is impossible. Check the analyzer: if you import one utility from a 300 KB CJS library, all 300 KB goes into your bundle. In that case, look for a lighter ESM alternative or copy just the function you need.

Use the sideEffects: false field in your own package's package.json to signal that all your modules are safe to tree-shake.

// BAD — pulls in the entire lodash CJS bundle (~70 KB gzipped)
import _ from 'lodash';
const result = _.groupBy(items, 'category');

// BETTER — lodash-es is ESM; named import is tree-shakeable
import { groupBy } from 'lodash-es';
const result = groupBy(items, 'category');

// BEST for simple cases — just write it yourself
function groupBy<T>(arr: T[], key: keyof T): Record<string, T[]> {
  return arr.reduce(
    (acc, item) => {
      const group = String(item[key]);
      (acc[group] ??= []).push(item);
      return acc;
    },
    {} as Record<string, T[]>
  );
}

const result = groupBy(items, 'category');

Replacing Heavy Libraries with Lighter Alternatives

The single highest-impact optimization is often swapping a large library for a smaller one that does exactly what you need. Common replacements:

  • moment (67 KB gz) → date-fns (tree-shakeable, ~3 KB per function) or dayjs (2 KB gz)
  • axios (13 KB gz) → native fetch (0 KB, built into Node 18+ and all browsers)
  • lodash (70 KB gz) → lodash-es + tree shaking, or native array methods
  • highlight.js (full) → import only the languages you need via the slim build

The pattern below shows swapping moment for date-fns in a Server Action context where only one formatting function is needed.

// BEFORE — moment ships all locales by default
// import moment from 'moment';
// const label = moment(date).format('MMM D, YYYY');

// AFTER — date-fns: import exactly what you need
import { format } from 'date-fns';

// This Server Action formats a timestamp — runs only on the server
export async function getFormattedDate(
  isoString: string
): Promise<string> {
  'use server';
  const date = new Date(isoString);
  return format(date, 'MMM d, yyyy'); // e.g. "Jun 11, 2026"
}

Measuring Real Bundle Impact with next build Output

You do not always need the full visual treemap. Next.js prints a route size table after every production build. Learn to read it:

  • Size — JavaScript downloaded for that route specifically (route-unique chunks)
  • First Load JS — total JS including shared chunks (what the user actually downloads on first visit)

The footer shows the shared chunk total. A green First Load JS is under 100 KB. Yellow is a warning. Red (over 500 KB) needs immediate attention.

Track this number in CI by failing the build when it exceeds a threshold, using the experimental.bundlePagesRouterDependencies or a custom size-limit script.

// scripts/check-bundle-size.ts
// Run after `next build` parses .next/build-manifest.json
import fs from 'node:fs';
import path from 'node:path';

const FIRST_LOAD_LIMIT_KB = 200;

interface BuildManifest {
  pages: Record<string, string[]>;
}

function getFileSizeKb(filePath: string): number {
  try {
    const stats = fs.statSync(filePath);
    return stats.size / 1024;
  } catch {
    return 0;
  }
}

const manifestPath = path.join(process.cwd(), '.next/build-manifest.json');
const manifest: BuildManifest = JSON.parse(
  fs.readFileSync(manifestPath, 'utf-8')
);

let exceeded = false;
for (const [page, files] of Object.entries(manifest.pages)) {
  const totalKb = files.reduce((sum, f) => {
    return sum + getFileSizeKb(path.join(process.cwd(), '.next', f));
  }, 0);
  if (totalKb > FIRST_LOAD_LIMIT_KB) {
    console.error(`FAIL ${page}: ${totalKb.toFixed(1)} KB > ${FIRST_LOAD_LIMIT_KB} KB`);
    exceeded = true;
  }
}

if (exceeded) process.exit(1);
console.log('Bundle size check passed.');

Using modularizeImports for Icon and UI Libraries

Icon libraries like react-icons and component libraries like @mui/material are notorious bundle killers when imported naively. Even a named import like import { FiTrash } from 'react-icons/fi' can pull in the entire icon set if the library's internal structure is not tree-shakeable.

Next.js 15 provides modularizeImports in next.config.ts to automatically rewrite barrel imports into direct deep imports at build time — no change needed in your component files.

This is equivalent to writing import FiTrash from 'react-icons/fi/index.js' but happens transparently, keeping your source code clean.

// next.config.ts
import type { NextConfig } from 'next';
import bundleAnalyzer from '@next/bundle-analyzer';

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

const nextConfig: NextConfig = {
  modularizeImports: {
    // Rewrites: import { FiTrash } from 'react-icons/fi'
    // To:       import FiTrash from 'react-icons/fi/FiTrash'
    'react-icons/?((<alpha>*))': {
      transform: 'react-icons/{{ matches.[1] }}/{{ member }}',
    },
    // Same pattern for @mui/material
    '@mui/material': {
      transform: '@mui/material/{{ member }}',
    },
    '@mui/icons-material': {
      transform: '@mui/icons-material/{{ member }}',
    },
  },
};

export default withBundleAnalyzer(nextConfig);

Externalizing Server-Only Packages

Some packages should never reach the client bundle. Database drivers (pg, mysql2), encryption libraries (bcrypt), and file-system utilities are server-only by design. Next.js 15 provides two mechanisms to enforce this:

  • server-only npm package — adding import 'server-only' to a module throws a build-time error if that module is ever imported in a Client Component
  • experimental.serverComponentsExternalPackages in next.config.ts — tells the bundler to leave certain packages as Node.js require() calls rather than bundling them

Using both together gives you belt-and-suspenders protection: the import guard catches mistakes in your code, and the external config handles native binaries that webpack cannot bundle anyway.

// lib/db.ts  — database client, server-only
import 'server-only'; // build error if imported in a Client Component
import { Pool } from 'pg';

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    // These packages stay as require() — not bundled by webpack
    // Needed for native addons like bcrypt, sharp, prisma engine
    serverComponentsExternalPackages: ['bcrypt', 'sharp', '@prisma/client'],
  },
};

export default nextConfig;

Knowledge Check: Bundle Optimization Strategy

You run npm run analyze and discover that highlight.js (full build, ~900 KB parsed) appears in your client bundle. The syntax highlighting is only used on /blog/[slug] post pages to colorize code blocks. The code blocks are static — the same for every visitor. What is the best optimization strategy?

Recap: Analyzing and Shrinking the Client Bundle

In this lesson you learned a systematic approach to reducing the JavaScript your Next.js 15 app ships to users:

  • Install and run @next/bundle-analyzer — wraps your build with an interactive treemap; triggered via ANALYZE=true next build
  • Read the treemap — focus on gzipped size in the client report; look for large rectangles, server-only packages, and duplicate modules
  • Move server-only work to Server Components — Markdown renderers, syntax highlighters, PDF parsers, and DB clients have no business in the browser
  • Use next/dynamic for deferred loading — split off modals, charts, and below-the-fold components; use ssr: false for browser-only packages
  • Tree shake correctly — prefer named ESM imports; replace CJS-only libraries with ESM alternatives
  • Use modularizeImports — automatically deep-import icon and UI library members at build time
  • Enforce server boundaries — import 'server-only' and serverComponentsExternalPackages prevent accidental client leakage

Run the analyzer before and after each optimization to confirm the impact. Automate a size budget check in CI to prevent regressions.

よくある質問

「クライアントバンドルの分析と縮小」レッスンは無料ですか?

はい。「クライアントバンドルの分析と縮小」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Next.js 15 Fullstack (App Router + Server Actions)コースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Next.js 15 Fullstack (App Router + Server Actions)コースには全4レッスンが含まれています。

「クライアントバンドルの分析と縮小」で何を学びますか?

バンドルアナライザーで容量の大きい依存関係を見つけ、ユーザーへ配信するクライアントJavaScriptを削減します。 ブラウザで直接実行するハンズオンコードでNext.js 15 Fullstack (App Router + Server Actions)を演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

Next.js 15 Fullstack (App Router + Server Actions)を始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのNext.js 15 Fullstack (App Router + Server Actions)は初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン1/4です。

「クライアントバンドルの分析と縮小」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このNext.js 15 Fullstack (App Router + Server Actions)レッスンでコードを書いて実行できますか?

はい。すべてのNext.js 15 Fullstack (App Router + Server Actions)レッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. クライアントバンドルの分析と縮小
  2. Turbopackとコンパイラー設定の徹底解説
  3. server-onlyとclient-onlyによるモジュール境界
  4. 動的インポート、コード分割、遅延ハイドレーション
← Next.js 15 Fullstack (App Router + Server Actions)に戻る