Last modified: April 11, 2026
·
12 min read

How to Analyze and Optimize Your Next.js Bundle Size

Your Next.js app works fine locally, then you deploy it and the page takes a long time to load. Or maybe Lighthouse keeps flagging "Reduce unused JavaScript" and you have no idea how to do it. The root cause is almost always the same: you are not optimizing your JavaScript bundle.

This guide covers how to measure your bundle, understand what you're looking at, and apply real optimizations that make a difference.

Why Bundle Size Matters

Every KB of JavaScript you ship has a cost. The browser needs to download it, parse it, compile it, and execute it. On a mid-range phone with a slow 3G connection, a 500KB bundle can take several seconds just to become interactive.

Larger bundles also lower your Core Web Vitals scores, particularly Largest Contentful Paint (LCP) and Interaction to Next Paint (INP). These metrics are essential to achieve a good search ranking. If you're building a content site or landing page, a bloated bundle can undo all your SEO work.

Luckily for you, Next.js already does a lot of heavy lifting with automatic code splitting per route. But that doesn't prevent you from accidentally importing a 200KB charting library on every page, or bundling a full icon set when you use three icons.

Analyzing Your Bundle with @next/bundle-analyzer

Before optimizing, we need a way to measure the bundle and find out the larger libraries.

@next/bundle-analyzer is the official plugin for Next.js. It wraps webpack-bundle-analyzer and generates an interactive treemap of your entire bundle after a production build. It contains every module and every dependency, sized proportionally so you can immediately see what's eating up space.

Setup

Install it as a dev dependency:

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

Then wrap your Next.js config:

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

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

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

export default bundleAnalyzer(nextConfig)

The enabled flag tied to an environment variable makes sure the analyzer only runs when you explicitly ask for it, not on every build.

Add a convenience script to your package.json:

{
  "scripts": {
    "analyze": "ANALYZE=true npm run build"
  }
}

Now run:

npm run analyze

This triggers a full production build and opens an interactive treemap in your browser when it finishes.

Reading the Treemap

The treemap shows nested boxes. Each box represents a module or a chunk, and its size is proportional to how many bytes it contributes to the final bundle. Larger boxes mean larger files.

Here's what to look for:

  • Disproportionately large boxes: If one dependency dominates the view, that's your biggest win. Common large dependencies are moment.js, lodash, icon libraries, or PDF/charting libraries
  • Duplicated modules: Sometimes the same library appears in multiple chunks. This means it's getting bundled more than once, inflating overall size
  • Unexpected dependencies: You might see libraries you didn't import directly. These are transitive dependencies pulled in by something else. Check if there's a lighter alternative

You can toggle between the stat, parsed, and gzip views at the top. The gzip view is the most relevant because that's the size your users actually download over the network.

Other Analysis Tools

@next/bundle-analyzer is the most popular option, but there are other tools worth knowing about.

source-map-explorer

While @next/bundle-analyzer uses a treemap that shows relative sizes, source-map-explorer takes a different approach: it reads the source maps from your build output and traces every byte back to the original source file.

npm install --save-dev source-map-explorer

To use it, you need source maps enabled in your build. Add this to your next.config.ts:

const nextConfig: NextConfig = {
  productionBrowserSourceMaps: true,
}

Then build and run the analysis:

npm run build
npx source-map-explorer .next/static/chunks/*.js

This opens a similar treemap, but the file paths map directly to your source code instead of webpack module ids. It can be easier to trace exactly which file or function is responsible for the bloat.

The downside is that enabling productionBrowserSourceMaps exposes your source code in production. Only enable it temporarily for analysis, then disable it before deploying.

Next.js Built-in Build Output

You don't always need a visual tool. Next.js prints a size summary after every build:

Route (app)                              Size     First Load JS
┌ ○ /                                    5.2 kB         89 kB
├ ○ /about                               1.1 kB         85 kB
├ ○ /tools/json-formatter                12.4 kB        96 kB
└ ○ /posts/some-article                  2.3 kB         86 kB
+ First load JS shared by all routes     83.2 kB

The First Load JS column shows the total JavaScript a user downloads when landing on that route: shared chunks plus page-specific code. If a route shows 200KB+ here, something on that page is pulling in a heavy dependency.

This table is free, always available, and often enough to identify problem routes before reaching for a full treemap.

Bundlephobia

Before you even install a package, check its size on bundlephobia.com. It shows the minified and gzipped size of any npm package, its download time on slow networks, and whether it supports tree shaking.

This isn't a build analysis tool, but it's useful during development when you have to choose which library to install. In this way you will avoid importing something heavy and having to remove it later.

Optimization Techniques

Now that you can see what's in your bundle, here's how to shrink it.

Dynamic Imports for Heavy Components

If a component is heavy and not needed for the initial render, don't ship it upfront. Use next/dynamic to lazy-load it:

import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('@/components/heavy-chart'), {
  loading: () => <p>Loading chart...</p>,
  ssr: false,
})

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <HeavyChart />
    </div>
  )
}

Setting ssr: false means the component is only loaded on the client side, which is what you want for browser-only libraries like charting or PDF tools. The component's code won't appear in the initial page JavaScript at all.

This technique is particularly effective for:

  • Charting libraries (recharts, chart.js, d3)
  • Rich text editors (tiptap, slate, quill)
  • PDF viewers or generators
  • Code editors (Monaco, CodeMirror)
  • Maps (leaflet, mapbox)

Replace Heavy Libraries with Lighter Alternatives

Sometimes the best approach is just changing a dependency:

HeavyLighter AlternativeSavings
moment (300KB)date-fns (tree-shakeable)~280KB
lodash (70KB full)lodash-es or individual imports~60KB
font-awesome (full)lucide-react (tree-shakeable)Varies
uuidcrypto.randomUUID() (built-in)~12KB
axiosfetch (built-in)~14KB

If the browser or the platform already provides the functionality, use it. If you need a library, pick one that supports tree shaking so the bundler can drop the parts you don't use.

Tree Shaking: Import What You Need

Tree shaking eliminates unused exports from your bundle, but it only works if you import correctly.

// Bad: imports the entire library
import _ from 'lodash'
_.debounce(fn, 300)

// Good: imports only debounce
import debounce from 'lodash/debounce'
debounce(fn, 300)
// Bad: pulls in every icon
import * as Icons from 'lucide-react'

// Good: only bundles the icons you use
import { Search, Menu, X } from 'lucide-react'

In order for tree shaking to work, the library must use ES modules (import/export). CommonJS modules (require/module.exports) are opaque to the bundler and get included entirely. When choosing dependencies, prefer packages that ship ESM builds.

Optimize Images and Fonts

This isn't strictly about JavaScript bundles, but it often shows up in performance audits alongside bundle size.

  • Use next/image for automatic format conversion and resizing (or unoptimized images with a CDN for static exports)
  • Self-host fonts instead of loading them from Google Fonts. Use next/font which adds the fonts to your bundle and avoid the external network calls
  • Don't import SVGs as React components unless you need to manipulate them with props. For static icons, use an <img> tag or inline the SVG directly

Analyze and Split Shared Chunks

If the "First load JS shared by all routes" number in your build output is large, it means every page on your site pays that cost. Look at what's inside that shared chunk.

Common culprits:

  • A UI component library imported in the root layout
  • Analytics scripts loaded globally
  • Heavy utility functions used in a few pages but imported from a shared module

Moving imports from layout-level components to page-level components, or lazy-loading them, can reduce the shared chunk size and improve load times across the entire site.

Use the optimizePackageImports Config

Next.js has a built-in config option that automatically transforms barrel imports (re-exports through index.ts files) into direct imports. This helps tree shaking work even when the library's barrel file re-exports everything:

// next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    optimizePackageImports: ['lucide-react', '@radix-ui/react-icons', 'date-fns'],
  },
}

With this config, writing import { Search } from 'lucide-react' gets automatically rewritten to something like import Search from 'lucide-react/dist/esm/icons/search' at build time, so the bundler doesn't even see the other exports.

Next.js already applies this optimization to a few popular packages by default, but you can add any package that uses barrel exports.

A Practical Workflow

Here's a workflow that keeps your bundle in check over time:

  1. Before adding a dependency, check its size on bundlephobia. Consider whether a native API or a lighter alternative exists
  2. After adding features, run npm run analyze and compare the treemap to your previous baseline. If a new chunk stands out, investigate
  3. Check the build output on every PR. The route size table in the Next.js build log catches regressions early, and it takes zero extra tooling
  4. Review dynamic imports periodically. Components that were small at first might grow as features are added. Something that was fine to ship eagerly at 5KB might deserve lazy loading at 50KB

The goal isn't to reach zero kilobytes. It's to make sure every byte you ship earns its place by providing immediate value to the user on that page.

Conclusion

Bundle optimization follows a simple workflow: measure, identify the biggest contributors, apply targeted fixes, then measure again. The tools covered here, from @next/bundle-analyzer to source-map-explorer to the built-in build output, give you visibility at different levels.

Most of the impact comes from a few actions: dynamically importing heavy components, replacing bigger libraries, fixing barrel imports, and being intentional about what loads globally and what loads per-page. None of these are complex changes on their own, but together they can cut your bundle size dramatically and make a real difference in how fast your site feels.