Skip to content
Back to Blog
ReactNext.jsPerformanceBundle SizeCode SplittingTree Shaking

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.

14 min read

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 build
Route (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 build

This 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 + DatePicker

This 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 libraryApprox. gzippedLighter alternativeWhy it's smaller
moment~72KBdate-fns / dayjsTree-shakeable functions, no bundled locales
lodash (default import)~71KBlodash-es or native JSES modules tree-shake per-function
chart.js / heavy chart libs~65KB+lightweight-chartsFocused feature set
react-icons (barrel)40KB+per-package icon importsShip only the icons you render
framer-motion~44KBmotion (lite) or CSS transitionsLess 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 knip

It'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

TechniqueTypical impactEffortWhen to reach for it
Bundle analyzerDiagnosticLowAlways, first
Server Components (App Router)30-50% client JSLow-MedAny non-interactive UI
Kill barrel imports / optimizePackageImports20-60% on affected routesLowIcon and UI library imports
Swap heavy libraries30-60KB eachLowmoment, full lodash, etc.
Named imports + sideEffects30-50%LowEvery project
Dynamic import (next/dynamic, React.lazy)Defers 20KB+ chunksLow-MedBelow-fold, interaction-gated heavy UI
next/font, next/image, next/scriptVariesLowFonts, images, third-party tags
knip unused-dep auditVariesLowBefore 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.