Next.js Bundle Analyzer: Reduce JavaScript Bundle Size in App Router (2026)

Find the heavy chunks in your Next.js app, then cut First Load JS by 30 to 60 percent. Install @next/bundle-analyzer, read the treemap, then use next/dynamic, Server Components, and optimizePackageImports to ship under 130 KB per route in Next.js 16.

Next.js Bundle Analyzer Guide (2026)

Updated: June 24, 2026

The Next.js Bundle Analyzer is a webpack/Turbopack plugin (@next/bundle-analyzer) that generates an interactive treemap of every JavaScript chunk shipped to the browser, letting you see exactly which packages bloat your First Load JS. To reduce bundle size in Next.js App Router, install the analyzer, identify oversized dependencies, then move heavy code behind next/dynamic, push interactive UI into Server Components, and configure experimental.optimizePackageImports for barrel-file libraries. Most apps cut First Load JS by 30–60% with two evenings of work, and I’ve got the audits to prove it.

  • @next/bundle-analyzer wraps your next.config.js and emits HTML treemaps for client, server, and edge bundles when ANALYZE=true.
  • The number that matters in App Router is First Load JS printed by next build. Aim for under 130 KB gzipped per route.
  • The four biggest wins: turning Client Components into Server Components, next/dynamic with ssr: false for heavy widgets, experimental.optimizePackageImports for barrel files like lucide-react, and replacing moment/lodash with native or modular alternatives.
  • Turbopack (default in Next.js 16) has its own analyzer output, and the @next/bundle-analyzer wrapper detects it automatically since v15.3.
  • Always measure before and after with the same route, same flags, same network throttling. A 200 KB drop on a tutorial blog means nothing without a baseline.

What the Next.js bundle analyzer actually shows

I get the same question on every audit: “Why is my Next.js bundle so big?” The honest answer is that nobody (including the engineer who shipped the route) knows until they look at the treemap. @next/bundle-analyzer is the official wrapper around webpack-bundle-analyzer and, since 15.3, Turbopack’s stats emitter. When you run ANALYZE=true next build, Next.js writes three HTML files to .next/analyze/: client.html, nodejs.html, and edge.html. Each one is a zoomable treemap where every rectangle is a module and its area is proportional to the parsed (un-gzipped) size of that module in the chunk.

Those three files matter for different reasons. client.html is the only one that ships to your users’ browsers, so this is the file you optimize for Core Web Vitals. nodejs.html shows what gets loaded on the Vercel Node runtime per Server Component render, which affects cold-start latency. edge.html covers anything running on the Edge Runtime (including proxy.ts/middleware.ts), and it has a hard 1 MB compressed cap on Vercel; exceed it and your deployment fails. In my experience, 90% of size problems live in client.html, but ignoring the other two is how you find out about a runtime crash on Friday at 6 PM.

Install and configure @next/bundle-analyzer

Installation is two minutes. The trick is the conditional wrap so you don’t slow every dev build:

npm install --save-dev @next/bundle-analyzer
# or: pnpm add -D @next/bundle-analyzer

Then update next.config.mjs (the example below is Next.js 16 with Turbopack as default):

// next.config.mjs
import bundleAnalyzer from '@next/bundle-analyzer';

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
  openAnalyzer: true, // auto-opens the report in your browser
});

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    optimizePackageImports: ['lucide-react', 'date-fns', '@radix-ui/react-icons'],
  },
};

export default withBundleAnalyzer(nextConfig);

Add a package.json script so you don’t have to remember the env var:

{
  "scripts": {
    "build": "next build",
    "analyze": "ANALYZE=true next build"
  }
}

Run npm run analyze. Three browser tabs open. If you’re on Windows without a shell that supports inline env vars, use cross-env in the script: cross-env ANALYZE=true next build. Honestly, the Next.js package-bundling guide is worth a skim for additional config options, particularly the transpilePackages field if you’re consuming ESM-only packages from a monorepo.

How to read the treemap without lying to yourself

The treemap shows three numbers per module: stat size (uncompressed source), parsed size (after webpack transforms, before gzip), and gzipped size. Switch to gzipped in the top-right dropdown right away. It’s the only one that maps to what users actually download. Then look at the chunk groups in the left sidebar and pick the one that contains your slowest route.

The pattern I look for first is what I call “barrel bleed”: a tiny rectangle labelled lucide-react/index.js with a parsed size of 280 KB. That means somewhere in your app you wrote import { Search } from 'lucide-react' and the bundler pulled the entire icon set instead of just Search. Same story for @mui/icons-material, @chakra-ui/react, and most icon libraries. You’ll also find moment shipping all its locales, lodash shipping every utility, and the occasional firebase auth bundle weighing 180 KB on a marketing page that doesn’t even have a login form.

The second pattern is duplicate React. If you see react-dom.production.min.js appearing in two chunks, you’ve got a Server Component leaking a Client Component subtree, or a third-party package bundling its own React. The fix is usually adding the package to transpilePackages, or hunting down the 'use client' directive that’s higher up the tree than it needs to be.

What is First Load JS in Next.js?

First Load JS is the total JavaScript bytes a user downloads to render a given route, including shared chunks (framework, polyfills, the runtime) plus that route’s own client code. After next build you’ll see a table like this in your terminal:

Route (app)                    Size     First Load JS
┌ ○ /                          1.42 kB  101 kB
├ ○ /about                     312 B    99.8 kB
├ ƒ /dashboard                 4.21 kB  148 kB
└ ƒ /dashboard/billing         12.7 kB  217 kB

○ (Static)  prerendered as static content
ƒ (Dynamic) server-rendered on demand

The “Size” column is the route-specific code. “First Load JS” is what actually ships to the browser on first request, including shared chunks. Google’s published Core Web Vitals thresholds suggest staying under ~130 KB gzipped of compressed JavaScript for an LCP under 2.5 seconds on a median mobile connection. The web.dev LCP guide spells out the underlying methodology. The /dashboard/billing route above is 217 KB, so that’s the one to attack with the analyzer first. Don’t waste time shaving 5 KB off your homepage if a checkout page is shipping a quarter-megabyte.

How do I reduce JavaScript bundle size in Next.js?

There are roughly six levers, ranked by the impact I’ve actually measured across client audits in 2026:

  1. Convert Client Components to Server Components. Any component that doesn’t use state, effects, browser APIs, or event handlers should not have 'use client' at the top. Server Components ship zero JavaScript to the browser.
  2. Lazy-load heavy widgets with next/dynamic. Charts (Recharts, Chart.js), rich text editors (Tiptap, Lexical), markdown previewers, video players, map components: none of these belong in your initial bundle.
  3. Enable experimental.optimizePackageImports for known-barrel libraries (the list above plus @heroicons/react, react-icons, recharts, framer-motion).
  4. Replace heavy dependencies. momentdate-fns or native Intl.DateTimeFormat. lodashlodash-es with named imports, or just native methods. axiosfetch.
  5. Use modularizeImports for libraries that don’t support tree-shaking out of the box. This is a Next.js config option that rewrites your imports at build time.
  6. Audit your fonts. next/font deduplicates and self-hosts, but if you import five weights of a variable font you’re still shipping five weights. Pick two.

I cover the Server Component conversion in detail in our Next.js streaming and Suspense guide. The pattern of pushing client boundaries down the tree is what unlocks both bundle savings and progressive rendering.

Code splitting with next/dynamic

next/dynamic is a thin wrapper around React.lazy + Suspense that’s aware of SSR. It’s the right tool when a component is heavy and rendered conditionally: modals, charts shown after a tab click, code editors, anything below the fold.

// app/dashboard/page.tsx
import dynamic from 'next/dynamic';
import { Skeleton } from '@/components/skeleton';

// Heavy chart library: ~140 KB gzipped. Don't ship it on first paint.
const RevenueChart = dynamic(() => import('@/components/revenue-chart'), {
  loading: () => <Skeleton className="h-80 w-full" />,
  ssr: false, // chart depends on window.ResizeObserver
});

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

Two gotchas. First, ssr: false only works inside a Client Component in Next.js 14+. If you’re calling it from a Server Component, wrap the import in a Client Component shim. Second, next/dynamic creates a new chunk per call site, so if you dynamically import the same heavy component from five places, you may get five chunks unless you hoist the import. Check the analyzer to confirm.

experimental.optimizePackageImports and barrel files

A barrel file is an index.js that re-exports every symbol from a package. They’re convenient for authors and disastrous for tree-shaking. Many bundlers can’t determine that import { Search } from 'lucide-react' doesn’t transitively reach 600 other icons, so they ship everything. experimental.optimizePackageImports rewrites these imports at build time so only the symbols you reference get bundled.

// next.config.mjs
const nextConfig = {
  experimental: {
    optimizePackageImports: [
      'lucide-react',
      '@radix-ui/react-icons',
      'date-fns',
      'lodash-es',
      'recharts',
      '@heroicons/react/24/outline',
      '@heroicons/react/24/solid',
    ],
  },
};

On a recent audit, adding lucide-react alone dropped First Load JS from 187 KB to 119 KB on a marketing page. A 36% reduction from one config line. That’s the highest ROI line of code you can write this quarter. The official optimizePackageImports reference lists the packages that are auto-optimized without configuration; check that list before adding entries.

Server Components: the cheat code

Here’s the unfair advantage the App Router gives you: Server Components ship zero JavaScript to the browser. Their entire output is HTML plus a tiny serialized payload for hydration of any nested Client Components. A page that uses Server Components for layout, navigation, and data fetching, with a thin Client Component island for the actually-interactive bits, routinely lands under 90 KB First Load JS on a moderately complex dashboard.

The pattern that wins is what I call the “client island”: find the smallest possible component that actually needs interactivity, mark only that with 'use client', and let everything else stay on the server. A common mistake is putting 'use client' at the top of a layout because one child needs useState; that promotes the entire layout subtree to client. The react-compiler won’t save you here. See our guide on the React Compiler in Next.js 16 for what it actually does.

If you need to pass props from a Server Component to a Client Component, those props must be serializable (no functions, no class instances, no Date objects after RSC payload encoding except as ISO strings). The bundler will yell at you in production but silently misbehave in dev. I hit this exact bug shipping a billing page last fall, and the silent dev-mode behavior cost me a full afternoon.

Bundle analysis under Turbopack in Next.js 16

Turbopack is the default bundler in Next.js 16 and changes the analyzer output slightly. @next/bundle-analyzer v15.3+ auto-detects whether you’re on webpack or Turbopack and emits the right format. Under Turbopack the treemap is generated from --turbopack-stats rather than the webpack stats plugin, and chunk names are derived from the file path of the entry rather than webpack’s incrementing IDs. Much easier to read.

# Turbopack-specific stats are emitted automatically when ANALYZE=true
ANALYZE=true next build

# If you want raw Turbopack stats without the analyzer wrapper:
NEXT_TURBOPACK_TRACING=1 next build
# Then drop .next/trace into https://turbo-trace-viewer.vercel.app

So, the trace viewer is genuinely useful for understanding why a build is slow, separate from why a bundle is large. For more on the Turbopack migration, see our Turbopack default bundler guide. The Turbopack architecture docs explain the trace format if you want to write your own tooling.

A real before/after: 412 KB to 117 KB

Last month I audited a B2B SaaS dashboard. The settings page was shipping 412 KB First Load JS, and Largest Contentful Paint on a throttled 3G connection clocked at 4.8 seconds. Here’s the diff, top of the treemap by parsed size:

ModuleBeforeAfterChange
moment + locales71 KB0 KB (replaced with Intl)−71 KB
lucide-react barrel61 KB4 KB (optimizePackageImports)−57 KB
Recharts on settings page142 KB0 KB (next/dynamic, only on /analytics)−142 KB
Layout sidebar (was Client)38 KB2 KB (Server Component + tiny island)−36 KB
Framer Motion (full)34 KB11 KB (LazyMotion + domAnimation)−23 KB
First Load JS412 KB117 KB−72%

LCP on the same throttled connection dropped from 4.8s to 1.6s. Total time spent: about six hours across two days, mostly debugging the Framer Motion LazyMotion API. The bundle analyzer pointed at every one of those wins in the first 90 seconds. I just had to look. If you haven’t run ANALYZE=true on your production app this quarter, that’s the highest-impact hour of work on your roadmap. For broader performance wins, also check our next/image optimization guide. Images are usually the next bottleneck after JS.

Frequently Asked Questions

How big should a Next.js bundle be?

Aim for under 130 KB gzipped First Load JS per route. This aligns with Google’s Core Web Vitals targets for a 2.5-second LCP on median mobile networks. Marketing pages should be under 100 KB; complex dashboards can stretch to 200 KB if every kilobyte is justified by interactivity.

Does @next/bundle-analyzer work with Turbopack?

Yes, since @next/bundle-analyzer 15.3. The wrapper auto-detects whether you’re building with webpack or Turbopack and emits the correct treemap format. No config change needed when migrating to Next.js 16’s default Turbopack bundler.

Why is my Next.js First Load JS so large after upgrading?

Most regressions come from accidentally promoting a Server Component to a Client Component (a stray 'use client' at a high level), or from a dependency upgrade that broke tree-shaking. Run ANALYZE=true next build and diff against the previous deploy’s treemap. The offending module will jump out.

What’s the difference between next/dynamic and React.lazy in Next.js?

next/dynamic is Next.js’s SSR-aware wrapper around React.lazy. It supports an ssr: false flag for client-only components and integrates with Next.js’s loading and error boundaries. Use next/dynamic in App Router code; reserve plain React.lazy for non-Next React libraries.

Will the React Compiler reduce my bundle size?

No. The React Compiler auto-memoizes to reduce render work, not bundle size. It actually adds a small amount of generated code. Bundle wins come from shipping less JavaScript (Server Components, dynamic imports, smaller deps), not from compiler optimizations.

Oliver Schmidt
About the Author Oliver Schmidt

React performance engineer. Lives in DevTools. Will explain to anyone listening why Suspense changes everything.