You ran next build and saw the red text:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 412 kB
├ ○ /dashboard 12.1 kB 419 kB
├ ○ /projects 8.4 kB 415 kB
└ ○ /settings 3.1 kB 410 kB
+ First Load JS shared by all routes 407 kB
├ chunks/framework-2a5b6c.js 85 kB
├ chunks/main-app-d4e5f6.js 35 kB
└ other shared chunks (total) 287 kB
○ (Static) prerendered as static content
412kb of JavaScript before your users see anything. On a mid-tier Android phone over 4G, that's nearly 5 seconds of staring at a blank screen.
This is the story of TaskFlow — a project management dashboard built with Next.js 16, MUI, Recharts, lodash, and moment.js. A stack that's common, powerful, and heavy. We took it from a 412kb First Load JS and a PageSpeed score of 38 to 185kb and a score of 82. Every fix is documented with exact numbers so you can apply the same process to your own app.
Here's what we started with:
| Metric | Value |
|---|---|
| First Load JS | 412kb |
| Total bundle (uncompressed) | 1.8MB |
| PageSpeed mobile | 38 |
| LCP | 4.8s |
| INP | 340ms |
| CLS | 0.02 |
According to a study of 300,000 Next.js production sites, the median app ships over 1MB of JavaScript. The lightest 10% manage around 350kb. We're going to beat both.
Step 1: See where the bytes are going
Before fixing anything, you need to see what's in the bundle. Guessing wastes time. The @next/bundle-analyzer plugin generates a treemap that shows every package and its size.
Set up the analyzer
npm install @next/bundle-analyzer
// next.config.ts
import type { NextConfig } from 'next';
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
const nextConfig: NextConfig = {
// your existing config
};
export default withBundleAnalyzer(nextConfig);
Run it:
ANALYZE=true next build
A browser tab opens with an interactive treemap. Each rectangle is a file or package, sized proportionally to its contribution to the bundle.
What TaskFlow's treemap revealed
The treemap told us everything we needed to know:
- lodash — 73kb. We used
groupByanddebounce. Two functions. 73kb. - @mui/icons-material — 41kb. Barrel imports were pulling in the entire icon set. We used 12 icons.
- recharts — 45kb. Loaded on every page. Charts only appear on the analytics tab.
- moment.js — 67kb. Used for three
format()calls. Doesn't tree-shake. - framer-motion — 32kb. An old animation experiment. Still imported, never rendered.
That's 258kb of JavaScript that either shouldn't be there or shouldn't load upfront. More than half the total bundle.
The treemap doesn't lie. Every large rectangle is a specific fix waiting to happen. Let's start with the easiest win.
Fix 1: Barrel imports and tree-shaking (saved 64kb)
Barrel imports are the most common cause of bundle bloat in Next.js apps using component libraries. One import statement can pull in an entire library.
The problem
// This processes the entire MUI library — every component, every export
import { Button, TextField, Card, Chip } from '@mui/material';
// This is even worse — thousands of icons, all bundled
import { Dashboard, Settings, Person, Delete, Edit,
Save, Close, Search, Add, Remove, Check, Warning } from '@mui/icons-material';
// And lodash — 73kb for two functions
import { groupBy, debounce } from 'lodash';
When your bundler sees from '@mui/material', it has to process the barrel file that re-exports every component. For @mui/icons-material, named imports can be 6x slower than path imports because there are thousands of icons.
The fix
// MUI components — import from the specific path
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Card from '@mui/material/Card';
import Chip from '@mui/material/Chip';
// MUI icons — each icon is ~1kb instead of 41kb for all
import Dashboard from '@mui/icons-material/Dashboard';
import Settings from '@mui/icons-material/Settings';
import Person from '@mui/icons-material/Person';
// lodash — import individual functions
import groupBy from 'lodash/groupBy';
import debounce from 'lodash/debounce';
More verbose, but the bundle impact is dramatic.
Let Next.js handle it automatically
Next.js has a built-in solution. Add optimizePackageImports to your config, and barrel imports are automatically converted to path imports during the build:
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: [
'@mui/material',
'@mui/icons-material',
'lodash',
],
},
};
With this config, you can keep writing import { Button } from '@mui/material' and Next.js will transform it to the path import at build time. Best of both worlds.
Results
| Package | Before | After | Saved |
|---|---|---|---|
| lodash | 73kb | 9kb | 64kb |
| @mui/icons-material | 41kb | 3kb | 38kb |
| Subtotal | 64kb (after dedup with shared chunks) |
First Load JS: 412kb → 348kb
64kb gone. One config change and some import cleanup. This is almost always the biggest win per effort in any Next.js app using third-party component libraries.
Fix 2: Move data work to Server Components (saved 58kb)
TaskFlow's dashboard page had a single large Client Component. It fetched project data, transformed it into chart-ready formats, computed summary statistics, and rendered everything — including parts that had zero interactivity.
The 'use client' directive at the top meant all of that code shipped to the browser, even the parts that never needed to run there.
The problem
'use client';
import { useState } from 'react';
import { groupBy } from 'lodash/groupBy';
import { format } from 'date-fns';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
interface Project {
id: string;
name: string;
status: 'active' | 'completed' | 'paused';
updatedAt: string;
tasks: number;
completedTasks: number;
}
export default function DashboardPage() {
const [statusFilter, setStatusFilter] = useState('all');
const [projects, setProjects] = useState<Project[]>([]);
// All of this runs in the browser — fetching, transforming, computing
useEffect(() => {
fetch('/api/projects').then(r => r.json()).then(setProjects);
}, []);
const grouped = groupBy(projects, 'status');
const totalTasks = projects.reduce((sum, p) => sum + p.tasks, 0);
const completedTasks = projects.reduce((sum, p) => sum + p.completedTasks, 0);
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
const filtered = statusFilter === 'all'
? projects
: projects.filter(p => p.status === statusFilter);
return (
<div>
<Typography variant="h4">Dashboard</Typography>
{/* Summary cards — completely static once rendered */}
<div className="summary-grid">
<Card><CardContent>
<Typography color="textSecondary">Total Projects</Typography>
<Typography variant="h3">{projects.length}</Typography>
</CardContent></Card>
<Card><CardContent>
<Typography color="textSecondary">Completion Rate</Typography>
<Typography variant="h3">{completionRate}%</Typography>
</CardContent></Card>
</div>
{/* Only this part needs to be interactive */}
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<MenuItem value="all">All</MenuItem>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="completed">Completed</MenuItem>
</Select>
{filtered.map(project => (
<Card key={project.id}>
<CardContent>
<Typography>{project.name}</Typography>
<Typography color="textSecondary">
Updated {format(new Date(project.updatedAt), 'MMM d, yyyy')}
</Typography>
</CardContent>
</Card>
))}
</div>
);
}
The fix
Split into a Server Component that handles data and a small Client Component for the interactive filter:
// app/dashboard/page.tsx (Server Component — no 'use client')
import { format } from 'date-fns';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import { ProjectFilter } from './project-filter';
interface Project {
id: string;
name: string;
status: 'active' | 'completed' | 'paused';
updatedAt: string;
tasks: number;
completedTasks: number;
}
async function getProjects(): Promise<Project[]> {
const res = await fetch('https://api.taskflow.dev/projects', {
next: { revalidate: 60 },
});
return res.json();
}
export default async function DashboardPage() {
const projects = await getProjects();
const totalTasks = projects.reduce((sum, p) => sum + p.tasks, 0);
const completedTasks = projects.reduce((sum, p) => sum + p.completedTasks, 0);
const completionRate = totalTasks > 0
? Math.round((completedTasks / totalTasks) * 100) : 0;
return (
<div>
<Typography variant="h4">Dashboard</Typography>
<div className="summary-grid">
<Card><CardContent>
<Typography color="textSecondary">Total Projects</Typography>
<Typography variant="h3">{projects.length}</Typography>
</CardContent></Card>
<Card><CardContent>
<Typography color="textSecondary">Completion Rate</Typography>
<Typography variant="h3">{completionRate}%</Typography>
</CardContent></Card>
</div>
{/* Only the filter is a Client Component */}
<ProjectFilter projects={projects} />
</div>
);
}
// app/dashboard/project-filter.tsx
'use client';
import { useState } from 'react';
import { format } from 'date-fns';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
interface Project {
id: string;
name: string;
status: 'active' | 'completed' | 'paused';
updatedAt: string;
}
export function ProjectFilter({ projects }: { projects: Project[] }) {
const [statusFilter, setStatusFilter] = useState('all');
const filtered = statusFilter === 'all'
? projects
: projects.filter(p => p.status === statusFilter);
return (
<>
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<MenuItem value="all">All</MenuItem>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="completed">Completed</MenuItem>
</Select>
{filtered.map(project => (
<Card key={project.id}>
<CardContent>
<Typography>{project.name}</Typography>
<Typography color="textSecondary">
Updated {format(new Date(project.updatedAt), 'MMM d, yyyy')}
</Typography>
</CardContent>
</Card>
))}
</>
);
}
The key insight: the summary cards, the page title, and the data fetching/transformation logic are now Server Components. They run on the server and ship zero JavaScript to the browser. The only client code is the filter dropdown and list rendering — maybe 2kb of actual interactive logic.
Results
| What moved to server | Size removed from client |
|---|---|
| Data fetching + useEffect | ~12kb |
| Data transformation (groupBy, reduce) | ~16kb |
| Summary card rendering | ~8kb |
| Date formatting utilities | ~10kb |
| Type definitions + interfaces | ~12kb |
| Total | 58kb |
First Load JS: 348kb → 290kb
This is the optimization most developers skip because it requires rethinking component boundaries. But it's the second biggest win after imports, and it makes your app faster in ways bundle size alone doesn't capture — the data fetching now happens on the server, closer to the database, with no client-side waterfall.
Fix 3: Dynamic imports for heavy components (saved 52kb)
TaskFlow's analytics page uses Recharts for data visualization. The charting library is 45kb gzipped — reasonable for a charting library, but unreasonable to load on every page when charts only appear on one tab.
There's also a settings modal with a rich text editor that most users never open.
The problem
// app/dashboard/layout.tsx
'use client';
// These load on EVERY page, even pages without charts
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { RichTextEditor } from './rich-text-editor';
Static imports mean these packages are in the shared bundle. Every route pays the cost, even routes that never render a chart.
The fix
Use next/dynamic to load heavy components only when they're needed:
// app/dashboard/analytics-chart.tsx
'use client';
import dynamic from 'next/dynamic';
import Skeleton from '@mui/material/Skeleton';
// Only loads when this component renders
const BarChart = dynamic(
() => import('recharts').then(mod => ({ default: mod.BarChart })),
{
ssr: false,
loading: () => (
<Skeleton
variant="rectangular"
width="100%"
height={300}
sx={{ borderRadius: 2 }}
/>
),
}
);
const Bar = dynamic(() => import('recharts').then(mod => ({ default: mod.Bar })));
const XAxis = dynamic(() => import('recharts').then(mod => ({ default: mod.XAxis })));
const YAxis = dynamic(() => import('recharts').then(mod => ({ default: mod.YAxis })));
const Tooltip = dynamic(() => import('recharts').then(mod => ({ default: mod.Tooltip })));
const ResponsiveContainer = dynamic(
() => import('recharts').then(mod => ({ default: mod.ResponsiveContainer }))
);
interface ChartData {
name: string;
tasks: number;
completed: number;
}
export function AnalyticsChart({ data }: { data: ChartData[] }) {
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="completed" fill="#4ade80" />
<Bar dataKey="tasks" fill="#94a3b8" />
</BarChart>
</ResponsiveContainer>
);
}
For the settings modal, load it on user interaction:
// app/dashboard/settings-button.tsx
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import Button from '@mui/material/Button';
import Settings from '@mui/icons-material/Settings';
// Only downloads when the user clicks "Settings"
const SettingsModal = dynamic(() => import('./settings-modal'), {
loading: () => null,
});
export function SettingsButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button startIcon={<Settings />} onClick={() => setOpen(true)}>
Settings
</Button>
{open && <SettingsModal open={open} onClose={() => setOpen(false)} />}
</>
);
}
The {open && <SettingsModal />} pattern means the dynamic import doesn't even start until the user clicks the button. Zero cost until it's needed.
Results
| Component | Size removed from initial bundle |
|---|---|
| Recharts (all components) | 45kb |
| Settings modal + rich text editor | 7kb |
| Total | 52kb |
First Load JS: 290kb → 238kb
The charts still load fast — dynamic chunks are typically fetched in under 200ms on a decent connection. The skeleton loader prevents any layout shift while the chunk loads.
Fix 4: Replace heavy dependencies (saved 33kb)
Some libraries earn their bundle cost. Others don't. TaskFlow had three that were easy wins.
moment.js → date-fns
moment.js is 67kb minified. It can't tree-shake because of its architecture — you always ship the entire library, even if you use one function. TaskFlow used moment() in exactly three places: formatting dates for display.
// Before — 67kb for this
import moment from 'moment';
const formatted = moment(project.updatedAt).format('MMM D, YYYY');
const relative = moment(project.updatedAt).fromNow();
const isRecent = moment(project.updatedAt).isAfter(moment().subtract(7, 'days'));
// After — ~3kb for these three imports (tree-shakeable)
import { format, formatDistanceToNow, isAfter, subDays } from 'date-fns';
const formatted = format(new Date(project.updatedAt), 'MMM d, yyyy');
const relative = formatDistanceToNow(new Date(project.updatedAt), { addSuffix: true });
const isRecent = isAfter(new Date(project.updatedAt), subDays(new Date(), 7));
The API is slightly different, but the migration is straightforward. For an app-wide find-and-replace, npx moment-to-date-fns can automate most of it.
Audit with knip
How do you find unused imports you don't know about? Run knip:
npx knip
Unused dependencies (2)
framer-motion package.json
react-hot-toast package.json
Unused exports (4)
ProjectCard app/components/project-card.tsx:12
useAnalytics app/hooks/use-analytics.ts:5
formatCurrency app/utils/format.ts:23
CHART_COLORS app/constants.ts:8
TaskFlow had framer-motion (32kb) still in package.json from an old animation experiment. The import was in a component file but the component was never rendered. The bundler included it anyway because it couldn't prove the import was dead.
Results
| Change | Saved |
|---|---|
| moment.js → date-fns | 20kb |
| Remove unused framer-motion | 5kb |
| Remove unused MUI component imports | 8kb |
| Total | 33kb |
First Load JS: 238kb → 205kb
The lesson: your bundler can only tree-shake what's provably unused at the module level. If you import a library but never call it, some bundlers will still include it. Regular audits with knip or depcheck catch what tree-shaking misses.
Fix 5: Build configuration (saved 20kb)
The last 20kb came from build-level optimizations. These aren't dramatic individually, but they compound.
Final next.config.ts
Here's TaskFlow's optimized config with everything from this article combined:
// next.config.ts
import type { NextConfig } from 'next';
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
const nextConfig: NextConfig = {
reactStrictMode: true,
experimental: {
optimizePackageImports: [
'@mui/material',
'@mui/icons-material',
'lodash',
'date-fns',
],
optimizeCss: true,
},
};
export default withBundleAnalyzer(nextConfig);
Verify compression
Bundle size on disk doesn't equal bytes over the wire. Make sure your hosting platform serves Brotli-compressed assets — it compresses 15-20% better than Gzip for JavaScript.
Check your response headers:
curl -sI -H "Accept-Encoding: br" https://your-app.com/_next/static/chunks/main-app.js | grep content-encoding
# Should show: content-encoding: br
If you see gzip instead of br, check your platform's compression settings. Vercel and Cloudflare enable Brotli by default. Self-hosted setups may need to configure it manually.
Results
| Optimization | Saved |
|---|---|
| optimizeCss (removes unused CSS) | 8kb |
| Brotli compression gains (vs Gzip) | 12kb |
| Total | 20kb |
First Load JS: 205kb → 185kb ✓
The results
Five fixes. One day of focused work. Here's the full picture:
| Metric | Before | After | Change |
|---|---|---|---|
| First Load JS | 412kb | 185kb | -55% |
| Total bundle (uncompressed) | 1.8MB | 780kb | -57% |
| PageSpeed mobile | 38 | 82 | +44 points |
| LCP | 4.8s | 2.1s | -56% |
| INP | 340ms | 160ms | -53% |
| CLS | 0.02 | 0.02 | No change |
And the breakdown by fix:
| Fix | Technique | Saved | Running Total |
|---|---|---|---|
| 1 | Barrel imports + tree-shaking | 64kb | 348kb |
| 2 | Server Components | 58kb | 290kb |
| 3 | Dynamic imports | 52kb | 238kb |
| 4 | Replace heavy deps | 33kb | 205kb |
| 5 | Build config + compression | 20kb | 185kb |
What made the biggest difference
The top two fixes — import optimization and Server Components — accounted for 122kb, more than half the total reduction. Neither required any new tooling or architectural overhaul. Import fixes are a config change. Server Components are about drawing the right line between what needs to be interactive and what doesn't.
The Core Web Vitals connection
Bundle size doesn't exist in a vacuum. Every kilobyte of JavaScript has a cascading effect:
- LCP dropped from 4.8s to 2.1s because the browser could start rendering sooner. Less JavaScript to download and parse means the largest content element appears faster.
- INP improved from 340ms to 160ms because there's less JavaScript competing for the main thread. User interactions get processed faster when the browser isn't busy parsing a 400kb bundle.
- CLS stayed the same at 0.02 — bundle size doesn't directly affect layout stability, but the skeleton loaders we added for dynamic imports help maintain it.
For a deeper dive on each metric, see our guides on improving LCP, fixing CLS, and improving INP.
A note on AI discoverability
JavaScript-heavy apps face an emerging challenge: AI crawlers like GPTBot and ClaudeBot don't execute JavaScript. If your content is trapped behind client-side rendering, it's invisible to AI-powered search engines. Reducing client-side JavaScript and using Server Components for content-heavy pages helps ensure your app is discoverable by both traditional and AI-powered search.
Your action plan
- Install
@next/bundle-analyzerand runANALYZE=true next build - Fix barrel imports or add
optimizePackageImportsto your config - Look for
'use client'files that do data fetching or transformation — split them - Dynamic import any component larger than 20kb that isn't visible on initial load
- Run
npx knipto find unused dependencies - Verify Brotli compression is enabled on your hosting platform
If you're using MUI with Next.js, our Material UI performance guide covers MUI-specific optimizations in depth. For a broader look at Next.js and Core Web Vitals, we have a dedicated guide for that too.
Frequently Asked Questions
What is a good First Load JS size for Next.js?
Next.js color-codes bundle sizes in its build output — green means acceptable, red means too large. The threshold is around 130kb. Well-optimized production apps with App Router typically achieve 80-130kb First Load JS. According to a study of 300,000 Next.js domains, the lightest 10% have median total bundles around 350kb, while the median site crosses 1MB. If your First Load JS is above 200kb, there's likely room to optimize.
Why is my Next.js bundle so big?
The most common culprits are: barrel imports pulling in entire libraries (especially MUI icons and lodash), heavy client-side dependencies that should be Server Components, charting libraries loaded on initial page load instead of dynamically, and date libraries like moment.js that don't tree-shake well. Use @next/bundle-analyzer to visualize exactly which packages are inflating your bundle.
Does bundle size affect Core Web Vitals?
Yes, directly. Large JavaScript bundles block rendering (hurting LCP), increase parse and execution time (hurting INP), and delay interactivity. Every 100kb of JavaScript adds roughly 350ms of processing time on a mid-tier mobile device. Reducing bundle size is one of the most impactful ways to improve all three Core Web Vitals.
App Router vs Pages Router — which has smaller bundles?
App Router can produce smaller client bundles when used correctly, because Server Components run entirely on the server and ship zero JavaScript to the browser. However, if you mark too many components with 'use client' or use client-heavy libraries without dynamic imports, App Router bundles can actually be larger. A MUI GitHub issue documented a 42% bundle increase when migrating to App Router without optimizing component boundaries.
Do React Server Components reduce bundle size?
Yes — Server Components are the single biggest bundle size win in modern Next.js. Any component that doesn't need browser APIs (useState, useEffect, onClick) or interactivity can be a Server Component, meaning its code never reaches the browser. Data fetching, markdown rendering, syntax highlighting, and data transformations are ideal candidates. Real-world reports show 25-30% client bundle reductions from proper Server Component usage.
