- Why Bundle Size Matters
- Analyzing Your Bundle with @next/bundle-analyzer
- Setup
- Reading the Treemap
- Other Analysis Tools
- source-map-explorer
- Next.js Built-in Build Output
- Bundlephobia
- Optimization Techniques
- Dynamic Imports for Heavy Components
- Replace Heavy Libraries with Lighter Alternatives
- Tree Shaking: Import What You Need
- Optimize Images and Fonts
- Analyze and Split Shared Chunks
- Use the `optimizePackageImports` Config
- A Practical Workflow
- Conclusion
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:
| Heavy | Lighter Alternative | Savings |
|---|---|---|
moment (300KB) | date-fns (tree-shakeable) | ~280KB |
lodash (70KB full) | lodash-es or individual imports | ~60KB |
font-awesome (full) | lucide-react (tree-shakeable) | Varies |
uuid | crypto.randomUUID() (built-in) | ~12KB |
axios | fetch (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/imagefor 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/fontwhich 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:
- Before adding a dependency, check its size on bundlephobia. Consider whether a native API or a lighter alternative exists
- After adding features, run
npm run analyzeand compare the treemap to your previous baseline. If a new chunk stands out, investigate - 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
- 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.
- Why Bundle Size Matters
- Analyzing Your Bundle with @next/bundle-analyzer
- Setup
- Reading the Treemap
- Other Analysis Tools
- source-map-explorer
- Next.js Built-in Build Output
- Bundlephobia
- Optimization Techniques
- Dynamic Imports for Heavy Components
- Replace Heavy Libraries with Lighter Alternatives
- Tree Shaking: Import What You Need
- Optimize Images and Fonts
- Analyze and Split Shared Chunks
- Use the `optimizePackageImports` Config
- A Practical Workflow
- Conclusion