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.
pnpm add -D @next/bundle-analyzer
# then wrap next.configimport 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.
// ❌ 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.
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.
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.
// 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.