Type something to search...
Analyzing and Reducing Bundle Size in Next.js with the Bundle Analyzer

Analyzing and Reducing Bundle Size in Next.js with the Bundle Analyzer

Every kilobyte of JavaScript you send has to be downloaded, parsed, and executed before the page is fully interactive, and on a mid-range phone, parsing and executing are the expensive parts. Bundle size is one of the most direct levers you have on load performance and on how responsive the page feels while it loads.

The problem is that bundles grow quietly. Someone adds a date library for one formatted string, someone imports an icon from a package's root, a "use client" directive moves a little too high up the tree, and six months later your product page ships 600KB of JavaScript and nobody knows why. You can't fix that by guessing; you need to see what's inside.

This post covers both bundle analysis tools available in Next.js 16 (the new Turbopack-based analyzer and the classic @next/bundle-analyzer for Webpack), how to read their output, and the fixes for the problems they usually reveal.

What Ends Up in the Client Bundle

It helps to know what you're looking for. In the App Router:

  • Server Components render on the server and send HTML and a serialized payload. Their code, and the libraries they import, never reach the browser.
  • Client Components (files with "use client", and everything they import) are bundled and sent to the browser.
  • Next.js and React runtime code is shared across all pages. You can't remove it, but it's cached after the first visit.

So the client bundle is your Client Components plus their entire import graph. When a bundle is too big, the cause is almost always that something heavy is reachable from a Client Component that doesn't need to be.

One change in Next.js 16 makes the analyzer more important than before: next build no longer prints per-route Size and First Load JS columns. The Next.js team removed them because they were inaccurate for Server Component architectures. To see what you ship, you now use an analyzer, or measure the real pages with Lighthouse and the browser's Network panel.

Option 1: The Turbopack Bundle Analyzer

Since Turbopack is the default bundler in Next.js 16, the most direct tool is the analyzer built into it, available from v16.1. It's marked experimental, but it's the one that matches your actual production bundles if you build with Turbopack.

npx next experimental-analyze

This analyzes your app without producing a build, then starts a local server (port 4000 by default) with an interactive view. In the UI you can:

  • Filter by route, so you see only the modules that a given page loads.
  • Switch between client and server, to look at browser bundles separately from server bundles.
  • Filter by type (JavaScript, CSS, JSON) or search for a specific file or package.
  • Click a module to see its size and its full import chain: exactly which of your files pulled it in.

That last feature is the important one. Knowing that highlight.js is 300KB is half the answer; knowing it got there through components/markdown-preview.tsx, imported by app/blog/[slug]/comment-form.tsx, tells you what to change.

Saving and Comparing Results

To share results or compare before and after a change, write the analysis to disk instead of starting the server:

npx next experimental-analyze --output
# results are written to .next/diagnostics/analyze

cp -r .next/diagnostics/analyze ./analyze-before

Make your change, run it again, and you have two snapshots to compare. A handy pattern is to keep a before copy while working through an optimization branch, so you can confirm each change actually reduced what you expected.

Other useful flags: --port to serve on a different port, and --no-mangling to keep readable names when you're trying to identify code (it doesn't reflect production output, so use it only for debugging).

Option 2: @next/bundle-analyzer for Webpack

The long-standing option is @next/bundle-analyzer, a wrapper around webpack-bundle-analyzer. It's a Webpack plugin, so in Next.js 16 it only works when you build with Webpack, using the --webpack flag. It's still useful if your project builds with Webpack, or if you prefer its treemap.

Install it as a dev dependency:

npm install --save-dev @next/bundle-analyzer

Wrap your config:

// 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 = {
  // your existing config
};

export default withBundleAnalyzer(nextConfig);

Add a script so you don't have to remember the flags:

{
  "scripts": {
    "analyze": "ANALYZE=true next build --webpack"
  }
}

On Windows without a POSIX shell, use the cross-env package (cross-env ANALYZE=true next build --webpack) to set the variable.

Running npm run analyze produces a normal Webpack build plus HTML reports for the client, Node.js server, and Edge bundles, and opens them in your browser. The enabled flag means regular builds are unaffected.

Reading the Treemap

Each rectangle is a module; its area is proportional to its size. Nested rectangles show which package a file belongs to. The sidebar lets you switch between three size measurements:

  • Stat: the size of the source before minification. Mostly useful for spotting what's included.
  • Parsed: the size after minification. This approximates how much JavaScript the browser has to parse and execute.
  • Gzipped: the compressed transfer size. This approximates download cost.

Focus on the client report and the parsed size. Big blocks of node_modules inside the chunks for one route are where to start.

What to Look For

When you open either analyzer, these are the patterns worth hunting for, roughly in order of how often they're the culprit.

1. Libraries That Could Run on the Server

Markdown parsers, syntax highlighters, date formatting libraries, and data transformation code often end up in Client Components even though their output is just static HTML. If the result doesn't need interactivity, do the work in a Server Component:

// app/blog/[slug]/code-block.tsx (Server Component: no "use client")
import { codeToHtml } from "shiki";

export async function CodeBlock({
  code,
  lang,
}: {
  code: string;
  lang: string;
}) {
  const html = await codeToHtml(code, { lang, theme: "github-dark" });
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

Shiki and its grammars stay on the server; the browser receives only highlighted markup. The same applies to marked, remark, and date-fns when you're only formatting values for display. See adding syntax highlighting to a Next.js blog for a fuller version of this example.

2. "use client" Placed Too High

"use client" marks a boundary: that file and everything it imports become client code. Put it on a layout or a page and the whole subtree, with every library it uses, ships to the browser.

// Before: the whole page is a Client Component because of one button
"use client";
// ...imports a markdown renderer, a chart library, and a date library

Move the directive down to the component that actually needs state or event handlers, and keep the page a Server Component that renders it. In the analyzer, this shows up as a route whose client bundle contains packages you'd expect only on the server. Composition patterns for mixing Server and Client Components covers how to restructure these trees.

3. Barrel Imports from Large Packages

Icon sets and utility libraries often export thousands of modules from a single index file. Importing one icon from the root can pull in far more than that icon if the bundler can't tree-shake it efficiently. Next.js handles this for many popular packages automatically, including lucide-react, date-fns, lodash-es, @heroicons/react, react-icons, @mui/material, @mui/icons-material, rxjs, and recharts.

For other packages with many exports, add them to optimizePackageImports:

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

const nextConfig: NextConfig = {
  experimental: {
    optimizePackageImports: ["@acme/icons", "@acme/ui"],
  },
};

export default nextConfig;

You keep writing import { Bell } from "@acme/icons", and Next.js rewrites it to load only the modules you use. This also speeds up development builds noticeably for large packages.

Your own barrel files (components/index.ts that re-exports everything) can cause the same problem at a smaller scale. If the analyzer shows a route pulling in components it never renders, import directly from the component's file.

4. Heavy Libraries with Light Alternatives

Some packages are big for historical reasons. Common swaps:

Instead ofConsider
momentNative Intl.DateTimeFormat, date-fns, or dayjs
lodash (CommonJS)Native array and object methods, or lodash-es with named imports
axios in the browserNative fetch
A full UI kit for two componentsCopy-in components (for example shadcn/ui)
uuid in the browsercrypto.randomUUID()
A charting library for one sparklineA small SVG component

Intl alone replaces a surprising amount of formatting code:

// lib/format.ts
const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});
const shortDate = new Intl.DateTimeFormat("en-US", { dateStyle: "medium" });
const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });

export const formatPrice = (cents: number) => currency.format(cents / 100);
export const formatDate = (iso: string) => shortDate.format(new Date(iso));
export const formatDaysAgo = (days: number) => relative.format(-days, "day");

Zero bytes of library code, and the browser handles locales for you.

5. Code Only Needed After Interaction

Rich text editors, emoji pickers, modals, charts below the fold, and PDF viewers don't need to load with the page. Load them on demand with next/dynamic or a dynamic import():

// app/notes/note-editor-launcher.tsx
"use client";

import dynamic from "next/dynamic";
import { useState } from "react";

const RichTextEditor = dynamic(() => import("./rich-text-editor"), {
  loading: () => <p>Loading editor…</p>,
});

export function NoteEditorLauncher() {
  const [editing, setEditing] = useState(false);
  return editing ? (
    <RichTextEditor />
  ) : (
    <button type="button" onClick={() => setEditing(true)}>
      Edit note
    </button>
  );
}

The editor moves into its own chunk that's only downloaded when the user clicks. In the analyzer, you'll see it split out from the route's main bundle. Lazy loading has enough nuance to deserve its own post: lazy loading components with next/dynamic.

6. Duplicate Packages

The same library appearing twice in the treemap, often at different versions, usually means two dependencies require incompatible ranges. Check with:

npm ls react-is
npm ls date-fns

Updating the dependency that pins the old version, or running npm dedupe, often collapses them into one copy. If you can't, overrides in package.json can force a single version, but test carefully, since you're overriding what a package declared it needs.

7. Large Data Imported into Client Code

Importing a big JSON file (a list of countries with translations, a full product catalog, a search index) into a Client Component inlines all of it into the bundle. Pass only what the component needs as props from a Server Component, or fetch it on demand.

8. Server Code Leaking to the Client

If the client report contains database drivers, SDKs that need secrets, or Node.js modules, a Client Component is importing server code, usually through a shared utility file. That's a bundle problem and potentially a security problem. Mark server-only modules with import "server-only" so the build fails if they're ever imported from the client. See using the server-only package.

Don't Forget the Server Bundle

The client bundle affects users directly, but server bundles matter on serverless platforms: bigger functions mean slower cold starts. Use the analyzer's server view (or the Node.js report with Webpack) to find heavy server dependencies. For packages that don't bundle well or rely on native Node.js features, list them in serverExternalPackages so they're loaded from node_modules at runtime instead of being bundled:

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

const nextConfig: NextConfig = {
  serverExternalPackages: ["@acme/pdf-renderer"],
};

export default nextConfig;

Next.js already treats many common packages with native bindings this way, so only add packages you've seen cause problems.

Measuring the Result

An analyzer tells you what's in your bundles; it doesn't tell you how pages perform. After making changes:

  1. Build and start a production server (next build && next start).
  2. In Chrome DevTools, open the Network panel, filter by "JS", disable the cache, and reload. The status bar shows total transferred and resource sizes for the page.
  3. Open the Coverage panel (Command Menu, then "Show Coverage") and reload. It shows how much of each script was actually executed. Large unused percentages point to more code that could be split or deferred.
  4. Run Lighthouse and check Total Blocking Time and the "Reduce unused JavaScript" audit.

Reducing JavaScript mostly shows up in field data as better Interaction to Next Paint, because less code means a less busy main thread while users interact.

Keeping Bundles Small Over Time

A one-time cleanup decays. A few habits keep it from happening again:

  • Run the analyzer before merging changes that add dependencies, and look at which routes they affect.
  • Keep an --output snapshot from your main branch to compare branches against.
  • Check package size before installing; bundlephobia.com shows the minified and gzipped cost of npm packages.
  • Default to Server Components, and treat each "use client" as a decision about what you're willing to ship.

Conclusion

Next.js 16 no longer prints bundle sizes in the build output, so analysis is now something you do on purpose. Use next experimental-analyze for Turbopack builds, or @next/bundle-analyzer with next build --webpack, and look at the client bundles route by route. The fixes are usually the same handful: do display-only work in Server Components, push "use client" down, let optimizePackageImports handle barrel-heavy packages, swap heavy libraries for native APIs, lazy load interaction-only code, and remove duplicates. Then verify with a production build in the browser, because the goal isn't a smaller treemap; it's a faster page.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading