Bundle Size Audit: How I Reduced My Next.js Bundle by 60%
A step-by-step walkthrough of auditing and shrinking a bloated Next.js bundle — from 420KB to 168KB gzipped — using real tools and techniques.
Last month I ran next build and stared at the output. Our main bundle was 420KB gzipped. For a content site with a blog and some interactive components, that was absurd. Four weeks later, it was down to 168KB. Here's every step I took.
Step 1: Visualize the Damage
Before cutting anything, you need to see what's actually in your bundle. Install @next/bundle-analyzer and add it to your Next.js config:
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
const config = withBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
})({
// your existing config
});
export default config;
Run ANALYZE=true next build and open the generated HTML report. The treemap visualization immediately reveals the biggest offenders. In my case: moment.js (67KB), lodash (full bundle at 71KB), an icon library shipping 4000 icons when we used 12, and a markdown parser pulled in by a component we no longer used.
Step 2: Replace Heavy Dependencies
The easiest wins come from swapping bloated libraries for lighter alternatives. Here's what I replaced:
- moment.js → date-fns: Tree-shakeable, only imports the functions you use. Saved ~60KB.
- lodash → native JS + lodash-es: Most lodash utilities have native equivalents. For the few I kept (
debounce,groupBy), switching tolodash-esenabled tree shaking. Saved ~65KB. - react-icons → lucide-react: Lucide ships individual ESM exports. Each icon is ~1KB instead of pulling the entire icon set.
Step 3: Dynamic Imports for Below-the-Fold
Components that aren't visible on initial load shouldn't be in the main bundle. Next.js makes this trivial with next/dynamic:
import dynamic from "next/dynamic";
// Heavy chart component — only loads when scrolled into view
const AnalyticsChart = dynamic(
() => import("@/components/analytics-chart"),
{
loading: () => <div className="h-64 animate-pulse bg-muted rounded" />,
ssr: false,
}
);
// Rich text editor — only needed on write page
const TipTapEditor = dynamic(
() => import("@/components/editor/tiptap-editor"),
{ ssr: false }
);
This moved about 90KB of JavaScript out of the critical path. The components load on demand when the user actually needs them.
Step 4: Audit Your Barrel Files
Barrel files (index.ts that re-export everything) are tree-shaking killers. If you import one function from a barrel file, the bundler may pull in everything. I restructured our component exports to use direct imports:
- import { Button, Card, Dialog } from "@/components/ui";
+ import { Button } from "@/components/ui/button";
+ import { Card } from "@/components/ui/card";
+ import { Dialog } from "@/components/ui/dialog";
Step 5: Check for Duplicate Dependencies
Run npm ls <package-name> to check for duplicate versions of the same library. I found two versions of zod and three versions of react-dom (from incompatible peer deps). Aligning versions and using npm dedupe saved another 25KB.
Step 6: Leverage Server Components
The biggest architectural win was moving data-fetching components to React Server Components. Server Components send zero JavaScript to the client. Our category sidebar, footer with dynamic links, and article metadata sections all became server-rendered, removing their entire JavaScript cost from the client bundle.
Results
- Before: 420KB gzipped main bundle, 2.1s TTI on 3G
- After: 168KB gzipped, 0.9s TTI on 3G
- Lighthouse Performance: 62 → 94
Takeaways
- Always visualize before optimizing —
@next/bundle-analyzeris non-negotiable - Replace moment.js and full lodash — there's no excuse in 2026
- Dynamic import everything below the fold
- Direct imports over barrel files for UI component libraries
- Server Components are free performance — use them aggressively
Admin
Cal.com
Open source scheduling — tự host booking system, thay thế Calendly. Free & privacy-first.
Bình luận (0)
Đăng nhập để bình luận
Chưa có bình luận nào. Hãy là người đầu tiên!