Ship Less JavaScript: Cutting React and Next.js Bundle Size Without Guesswork
A measurement-first walkthrough of what actually bloats React and Next.js bundles, from barrel files and client boundaries to dynamic imports and library swaps, with the exact code and config to fix each one.
TL;DR: Most bundle bloat comes from four sources: barrel-file imports that pull in a whole library, components marked 'use client' that could run on the server, heavy libraries that don't tree-shake, and code loaded up front that nobody sees until later. Measure with the bundle analyzer first, then fix in order of payoff: move work to Server Components, kill barrel imports (or enable optimizePackageImports), swap heavy deps, and dynamically import the genuinely large stuff.
I once shipped a marketing page that pulled in a 65KB charting library. The page had no charts. The chart lived in a dashboard three routes away, but a shared index.ts re-exported it, and one utility import dragged the whole thing along for the ride.
That's the thing about bundle size: the code you ship and the code you use drift apart quietly. Nobody writes import Chart from 'heavy-chart-lib' on the homepage. It sneaks in through a re-export, a client boundary drawn too high, or a library that looked small in the docs and turned out to be 70KB gzipped.
This post is the field guide I wish I'd had. It's measurement-first, because guessing at bundle size is how you spend an afternoon optimizing a 4KB module while a 200KB one sits untouched.
Measure First, Always
Before you change a single import, get numbers. Next.js prints them after every production build:
next buildRoute (app) Size First Load JS
┌ ○ / 1.2 kB 98 kB
├ ○ /dashboard 8.4 kB 310 kB
└ ○ /settings 2.1 kB 102 kB
+ First Load JS shared by all 88 kB
First Load JS is the number that matters. It's the JavaScript a user downloads before the route becomes interactive. The shared chunk at the bottom is loaded on every route, so weight there is the most expensive weight you have. A rough rule: under ~130KB First Load JS is healthy, anything above 250KB per route is worth investigating, and above 500KB is a problem.
For the treemap that tells you which packages are landing in each chunk, use the analyzer:
npm install --save-dev @next/bundle-analyzer// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your config
});ANALYZE=true next buildThis opens an interactive treemap. Big rectangles are your targets. For a plain React app on Vite, rollup-plugin-visualizer does the same job. Everything below is a response to something you see in that treemap, not a checklist to apply blindly.
One caveat before you read the treemap: what you see there is uncompressed-ish size. Your CDN serves Brotli or gzip on top, which typically shaves 70-80% off. So a "100KB" module in the treemap is more like 25-30KB over the wire. Compare like with like, and confirm Brotli is actually enabled on your host.
The Barrel File Trap
A barrel file is an index.ts that re-exports a directory:
// components/index.ts
export { Button } from './Button';
export { Modal } from './Modal';
export { Chart } from './Chart'; // pulls in a 65KB charting lib
export { DatePicker } from './DatePicker';It feels tidy. You get import { Button } from '@/components' everywhere. But here's the problem: when a bundler can't guarantee a module is side-effect-free, importing one name from the barrel can drag in all of them.
// You wrote this, expecting to ship a button
import { Button } from '@/components';
// The bundler may have shipped Button + Modal + Chart + DatePickerThis is the single most common source of accidental bloat I see, and it hides well because the import statement looks minimal. The fix is direct imports:
// ✅ imports exactly one module, nothing else can hitchhike
import { Button } from '@/components/Button';You can enforce this at lint time so it doesn't regress:
// .eslintrc: flags imports from barrel files
{
"rules": {
"no-restricted-imports": [
"error",
{ "patterns": ["@/components/index", "@/components"] }
]
}
}The same trap exists inside third-party libraries. import { Search } from 'lucide-react' or import { Button } from '@mui/material' reach through the library's own barrel, and historically that meant parsing thousands of modules just to get one icon.
Let Next.js Rewrite Your Imports
Next.js has a built-in fix for the library-barrel problem: optimizePackageImports. It transforms a barrel import into direct imports at build time, so you keep the ergonomic import syntax but only ship what you use.
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ['@acme/ui', 'my-icon-library'],
},
};A long list of popular libraries is already optimized by default, so you don't need to add them yourself. As of late 2025 that includes lucide-react, date-fns, lodash-es, @mui/material, @mui/icons-material, recharts, @headlessui/react, @heroicons/react/*, react-icons/*, @tabler/icons-react, ramda, antd, rxjs, and more. You only reach for the config when you have a different barrel-heavy dependency (or your own internal component library).
Note that optimizePackageImports is still flagged experimental in the Next.js docs, though it's widely used in production. If you want a stable, explicit alternative, modularizeImports rewrites imports with a template and has been stable for a long time:
// next.config.js: turns a barrel import into a per-icon path import
module.exports = {
modularizeImports: {
'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{kebabCase member}}',
},
},
};With that, import { Search, Menu } from 'lucide-react' compiles to two direct path imports. The difference between the two: optimizePackageImports figures out the paths automatically, modularizeImports needs you to describe the path template. Prefer the former unless you hit its limits.
Swap Libraries That Refuse to Shrink
Some libraries are heavy no matter how carefully you import them, usually because they're authored as a single CommonJS blob that can't tree-shake. The classic offender is moment.js: every import pulls the whole thing plus all locales.
Check what a dependency actually costs before and after with Bundlephobia, then reach for lighter equivalents:
| Heavy library | Approx. gzipped | Lighter alternative | Why it's smaller |
|---|---|---|---|
moment | ~72KB | date-fns / dayjs | Tree-shakeable functions, no bundled locales |
lodash (default import) | ~71KB | lodash-es or native JS | ES modules tree-shake per-function |
chart.js / heavy chart libs | ~65KB+ | lightweight-charts | Focused feature set |
react-icons (barrel) | 40KB+ | per-package icon imports | Ship only the icons you render |
framer-motion | ~44KB | motion (lite) or CSS transitions | Less runtime for simple animations |
Two details that matter more than the swap itself:
Import the ES build, per function. With Lodash, the import style is the optimization:
// ❌ pulls the entire lodash bundle
import _ from 'lodash';
_.debounce(fn, 200);
// ✅ tree-shakeable, ships only debounce
import debounce from 'lodash-es/debounce';Ask whether you need the library at all. A surprising amount of Lodash is now one line of native JavaScript. _.uniq(arr) is [...new Set(arr)]. _.flatten(arr) is arr.flat(). Deleting a dependency is the only optimization with zero runtime cost and zero maintenance.
The Biggest Lever: Server Components
Everything above trims kilobytes. Server Components delete them wholesale. In the Next.js App Router, every component is a Server Component by default, and Server Components ship zero JavaScript to the browser. Their code runs on the server, and only the resulting HTML crosses the wire.
The mistake that quietly inflates App Router bundles is marking too much as 'use client'. The moment a file has 'use client' at the top, that file and everything it imports becomes part of the client bundle.
// ❌ 'use client' at the page level drags the whole tree to the client
'use client';
import { Sidebar } from './Sidebar';
import { DataTable } from './DataTable';
import { formatCurrency } from './format';
export default function Dashboard({ data }) {
const [tab, setTab] = useState('overview');
return (
<>
<Sidebar /> {/* now client-side for no reason */}
<Tabs value={tab} onChange={setTab} />
<DataTable data={data} /> {/* now client-side for no reason */}
</>
);
}The only thing that actually needs interactivity here is the tabs. Push the boundary down to the leaf that needs it:
// ✅ page stays a Server Component; only Tabs is a Client Component
import { Sidebar } from './Sidebar';
import { DataTable } from './DataTable';
import { Tabs } from './Tabs'; // this file has 'use client'
export default function Dashboard({ data }) {
return (
<>
<Sidebar /> {/* server-rendered, 0 JS */}
<Tabs /> {/* client island */}
<DataTable data={data} /> {/* server-rendered, 0 JS */}
</>
);
}The rule I use: if a component doesn't call useState, useEffect, an event handler, or a browser-only API, it should not be a Client Component. Data fetching, markdown rendering, syntax highlighting, date formatting, and static layout are all ideal Server Component work. Teams routinely report 30-50% client bundle reductions from drawing these boundaries correctly, without changing a line of application logic. Only where it runs changed.
One composition trick makes this easier: a Client Component can render Server Components passed as children. So an interactive shell can wrap server-rendered content without forcing that content onto the client.
// Accordion is 'use client', but its content stays server-rendered
<Accordion>
<ExpensiveServerRenderedReport data={data} />
</Accordion>Defer the Heavy Stuff: Dynamic Imports
Server Components remove code from the client. Dynamic imports remove code from the initial load, deferring it until it's actually needed. Both React and Next.js build this on one language primitive: the dynamic import() expression, which the bundler treats as a split point and emits as a separate chunk.
In plain React, React.lazy plus Suspense is the API:
import { lazy, Suspense } from 'react';
// This chunk only downloads when <Editor /> actually renders
const Editor = lazy(() => import('./RichTextEditor'));
function CommentBox() {
const [editing, setEditing] = useState(false);
if (!editing) return <button onClick={() => setEditing(true)}>Reply</button>;
return (
<Suspense fallback={<EditorSkeleton />}>
<Editor />
</Suspense>
);
}React.lazy requires a default export. If your module only has named exports, wrap it:
const Chart = lazy(() =>
import('./Chart').then(m => ({ default: m.Chart }))
);In Next.js, next/dynamic wraps the same mechanism with SSR control, which matters because some components can't run on the server at all:
import dynamic from 'next/dynamic';
// A map that touches window, skip SSR entirely
const Map = dynamic(() => import('./Map'), {
ssr: false,
loading: () => <MapSkeleton />,
});Use ssr: false only when the component genuinely needs the browser (maps, canvas, anything reading window). Turning it off unnecessarily hurts your initial paint.
The honest caveat: dynamic imports are not a blanket performance win. Each one adds a network round trip at the moment of use, which can hurt INP if the user needs the component immediately. Reserve them for things that are both large and not needed on first render: rich text editors, charting libraries, maps, PDF viewers, heavy modals. Lazy-loading a Button costs more in flicker and complexity than it saves.
A practical size heuristic: dynamically import components over ~20KB that live below the fold or behind an interaction. Leave everything above the fold in the main bundle.
Tree Shaking Won't Save Broken Modules
Tree shaking (dead-code elimination) is automatic in production builds, but it only works when the code is shaped to allow it. Two things break it:
Namespace imports. They tell the bundler you might use anything:
// ❌ imports the whole module namespace
import * as utils from './utils';
utils.formatDate(d);
// ✅ named import the bundler can trace
import { formatDate } from './utils';Undeclared side effects. If a package might do something on import (patch a global, register a plugin), the bundler keeps it to be safe. If your own package is genuinely side-effect-free, say so:
// package.json: lets the bundler drop unused exports
{
"sideEffects": false
}If some files do have side effects (CSS imports, polyfills), list them instead of claiming purity:
{
"sideEffects": ["*.css", "./src/polyfills.ts"]
}Getting sideEffects wrong in the pessimistic direction (claiming false when it isn't) silently disables tree shaking for your package. Getting it wrong in the optimistic direction drops code you needed. Be precise.
Don't Forget the Non-JS Weight
Bundle size isn't only JavaScript. Next.js ships first-party primitives that keep fonts, images, and third-party scripts from becoming their own bloat:
// next/font: self-hosts and subsets fonts, no layout shift, no extra request
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });// next/image: automatic WebP/AVIF, lazy loading, correct sizing
import Image from 'next/image';
<Image src="/hero.jpg" alt="" width={1200} height={600} priority />;// next/script: defer non-critical third-party scripts off the critical path
import Script from 'next/script';
<Script src="https://analytics.example.com/tag.js" strategy="lazyOnload" />;Analytics tags, chat widgets, and A/B testing snippets are often heavier than your app code and load render-blocking by default. strategy="lazyOnload" pushes them to after the page is interactive.
The Over-Splitting Trap
It's possible to overcorrect. Split your bundle into too many tiny chunks and you trade one problem for another: dozens of HTTP requests, waterfalls where chunk A must load before the browser discovers it needs chunk B, and metadata overhead that eats the savings.
Modern bundlers mitigate this (Vite preloads code-split imports, Turbopack parallelizes shared chunks), but the principle holds: aim for meaningfully sized chunks, roughly 50-500KB gzipped, not a swarm of 2KB files. Keep your entry chunk lean, group rarely-used heavy libraries into their own chunk, and never lazy-load the app shell (navbar, header, search) that the user sees instantly.
The discipline is the same one that started this post: measure, then split only what the treemap says is fat. Splitting on instinct is how waterfalls are born.
Find the Dead Weight You Forgot About
Codebases accumulate dependencies that nobody imports anymore. knip finds unused files, exports, and dependencies in one pass:
npx knipIt'll surface the chart library you removed the feature for six months ago but never uninstalled, plus dead exports that keep barrel files fat. Run it before a bundle audit, not after.
Quick Reference
| Technique | Typical impact | Effort | When to reach for it |
|---|---|---|---|
| Bundle analyzer | Diagnostic | Low | Always, first |
| Server Components (App Router) | 30-50% client JS | Low-Med | Any non-interactive UI |
Kill barrel imports / optimizePackageImports | 20-60% on affected routes | Low | Icon and UI library imports |
| Swap heavy libraries | 30-60KB each | Low | moment, full lodash, etc. |
Named imports + sideEffects | 30-50% | Low | Every project |
Dynamic import (next/dynamic, React.lazy) | Defers 20KB+ chunks | Low-Med | Below-fold, interaction-gated heavy UI |
next/font, next/image, next/script | Varies | Low | Fonts, images, third-party tags |
knip unused-dep audit | Varies | Low | Before every audit |
The pattern under all of this is the same discipline you'd apply to any performance work: the code you think you ship and the code you actually ship are two different things, and only measurement closes the gap. The barrel import that looks like one component. The 'use client' that looks like one file. The library that looked small in the README. None of them announce themselves in the source.
So open the analyzer, find the biggest rectangle, and ask one question: does the user on the homepage actually need this? Most of the time, the answer is no, and the fix is moving a boundary or deleting an import. The best kilobyte is the one you never ship.