TK
All writing
Performance

Cutting a Next.js bundle by 89% without losing a feature

A disciplined, measurement-first approach to shrinking a bloated Next.js app — route-level code splitting, dynamic imports, barrel-file surgery and bundle analysis — that took first-load JS from ~1.1 MB to ~120 KB.

Tanveer Khan9 min read

Every slow React app I have inherited tells the same story: nobody set out to ship a megabyte of JavaScript. It accreted — a date library here, a charting dependency there, a barrel file that re-exports an entire design system from one import. This is the exact process I used to take one Next.js dashboard from a 1.1 MB first-load bundle down to ~120 KB, without removing a single user-facing feature.

Measure before you touch anything

The cardinal rule: never optimise a bundle you have not measured. Guessing is how you spend a day shaving 4 KB off a dependency that was already tree-shaken. Start with the analyzer so you are cutting the things that are actually large.

terminal
bash
pnpm add -D @next/bundle-analyzer
# then wrap next.config
next.config.ts
ts
import withBundleAnalyzer from "@next/bundle-analyzer";

const analyze = withBundleAnalyzer({
  enabled: process.env.ANALYZE === "true",
});

export default analyze({
  // ...your existing Next config
});

Run it with ANALYZE=true next build and you get an interactive treemap. The first run on this app showed three offenders eating ~60% of the bundle: a moment-based date stack, a charting library imported in full, and an icon package pulled in through a barrel export.

Kill barrel-file imports

A barrel file (index.ts that re-exports everything) is the single most common cause of accidental bloat. When you import one icon from a barrel, a naive bundler can pull the whole module graph behind it. The fix is to import from the exact path.

before-after.ts
ts
// ❌ pulls the entire icon set into the graph
import { ArrowRight } from "@/components/icons";

// ✅ imports exactly one icon's module
import { ArrowRight } from "@/components/icons/arrow-right";

Next.js has optimizePackageImports for exactly this. Adding a package to that list makes Next rewrite barrel imports to deep imports automatically — the cheapest win in this whole list.

next.config.ts
ts
const nextConfig = {
  experimental: {
    optimizePackageImports: ["lucide-react", "date-fns", "@/components/icons"],
  },
};

Split at the route, not the component

The biggest structural win is code splitting on the thing the user is least likely to need on first paint. A charting dashboard panel, a rich text editor, a modal that only opens on click — none of these belong in the initial bundle. next/dynamic defers them until render.

compose-panel.tsx
tsx
import dynamic from "next/dynamic";

// The editor is ~180 KB. Nobody sees it until they click "Compose",
// so it has no business being in the first-load bundle.
const RichTextEditor = dynamic(
  () => import("@/components/rich-text-editor"),
  {
    ssr: false,
    loading: () => <EditorSkeleton />,
  },
);

The ssr: false matters for anything that touches window or ships a heavy client-only dependency: it keeps that code out of the server bundle entirely and off the critical path.

Replace heavyweight dependencies

The date library was the easiest 70 KB I have ever deleted. The app used three functions from it. I replaced them with date-fns (fully tree-shakeable, import exactly what you call) and, for the trivial cases, the native Intl API that ships with every browser.

format-date.ts
ts
// 70 KB dependency -> 0 KB, for a format string
const formatted = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium",
  timeStyle: "short",
}).format(new Date(iso));

The results

  • First-load JS: 1.1 MB → ~120 KB (−89%).
  • Largest Contentful Paint: 3.4 s → 1.1 s on a throttled 4G profile.
  • Total Blocking Time dropped below the 200 ms 'good' threshold.
  • Zero features removed — every change was structural, not functional.

Performance is a ratchet, not a one-off. I added the analyzer to CI with a size budget so a future dependency that blows the budget fails the build instead of silently re-bloating the app six months later.

The lesson that generalises: bundle size is an architecture problem, not a minification problem. You win it by deciding what belongs on the critical path and ruthlessly deferring everything else — then wiring a budget so the win sticks.