You ran next build and saw output like this:
Route (app) Size First Load JS
┌ ○ / 160 B 107 kB
├ ○ /dashboard 5.2 kB 138 kB
├ ● /blog/[slug] 2.8 kB 116 kB
└ ƒ /account 7.4 kB 145 kB
+ First Load JS shared by all 101 kB
├ chunks/framework-2a5b6c.js 45 kB
├ chunks/main-app-d4e5f6.js 31 kB
└ other shared chunks 25 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML with generated params
ƒ (Dynamic) server-rendered on demand
The confusing part is that Next.js is showing several different things at once: how each route renders, how much JavaScript each route adds, and how much JavaScript the browser needs before that route can run.
This guide is the translation layer. First, we will decode the symbols and columns. Then we will cover the fixes that actually reduce the number that matters: First Load JS.
Quick answer
If you only need the short version:
| Output | Meaning | What to check |
|---|---|---|
○ | Static route | Good for cacheable pages. No request-time server render. |
● | Statically generated route with params/data | Good for generated pages like blog posts or docs. |
ƒ | Dynamic server-rendered route | Check whether the page really needs request-time rendering. |
λ | Pages Router server route/API route | Older Pages Router symbol for server-side code. |
Size | JavaScript unique to that route | Usually less important than First Load JS. |
First Load JS | Total JS needed on first visit to that route | The number to reduce for performance. |
shared by all | Baseline JS every route loads | Highest-leverage place to find waste. |
The most common misunderstanding:
160 Bdoes not mean the page only sends 160 bytes of JavaScript.
It means the page adds 160 bytes of route-specific JavaScript. The browser may still need 107 kB First Load JS because shared framework and app chunks are loaded too.
What the route symbols mean
Each route gets a symbol that describes how Next.js will render it.
| Symbol | Name | Meaning |
|---|---|---|
○ | Static | Prerendered as static HTML at build time. |
● | SSG | Statically generated with data or generated route params. |
ƒ | Dynamic | Server-rendered on demand for each request. |
λ | Server | Pages Router server-side route or API route. |
Rule of thumb: ○ and ● are cheap and cacheable; ƒ and λ run server code at request time.
That does not mean ƒ is bad. Account pages, dashboards, admin screens, authenticated pages, personalized pages, and pages that depend on request headers often need dynamic rendering. The problem is when a route becomes dynamic by accident.
Why a route becomes ƒ dynamic
In the App Router, a route can become dynamic when it uses request-time APIs or disables caching.
Common causes:
| Cause | Example | Fix if accidental |
|---|---|---|
cookies() | Reading auth/session cookies in a page | Move auth checks to the smallest layout/page that needs them. |
headers() | Reading user agent or host headers | Avoid for static marketing/content pages. |
searchParams | Rendering based on query string | Keep the static shell separate from query-driven UI. |
fetch(..., { cache: 'no-store' }) | Always fetching fresh data | Use revalidate where freshness can be bounded. |
export const dynamic = 'force-dynamic' | Explicit dynamic route config | Remove it unless the page really needs request-time rendering. |
| Uncached data inside a shared layout | Dynamic logic in app/layout.tsx or nested layouts | Push dynamic logic down to the route that needs it. |
If a route you expected to be static appears as ƒ, inspect the route's page.tsx, its layouts, and any Server Components it imports. Dynamic behavior can come from a parent layout or nested component, not just the page file.
What Size means
Size is the JavaScript unique to that route.
Route (app) Size First Load JS
┌ ○ / 160 B 107 kB
├ ○ /pricing 2.4 kB 109 kB
└ ○ /blog 4.1 kB 111 kB
In this output, / adds only 160 B of route-specific JavaScript. That is good, but it does not tell the whole story.
Route Size is useful when comparing pages to each other. If one route has 2 kB and another has 85 kB, the large route probably imports a heavy component, charting library, editor, map, carousel, or client-side dependency.
But if every route has a high First Load JS, the problem is usually not one route. It is the shared baseline.
What First Load JS means
First Load JS is the total JavaScript needed when a user first loads that route.
It includes:
- JavaScript unique to the route
- React and Next.js runtime code
- app/router chunks
- shared chunks imported by layouts or common components
- client components needed for that initial route
That is why this can happen:
Route (app) Size First Load JS
┌ ○ / 160 B 107 kB
+ First Load JS shared by all 101 kB
The route itself adds almost nothing. The page still needs about 107 kB because the app has a 101 kB shared baseline.
For performance work, First Load JS is usually more important than Size because it better reflects what the browser has to download, parse, compile, and execute before the page feels ready.
What "First Load JS shared by all" means
The shared section is the baseline JavaScript cost every route pays.
+ First Load JS shared by all 101 kB
├ chunks/framework-2a5b6c.js 45 kB
├ chunks/main-app-d4e5f6.js 31 kB
└ other shared chunks 25 kB
This is where high-leverage fixes usually live.
If you import a heavy client component from your root layout, navigation, provider tree, analytics wrapper, theme provider, or shared shell, it can land in shared JS. Once that happens, every route pays for it, even routes that never render the feature.
Common shared-bundle culprits:
- UI libraries imported broadly
- icon libraries imported through barrels
- chart libraries in dashboard shells
- rich text editors in shared components
- date libraries like
moment - broad utility imports like
lodash - client providers wrapped around the whole app
- too many components marked with
'use client'
If shared by all is large, do not start by shaving 1 kB from one page. Find what is in the shared chunk.
What is a good First Load JS size?
There is no universal limit, but these are practical ranges for production apps:
| First Load JS | Read |
|---|---|
| Under 100 kB | Strong. Usually achievable for content-heavy App Router pages. |
| 100-130 kB | Good. A reasonable target for many apps. |
| 130-170 kB | Watch. Fine for some product apps, but worth checking. |
| 170-200 kB | Investigate. Usually there is avoidable shared JavaScript. |
| 200 kB+ | High. Likely to affect mobile performance and interaction latency. |
| 300 kB+ | Serious. Expect Core Web Vitals pressure on real devices. |
Treat these as triage thresholds, not laws. A 150 kB page with little hydration work can feel better than a 120 kB page that runs expensive client code on startup.
The real test is how the page performs on your users' devices. Large JavaScript can hurt:
- LCP because the browser spends more time downloading, parsing, and executing scripts before rendering useful content
- INP because the main thread has more JavaScript competing with user interactions
- crawlability and AI discoverability if meaningful content only appears after client rendering
How to read the output without guessing
Use this process when you see a suspicious build table:
- Look at
First Load JS shared by all. - If it is high, inspect shared imports, layouts, providers, and global client components.
- Look for routes with unusually large
Size. - Check whether static pages unexpectedly became
ƒ. - Run a bundle analyzer before making changes.
- Fix the biggest dependency or boundary issue first.
Do not optimize by vibes. The build output tells you where to look, and the bundle analyzer tells you what is actually inside.
Step 1: Run the bundle analyzer
Install @next/bundle-analyzer:
npm install @next/bundle-analyzer
Set it up in 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:
ANALYZE=true next build
The analyzer opens a treemap. Each rectangle is a file or package, sized by its contribution to the bundle.
Look for:
- one library dominating the shared chunk
- packages loaded on routes that do not use them
- duplicate dependencies
- broad imports from large packages
- client components that could be server components
Example: a 412 kB dashboard bundle
Here is a realistic example from a project management dashboard built with Next.js, MUI, Recharts, lodash, and moment.js.
Initial output:
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 407 kB
The route sizes were not the problem. The shared baseline was. Every page paid for roughly 407 kB before route-specific code.
The analyzer showed:
| Package/problem | Cost | Why it was bad |
|---|---|---|
lodash | 73 kB | Used for two functions. |
@mui/icons-material | 41 kB | Barrel imports pulled too much into the bundle. |
recharts | 45 kB | Loaded on pages without charts. |
moment.js | 67 kB | Used for simple date formatting. |
framer-motion | 32 kB | Old unused animation import. |
That is the kind of evidence you want before touching code.
Fix 1: Reduce barrel imports
Barrel imports are a common cause of bloated Next.js bundles.
Problem:
import { Button, TextField, Card, Chip } from '@mui/material';
import { Dashboard, Settings, Person } from '@mui/icons-material';
import { groupBy, debounce } from 'lodash';
Better:
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Card from '@mui/material/Card';
import Chip from '@mui/material/Chip';
import Dashboard from '@mui/icons-material/Dashboard';
import Settings from '@mui/icons-material/Settings';
import Person from '@mui/icons-material/Person';
import groupBy from 'lodash/groupBy';
import debounce from 'lodash/debounce';
You can also let Next.js rewrite some package imports automatically:
const nextConfig = {
experimental: {
optimizePackageImports: [
'@mui/material',
'@mui/icons-material',
'lodash',
],
},
};
This is often the fastest win because it reduces shared JavaScript without changing product behavior.
In the dashboard example:
First Load JS: 412 kB -> 348 kB
Fix 2: Move non-interactive work to Server Components
A large 'use client' boundary can quietly ship data fetching, formatting, transformation, and static rendering code to the browser.
Problem:
'use client';
import { useEffect, useState } from 'react';
import groupBy from 'lodash/groupBy';
import { format } from 'date-fns';
export default function DashboardPage() {
const [projects, setProjects] = useState([]);
const [statusFilter, setStatusFilter] = useState('all');
useEffect(() => {
fetch('/api/projects').then((res) => res.json()).then(setProjects);
}, []);
const grouped = groupBy(projects, 'status');
const totalTasks = projects.reduce((sum, project) => sum + project.tasks, 0);
return (
<>
<SummaryCards projects={projects} totalTasks={totalTasks} />
<ProjectFilter projects={projects} statusFilter={statusFilter} setStatusFilter={setStatusFilter} />
</>
);
}
The summary cards, data fetching, grouping, and totals do not need to run in the browser.
Better:
// app/dashboard/page.tsx
import { ProjectFilter } from './project-filter';
async function getProjects() {
const res = await fetch('https://api.example.com/projects', {
next: { revalidate: 60 },
});
return res.json();
}
export default async function DashboardPage() {
const projects = await getProjects();
const totalTasks = projects.reduce((sum, project) => sum + project.tasks, 0);
return (
<>
<SummaryCards projects={projects} totalTasks={totalTasks} />
<ProjectFilter projects={projects} />
</>
);
}
// app/dashboard/project-filter.tsx
'use client';
import { useState } from 'react';
export function ProjectFilter({ projects }) {
const [statusFilter, setStatusFilter] = useState('all');
const filtered = statusFilter === 'all'
? projects
: projects.filter((project) => project.status === statusFilter);
return (
<>
<StatusSelect value={statusFilter} onChange={setStatusFilter} />
<ProjectList projects={filtered} />
</>
);
}
The page still has interactivity, but the expensive non-interactive work stays on the server. That means less JavaScript in the client bundle and less main-thread work during startup.
In the dashboard example:
First Load JS: 348 kB -> 290 kB
Fix 3: Dynamically import heavy components
If a component is large and not needed for the first view, do not put it in the initial bundle.
Good candidates:
- charts
- maps
- rich text editors
- code editors
- video players
- admin-only widgets
- modals that open after a click
Example:
'use client';
import dynamic from 'next/dynamic';
import Skeleton from '@mui/material/Skeleton';
const AnalyticsChart = dynamic(() => import('./analytics-chart'), {
ssr: false,
loading: () => (
<Skeleton
variant="rectangular"
width="100%"
height={300}
/>
),
});
export function AnalyticsPanel({ data }) {
return <AnalyticsChart data={data} />;
}
For a modal, delay the download until the user asks for it:
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
const SettingsModal = dynamic(() => import('./settings-modal'), {
loading: () => null,
});
export function SettingsButton() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Settings</button>
{open && <SettingsModal open={open} onClose={() => setOpen(false)} />}
</>
);
}
In the dashboard example:
First Load JS: 290 kB -> 238 kB
Fix 4: Replace heavy dependencies
Some libraries are worth their cost. Others are not, especially when they are used for one or two helpers.
Example: replacing moment with tree-shakeable date utilities.
Before:
import moment from 'moment';
const formatted = moment(project.updatedAt).format('MMM D, YYYY');
const relative = moment(project.updatedAt).fromNow();
After:
import { format, formatDistanceToNow } from 'date-fns';
const formatted = format(new Date(project.updatedAt), 'MMM d, yyyy');
const relative = formatDistanceToNow(new Date(project.updatedAt), {
addSuffix: true,
});
Also run an unused dependency audit:
npx knip
Typical findings:
Unused dependencies (2)
framer-motion package.json
react-hot-toast package.json
Unused exports (3)
ProjectCard app/components/project-card.tsx:12
useAnalytics app/hooks/use-analytics.ts:5
CHART_COLORS app/constants.ts:8
Remove unused packages and dead imports. Bundlers are good, but they cannot always prove a dependency is safe to drop.
In the dashboard example:
First Load JS: 238 kB -> 205 kB
Fix 5: Check build config and compression
Package imports and component boundaries matter more than config, but config still helps.
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);
Then verify that production serves compressed assets:
curl -sI -H "Accept-Encoding: br" https://your-app.com/_next/static/chunks/main-app.js | grep content-encoding
You want Brotli where possible:
content-encoding: br
Vercel and Cloudflare usually handle this for you. Self-hosted apps need explicit server/CDN configuration.
In the dashboard example:
First Load JS: 205 kB -> 185 kB
Full reduction example
The dashboard started here:
| Metric | Before |
|---|---|
| First Load JS | 412 kB |
| Shared JS | 407 kB |
| Total bundle, uncompressed | 1.8 MB |
| PageSpeed mobile | 38 |
| LCP | 4.8s |
| INP | 340ms |
| CLS | 0.02 |
After one focused pass:
| Metric | Before | After | Change |
|---|---|---|---|
| First Load JS | 412 kB | 185 kB | -55% |
| Total bundle, uncompressed | 1.8 MB | 780 kB | -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 fixes:
| Fix | Saved |
|---|---|
| Barrel imports and tree-shaking | 64 kB |
| Server Component boundaries | 58 kB |
| Dynamic imports | 52 kB |
| Heavy dependency replacements | 33 kB |
| Build config and compression | 20 kB |
The top two fixes did most of the work. That is common. The biggest bundle wins usually come from fewer client boundaries and less shared dependency weight, not micro-optimizing individual components.
Troubleshooting table
| What you see | Likely cause | Best next step |
|---|---|---|
Size is tiny but First Load JS is high | Shared JS is large | Inspect First Load JS shared by all with bundle analyzer. |
One route has much larger Size than others | Route-specific heavy import | Look for charts, editors, maps, carousels, or client-only libraries. |
Static page shows as ƒ | Request-time API or uncached fetch | Check cookies, headers, searchParams, no-store, and route config. |
| Every page loads chart/editor code | Imported from layout or shared shell | Move it down or use next/dynamic. |
| MUI app has large shared JS | Barrel imports or broad icon imports | Use path imports and optimizePackageImports. |
| Good bundle size but bad INP | Too much JavaScript executing after load | Profile main-thread work in Chrome DevTools. |
| Good desktop, poor mobile | JS parse/execute cost on slower devices | Test on mobile throttling and reduce hydration work. |
Your action plan
- Run
next buildand identify the routes with highFirst Load JS. - Check whether the problem is route-specific
Sizeor shared JS. - Run
ANALYZE=true next build. - Fix large shared dependencies first.
- Move non-interactive logic out of Client Components.
- Dynamically import components that are not needed for the initial view.
- Replace heavy dependencies used for small jobs.
- Run
npx knipto remove unused packages. - Verify production compression.
- Re-run PageSpeed and compare LCP/INP, not just bundle size.
If you use MUI, start with our Material UI performance guide. If you are trying to connect these changes to field performance, read the broader Next.js Core Web Vitals guide.
Frequently Asked Questions
What do ○, ●, ƒ, and λ mean in Next.js build output?
They describe how a route is rendered:
○means static HTML generated at build time.●means statically generated with data or generated params.ƒmeans dynamically server-rendered on demand.λis the older Pages Router server symbol for server-side routes or API routes.
For most public content pages, ○ or ● is ideal. For personalized or request-dependent pages, ƒ may be correct.
Why does Next.js show 160 B Size but 107 kB First Load JS?
Because Size only measures JavaScript unique to that route. First Load JS includes the route code plus shared JavaScript needed by the app.
So this:
Route (app) Size First Load JS
┌ ○ / 160 B 107 kB
+ First Load JS shared by all 101 kB
means the page itself adds only 160 B, but the browser still needs the shared 101 kB baseline.
What is a good First Load JS size for Next.js?
Aim for under about 130 kB when possible. Treat 170-200 kB as a warning range and 200 kB+ as a strong signal to inspect dependencies and client boundaries.
That said, bundle size is not the only factor. Hydration cost, long tasks, device mix, network conditions, and how much JavaScript executes after load all matter.
Why did my Next.js route become ƒ dynamic?
Usually because the route, a layout, or an imported Server Component uses request-time behavior.
Check for:
cookies()headers()searchParamsfetchwithcache: 'no-store'export const dynamic = 'force-dynamic'- dynamic logic in shared layouts
Push dynamic work as low in the route tree as possible so static pages can stay static.
How do I reduce First Load JS in Next.js?
Start with the shared baseline. Run @next/bundle-analyzer, then look for heavy libraries, broad imports, and client components in shared layouts.
The usual order:
- Fix barrel imports.
- Move non-interactive work to Server Components.
- Dynamically import heavy UI.
- Replace heavy dependencies.
- Remove unused packages.
- Verify compression.
That sequence usually beats random component-level cleanup because it attacks the JavaScript every route pays for.
