0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · Lesson

Analyzing and Shrinking the Client Bundle

Use the bundle analyzer to find heavy dependencies and trim client JavaScript shipped to users.

Analyzing and Shrinking the Client Bundle is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

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 boundariesimport '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.

Frequently asked questions

Is the “Analyzing and Shrinking the Client Bundle” lesson free?

Yes — the full text of “Analyzing and Shrinking the Client Bundle” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.

What will I learn in “Analyzing and Shrinking the Client Bundle”?

Use the bundle analyzer to find heavy dependencies and trim client JavaScript shipped to users. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?

No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Analyzing and Shrinking the Client Bundle” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?

Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Analyzing and Shrinking the Client Bundle
  2. Turbopack and Compiler Configuration Deep Dive
  3. Module Boundaries with server-only and client-only
  4. Dynamic Imports, Code Splitting, and Lazy Hydration
← Back to Next.js 15 Fullstack (App Router + Server Actions)