How I use @next/bundle-analyzer to find and fix Next.js bundle size bloat
Jun 05, 2026 · 5 min read
Next.js bundle size analysis is one of those tasks that feels optional until your Lighthouse score tanks and a client asks why the page takes four seconds to load. This post walks through the exact process I used on a recent content site: installing @next/bundle-analyzer, reading the treemap output, and cutting 210 kB from the client bundle in an afternoon.
Setting up @next/bundle-analyzer
Install the package and wrap your Next.js config:
// 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)Run the analysis with:
ANALYZE=true next buildThis opens two HTML treemaps in your browser: one for the client bundle, one for the server bundle. The client treemap is almost always where the problem lives.
Reading the treemap output
The treemap groups chunks by size. Bigger rectangle = more bytes in the client bundle. The first thing I look at is whether I can see node_modules chunks that are larger than my own application code. On the project in question, I immediately spotted three problems.
Problem 1 — date-fns fully included (47 kB). The codebase imported format and parseISO from date-fns/index.js rather than the subpath exports. Webpack bundled the entire library.
Problem 2 — lucide-react barrel import (89 kB). A UI component file had import { Calendar, Clock, ChevronDown, ... } from 'lucide-react'. That barrel import forced the bundler to include every icon in the package — 1,000+ icons the app did not need.
Problem 3 — unsplit vendor chunk with @radix-ui/react-dialog and @radix-ui/react-popover (74 kB combined). Both components were imported in a layout that wrapped every page, so they were never code-split — they landed in the main client chunk alongside first-paint code.
Total before: ~412 kB first-load JS (gzipped: 118 kB). Target: under 200 kB gzipped.
Fix 1: subpath imports for date-fns
The fix is a one-line change per import site:
// Before (pulls in full date-fns barrel)
import { format, parseISO } from 'date-fns'
// After (subpath imports — only these two functions bundled)
import { format } from 'date-fns/format'
import { parseISO } from 'date-fns/parseISO'If you have many files, an ESLint rule like no-restricted-imports can enforce subpath imports across the codebase. After this change the date-fns contribution to the client chunk dropped from 47 kB to 3.1 kB.
Fix 2: individual icon imports from lucide-react
Lucide ships individual modules under lucide-react/dist/esm/icons/. Import from there directly:
// Before — barrel import, bundles every icon
import { Calendar, Clock, ChevronDown } from 'lucide-react'
// After — individual imports
import Calendar from 'lucide-react/dist/esm/icons/calendar'
import Clock from 'lucide-react/dist/esm/icons/clock'
import ChevronDown from 'lucide-react/dist/esm/icons/chevron-down'If this pattern is too verbose across a large codebase, create a thin re-export file at src/components/icons/index.ts that centralises the individual imports. That way call sites keep the same import shape and you fix the barrel in one place.
After switching: 89 kB → 7.4 kB. The three icons the app actually used weigh about 2.5 kB each.
Fix 3: lazy-load Radix primitives that are not on the critical path
The dialog and popover were used in a header navigation component that appeared on every page — but neither is visible on first paint. Wrapping them in dynamic with ssr: false moves them out of the main chunk:
// src/components/nav/search-dialog.tsx
import dynamic from 'next/dynamic'
const SearchDialog = dynamic(
() => import('@/components/nav/search-dialog-inner'),
{ ssr: false, loading: () => null }
)
export default SearchDialogThe search-dialog-inner file contains the Radix Dialog import and everything that depends on it. Next.js now splits this into a separate chunk that only loads when the component is first rendered — which for most users means after the first user interaction.
The Radix chunk (74 kB combined) moved entirely out of the main bundle. It still loads, but lazily, and it no longer blocks the initial parse.
Before and after numbers
| Metric | Before | After | Delta | |---|---|---|---| | First-load JS (raw) | 412 kB | 201 kB | −211 kB | | First-load JS (gzip) | 118 kB | 58 kB | −60 kB | | LCP (mobile, Slow 4G) | 4.1 s | 2.6 s | −1.5 s | | Lighthouse Performance | 61 | 84 | +23 pts |
The LCP improvement came almost entirely from main-thread parse time dropping — the browser was spending ~400 ms parsing JavaScript that had nothing to do with the first-paint content.
What to check on every project
When I do a bundle analysis pass on a new project, I look for these signals in the treemap:
- Any single
node_modulesrectangle larger than 30 kB that isn't a polyfill or charting library - Icon library rectangles (
lucide-react,react-icons,heroicons) — they are almost always barrel-imported - Date and utility libraries (
date-fns,lodash,ramda) not using subpath imports - Radix or other UI primitive libraries in the main chunk when they are not needed on first paint
- Duplicate copies of React or a library (two rectangles for the same package name means a version conflict somewhere in your dependency tree)
The @next/bundle-analyzer treemap makes all of this visible in under ten minutes. Running it before every major dependency upgrade catches regressions before they reach production.
Related posts
All posts →How I cut Next.js bundle size by analysing and reducing chunk bloat
May 28, 2026 · 5 min read
A practical walkthrough of Next.js bundle size analysis using @next/bundle-analyzer, with before/after numbers from a real project.
Next.js partial prerendering in production: an honest assessment
May 29, 2026 · 6 min read
PPR promises static shells with dynamic holes. Here is what Next.js partial prerendering actually solves in production, where it breaks, and what changes.
How I wire Mux video into a Next.js + Sanity CMS upload workflow
May 26, 2026 · 6 min read
Step-by-step guide to Mux video in Next.js and Sanity CMS: plugin setup, direct uploads, signed playback URLs, and poster images for fast LCP.