Our Next.js e-commerce dashboard had grown to a monstrous 2.3MB initial bundle. Lighthouse scores were suffering, and mobile users on 3G connections were experiencing 8+ second load times. Something had to change.
The scary part? We couldn't just hack away at dependencies. The app needed all its features: real-time charts, PDF exports, rich text editing, and complex data tables. The challenge was maintaining functionality while dramatically reducing bundle size.
Analysis & Discovery
First step: understand what's actually in the bundle. I ran the analyzer and discovered three major culprits:
- Charting libraries — Recharts and D3 were accounting for 400KB alone
- Moment.js — The infamous 290KB date library (we only used 5% of it)
- Lodash imports — Importing the full library instead of specific functions
Tools Used
| Tool | Purpose |
|---|---|
@next/bundle-analyzer | Visualize bundle composition |
webpack-bundle-analyzer | Detailed chunk analysis |
compression-webpack-plugin | Gzip/Brotli compression |
terser-webpack-plugin | Advanced minification |
javascript// next.config.js const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }) module.exports = withBundleAnalyzer({ experimental: { optimizePackageImports: ['lodash', 'date-fns', '@mui/material'], }, webpack: (config, { isServer }) => { if (!isServer) { config.optimization.splitChunks = { chunks: 'all', cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all', }, }, } } return config }, })
Solutions Implemented
1. Dynamic Imports with Loading States
The biggest wins came from lazy loading heavy components. Here's how we transformed our chart imports:
typescript// Before - static import import HeavyChart from './HeavyChart' // After - dynamic import with SSR disabled import dynamic from 'next/dynamic' const HeavyChart = dynamic( () => import('./HeavyChart'), { ssr: false, loading: () => <ChartSkeleton /> } )
2. Tree-Shaking Dependencies
We audited all imports and switched to modular versions of libraries:
typescript// ❌ Bad - imports entire library import _ from 'lodash' _.debounce(fn, 300) // ✅ Good - import specific function import debounce from 'lodash/debounce' debounce(fn, 300) // ✅ Better - use esm version import { debounce } from 'lodash-es'
3. Components to Lazy Load
Here's our priority list for dynamic imports:
- Heavy charting libraries (Recharts, D3)
- Rich text editors (TipTap, Quill)
- PDF viewers and generators
- Video players and media components
- Code editors (Monaco, CodeMirror)
- Complex form builders
Results
The results exceeded expectations. Here's the before/after comparison:
| File | Before | After | Reduction |
|---|---|---|---|
main.js | 1.2 MB | 380 KB | -68% |
vendor.js | 890 KB | 420 KB | -53% |
framework.js | 210 KB | 100 KB | -52% |
Total: From 2.3MB to 900KB (60% reduction). Lighthouse performance score jumped from 42 to 89.
Key Lessons
1. Measure First
Don't optimize blindly. Use bundle analyzers to identify actual bottlenecks. We were surprised by which dependencies were the heaviest.
2. Dynamic Imports Are Your Friend
Most heavy components don't need to be in the initial bundle. Good loading states make lazy loading invisible to users.
3. Tree-Shaking Requires Discipline
Establish linting rules to prevent full library imports. One careless import can undo weeks of optimization work.
Bundle optimization is an ongoing process, not a one-time fix. Set up monitoring to catch regressions early. Your users (and your hosting bill) will thank you.