How I cut Next.js bundle size by analysing and reducing chunk bloat
May 28, 2026 · 5 min read
Next.js bundle size analysis is one of the highest-ROI performance tasks you can do — it takes an afternoon, and the wins compound across every page. Here is exactly how I ran it on a client e-commerce site and cut first-load JS from 487 kB to 301 kB.
Setting up @next/bundle-analyzer
Install the package and wire it into next.config.ts.
// next.config.ts
import type { NextConfig } from 'next';
import withBundleAnalyzer from '@next/bundle-analyzer';
const withAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: true,
});
const nextConfig: NextConfig = {
// your existing config
};
export default withAnalyzer(nextConfig);Then run:
ANALYZE=true next buildThis opens two treemap files in your browser — one for the client bundle, one for the server bundle. The client treemap is the one to fix first because it is what your users download. Every rectangle you hover over shows the parsed size; sort by largest first and write them down before touching any code.
What I found on the client treemap
The project had three obvious offenders:
1. date-fns (41 kB parsed) — the code imported from the top-level package rather than the specific functions:
// before — pulls the full lib
import { format, parseISO } from 'date-fns';That sounds fine in modern bundlers but date-fns v2 ships a CommonJS barrel that webpack could not tree-shake cleanly. The fix was switching to date-fns v3, which ships proper ESM, and confirming sideEffects: false was respected. Parsed size dropped from 41 kB to 7 kB.
2. lucide-react icon barrel (112 kB parsed) — this was the biggest single win. The project imported icons from the package index:
// before — entire icon library loaded into every chunk that touches icons
import { ShoppingCart, Search, User, ChevronDown } from 'lucide-react';Webpack was not splitting this correctly because the icon barrel re-exports hundreds of SVG components and each component file had a small side effect (a forwardRef wrapper). The treemap showed lucide-react sitting as a solid 112 kB block inside the shared vendor chunk, meaning it shipped on every single route.
The fix was explicit path imports:
// after — each icon is its own 1-2 kB module
import ShoppingCart from 'lucide-react/dist/esm/icons/shopping-cart';
import Search from 'lucide-react/dist/esm/icons/search';
import User from 'lucide-react/dist/esm/icons/user';
import ChevronDown from 'lucide-react/dist/esm/icons/chevron-down';This dropped the vendor chunk from 198 kB to 88 kB. If you prefer keeping the cleaner import syntax, add a modularizeImports rule in next.config.ts:
// next.config.ts (addition)
modularizeImports: {
'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{kebabCase member}}',
},
},With modularizeImports Next.js rewrites the barrel imports at build time so you keep the readable syntax in source and get the per-icon splitting for free.
3. An unsplit vendor chunk — the treemap showed a _app-[hash].js chunk of 214 kB that bundled together: Radix Dialog, Radix Dropdown, a rich-text editor used only on the account page, and the full Zod library. None of these were dynamically imported; webpack had no reason to split them.
The rich-text editor (@tiptap/react + extensions, ~68 kB) was the easiest target. It only appeared on one route behind authentication. Wrapping it with next/dynamic and ssr: false moved it entirely out of the shared chunk:
// app/account/profile/page.tsx
import dynamic from 'next/dynamic';
const BioEditor = dynamic(() => import('@/components/bio-editor'), {
ssr: false,
loading: () => <div className="h-32 animate-pulse rounded-md bg-zinc-100" />,
});Zod (22 kB parsed) was imported in a shared form validation module that every page touched. Rather than lazy-loading Zod itself, I audited which schemas were actually needed on the server vs the client. Moving server-only validation into route handlers dropped Zod out of the client bundle entirely — it now runs only at the edge.
Before and after numbers
| Chunk | Before | After | |---|---|---| | Shared vendor JS | 198 kB | 88 kB | | First-load JS (homepage) | 487 kB | 301 kB | | First-load JS (account page) | 487 kB | 369 kB (editor lazy) | | date-fns contribution | 41 kB | 7 kB | | lucide-react contribution | 112 kB | ~4 kB per route |
Lighthouse on a mid-range Android device (throttled 4G) showed FCP improve from 2.4 s to 1.6 s and TBT drop from 380 ms to 140 ms. The TBT improvement mattered most for INP — less JS parsing on load means the main thread is free sooner.
What to check on every new project
Run the analyzer before adding any significant library, not just when you notice slowness. The three patterns that create recurring bloat:
- Barrel-import icon libraries: lucide-react, react-icons, @heroicons/react all have the same problem. Always use path imports or
modularizeImports. - Shared utility modules: anything imported in more than five places ends up in the vendor chunk. Audit whether each utility actually needs to be client-side.
- Heavy components behind auth or toggles: rich-text editors, drag-and-drop, chart libraries, and map components should almost always be lazy-loaded. They are never on the critical path.
The analyzer output is also useful in scope conversations with clients. A screenshot of a 112 kB icon chunk is far more persuasive than explaining tree-shaking in the abstract.
Related posts
All posts →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.
How I Shaved 140 kB Off a Next.js Bundle by Lazy-Loading Sanity Portable Text
Apr 27, 2026 · 5 min read
Portable Text blocks can bloat client bundles. Here's how I defer serializers with dynamic imports and RSC boundaries to keep marketing pages under 80 kB.
Sanity vs WordPress headless CMS: when headless actually beats traditional
May 20, 2026 · 7 min read
Sanity vs WordPress headless CMS compared on editor UX, performance, dev experience, and real migration numbers. Honest about where WordPress still wins.