Analyze Next.js bundle sizes with next/bundle-analyzer, identify and eliminate large dependencies, implement route-based code splitting, and measure impact on Lighthouse performance scores.
JavaScript bundle size directly determines how long users wait before they can interact with your application. Every kilobyte of JavaScript must be downloaded, parsed, compiled, and executed before the main thread is free. Total Blocking Time (TBT) — a key Lighthouse metric — measures exactly this cost.
A typical unoptimized Next.js app ships 500-800 KB of JavaScript to the initial page load. Optimized apps ship under 150 KB for landing pages. This lesson teaches you to measure what is in your bundle, identify unnecessary weight, and eliminate it.
1npm install @next/bundle-analyzer1// next.config.js
2const withBundleAnalyzer = require('@next/bundle-analyzer')({
3 enabled: process.env.ANALYZE === 'true',
4});
5
6/** @type {import('next').NextConfig} */
7const config = {
8 experimental: {
9 optimizePackageImports: ['lucide-react', '@radix-ui/react-icons'],
10 },
11};
12
13module.exports = withBundleAnalyzer(config);1// package.json
2{
3 "scripts": {
4 "analyze": "ANALYZE=true next build",
5 "analyze:win": "set ANALYZE=true && next build"
6 }
7}Running npm run analyze builds the app and opens two interactive treemap visualizations:
The treemap shows every module by parsed size (after minification, before compression). Width = size. Look for unexpectedly large rectangles — anything over 50 KB warrants investigation.
Common oversized dependencies and their replacements:
moment.js (230 KB) → date-fns (tree-shakeable, only import what you use):
1// BAD: imports entire 230 KB moment bundle
2import moment from 'moment';
3const formatted = moment(date).format('MMMM Do YYYY');
4
5// GOOD: date-fns is tree-shakeable — only 2 KB for these functions
6import { format, formatDistanceToNow } from 'date-fns';
7const formatted = format(new Date(date), 'MMMM do yyyy');
8const relative = formatDistanceToNow(new Date(date), { addSuffix: true });lodash (70 KB) → Native ES methods:
1// BAD: imports entire lodash
2import _ from 'lodash';
3const unique = _.uniq(arr);
4const grouped = _.groupBy(items, 'category');
5
6// GOOD: native ES2019+
7const unique = [...new Set(arr)];
8const grouped = items.reduce<Record<string, typeof items>>((acc, item) => {
9 (acc[item.category] ??= []).push(item);
10 return acc;
11}, {});ProseMirror/Quill rich text editors (400-800 KB) → Dynamic import:
1// Only load the editor when the user clicks 'Edit'
2const RichEditor = dynamic(() => import('@/components/RichEditor'), {
3 loading: () => <div>Loading editor...</div>,
4 ssr: false,
5});@faker-js/faker (found in production builds): Never import test utilities in production code. Use devDependencies and verify with bundle analyzer.
next/dynamic wraps React.lazy with SSR support and loading states. Use it for any component > 50 KB that is not needed on initial render.
1import dynamic from 'next/dynamic';
2
3// Code editor — 800 KB, only needed on lesson pages
4const CodeEditor = dynamic(
5 () => import('@monaco-editor/react').then((mod) => mod.Editor),
6 {
7 loading: () => (
8 <div className='h-64 animate-pulse rounded-lg bg-gray-200' />
9 ),
10 ssr: false, // Monaco requires browser APIs
11 }
12);
13
14// Chart library — 400 KB, only needed on analytics dashboard
15const ProgressChart = dynamic(
16 () => import('@/components/analytics/ProgressChart'),
17 { ssr: false }
18);
19
20// Video player — 300 KB
21const VideoPlayer = dynamic(
22 () => import('@/components/VideoPlayer'),
23 {
24 loading: () => <VideoPlayerSkeleton />,
25 ssr: false,
26 }
27);
28
29// Route-level splitting happens automatically in Next.js App Router
30// Each page.tsx is a separate chunk — components shared across pages
31// go into shared chunks via Next.js chunk optimizationAfter dynamic imports, re-run npm run analyze to verify the component moved from the initial bundle to a separate chunk.
Tree shaking eliminates unused exports from JavaScript bundles. It works when:
export function ... not module.exports = ...)1// BAD: imports entire lucide-react (1000+ icons = 2 MB+)
2import * as Icons from 'lucide-react';
3const icon = <Icons.ChevronRight />;
4
5// GOOD: import only the icons you use (< 1 KB each)
6import { ChevronRight, Star, BookOpen } from 'lucide-react';
7const icon = <ChevronRight />;
8
9// Verify tree shaking is working in next.config.js:
10const config = {
11 experimental: {
12 // Tells Next.js to only import used exports from these packages
13 optimizePackageImports: [
14 'lucide-react',
15 '@radix-ui/react-icons',
16 '@heroicons/react',
17 ],
18 },
19};
20
21// For packages that don't support tree shaking:
22// Use babel-plugin-transform-imports or direct path imports:
23import debounce from 'lodash/debounce'; // instead of: import { debounce } from 'lodash'Check tree shaking effectiveness: after removing an icon import, run npm run analyze and verify the icon's parsed size is gone from the bundle.
Bundle size reduction has a predictable impact on Lighthouse metrics. Use this workflow to quantify improvements.
1# 1. Measure baseline
2npm run analyze
3# Record: initial JS (client) total parsed size
4
5npm run build && npx lighthouse http://localhost:3000 \
6 --output=json \
7 --output-path=reports/baseline.json \
8 --chrome-flags='--headless' \
9 --quiet
10
11# 2. Apply optimization (remove moment.js, add dynamic imports)
12# 3. Measure again
13npm run analyze
14npx lighthouse http://localhost:3000 \
15 --output=json \
16 --output-path=reports/optimized.json \
17 --chrome-flags='--headless'
18
19# 4. Compare results with this script:
20node scripts/compare-lighthouse.js1// scripts/compare-lighthouse.ts
2import baseline from '../reports/baseline.json';
3import optimized from '../reports/optimized.json';
4
5const metrics = [
6 'first-contentful-paint',
7 'largest-contentful-paint',
8 'total-blocking-time',
9 'speed-index',
10 'interactive',
11];
12
13console.log('=== Bundle Optimization Impact ===\n');
14for (const metric of metrics) {
15 const before = baseline.audits[metric]?.numericValue ?? 0;
16 const after = optimized.audits[metric]?.numericValue ?? 0;
17 const delta = after - before;
18 const pct = ((delta / before) * 100).toFixed(1);
19 const direction = delta < 0 ? 'improvement' : 'regression';
20 console.log(`${metric}: ${Math.round(before)}ms → ${Math.round(after)}ms (${pct}% ${direction})`);
21}
22
23const perfBefore = Math.round(baseline.categories.performance.score * 100);
24const perfAfter = Math.round(optimized.categories.performance.score * 100);
25console.log(`\nPerformance Score: ${perfBefore} → ${perfAfter}`);Typical results after removing moment.js + dynamic importing Monaco + date-fns swap:
Step-by-step bundle analysis, identifying the top 3 oversized dependencies, applying dynamic imports and library swaps, and measuring Lighthouse score improvement.
1// before-optimization: apps/web/next.config.js
2const withBundleAnalyzer = require('@next/bundle-analyzer')({
3 enabled: process.env.ANALYZE === 'true',
4});
5
6module.exports = withBundleAnalyzer({
7 // No special configuration — default Next.js settings
8});
9
10// PROBLEM: Running 'ANALYZE=true npm run build' reveals:
11// - moment: 232 KB (used only for date formatting)
12// - @monaco-editor/react: 812 KB (loaded on EVERY page, not just lesson pages)
13// - @faker-js/faker: 178 KB (accidentally imported in a story file)
14// - lodash: 71 KB (only debounce is used)
15// Total initial JS: 847 KB → Lighthouse Performance: 68/100, TBT: 1240ms
16
17// AFTER STEP 1: Remove moment.js → date-fns
18// apps/web/src/lib/date.ts — centralized date utilities
19import { format, formatDistanceToNow, parseISO, isValid } from 'date-fns';
20
21export function formatDate(date: string | Date, fmt = 'MMM d, yyyy'): string {
22 const d = typeof date === 'string' ? parseISO(date) : date;
23 return isValid(d) ? format(d, fmt) : 'Invalid date';
24}
25
26export function timeAgo(date: string | Date): string {
27 const d = typeof date === 'string' ? parseISO(date) : date;
28 return isValid(d) ? formatDistanceToNow(d, { addSuffix: true }) : '';
29}
30
31// AFTER STEP 2: Dynamic import Monaco editor
32// apps/web/src/components/lesson/CodeEditor.tsx
33import dynamic from 'next/dynamic';
34import type { EditorProps } from '@monaco-editor/react';
35
36const MonacoEditor = dynamic(
37 () => import('@monaco-editor/react').then((m) => m.default),
38 {
39 loading: () => (
40 <div className="flex h-64 items-center justify-center rounded-lg bg-gray-900">
41 <div className="text-sm text-gray-400">Loading editor...</div>
42 </div>
43 ),
44 ssr: false,
45 }
46);
47
48interface CodeEditorProps {
49 value: string;
50 language: string;
51 onChange?: (value: string) => void;
52}
53
54export function CodeEditor({ value, language, onChange }: CodeEditorProps) {
55 return (
56 <MonacoEditor
57 height="400px"
58 language={language}
59 value={value}
60 onChange={onChange}
61 theme="vs-dark"
62 options={{ minimap: { enabled: false }, fontSize: 14 }}
63 />
64 );
65}
66
67// AFTER STEP 3: Replace lodash debounce → native
68// apps/web/src/hooks/use-search.ts
69import { useState, useCallback, useRef } from 'react';
70
71export function useSearch<T>(
72 searchFn: (query: string) => Promise<T[]>,
73 delay = 300
74) {
75 const [results, setResults] = useState<T[]>([]);
76 const [loading, setLoading] = useState(false);
77 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
78
79 const search = useCallback((query: string) => {
80 if (timerRef.current) clearTimeout(timerRef.current);
81 if (!query.trim()) { setResults([]); return; }
82
83 timerRef.current = setTimeout(async () => {
84 setLoading(true);
85 try {
86 const data = await searchFn(query);
87 setResults(data);
88 } finally {
89 setLoading(false);
90 }
91 }, delay);
92 }, [searchFn, delay]);
93
94 return { results, loading, search };
95}
96
97// AFTER STEP 4: Enable tree shaking for icon libraries
98// apps/web/next.config.js
99const withBundleAnalyzer = require('@next/bundle-analyzer')({
100 enabled: process.env.ANALYZE === 'true',
101});
102
103module.exports = withBundleAnalyzer({
104 experimental: {
105 optimizePackageImports: ['lucide-react'],
106 },
107});
108
109// RESULT after all optimizations:
110// Initial JS: 847 KB → 148 KB (83% reduction)
111// Lighthouse Performance: 68 → 95
112// TBT: 1240ms → 85ms
113// LCP: 3100ms → 1800msThis optimization sequence achieves an 83% bundle size reduction through four targeted changes: replacing moment.js (232 KB) with date-fns tree-shaken imports (~8 KB), dynamic importing Monaco editor (812 KB) so it only loads on lesson pages, replacing lodash debounce with a native setTimeout implementation, and enabling Next.js optimizePackageImports for lucide-react. Each change is measurable — run 'ANALYZE=true npm run build' before and after each step to verify the specific module was removed from the initial bundle. The Lighthouse Performance score jumping from 68 to 95 is directly attributable to TBT dropping from 1240ms to 85ms.
The next lesson covers performance testing in CI/CD pipelines — integrating k6, Lighthouse CI, and bundle size checks into GitHub Actions workflows with deployment blocking thresholds.
Your progress is saved in this browser, on this device only.