Next.js Build Output Explained: ○, ●, ƒ, Size, and First Load JS

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:

OutputMeaningWhat to check
Static routeGood for cacheable pages. No request-time server render.
Statically generated route with params/dataGood for generated pages like blog posts or docs.
ƒDynamic server-rendered routeCheck whether the page really needs request-time rendering.
λPages Router server route/API routeOlder Pages Router symbol for server-side code.
SizeJavaScript unique to that routeUsually less important than First Load JS.
First Load JSTotal JS needed on first visit to that routeThe number to reduce for performance.
shared by allBaseline JS every route loadsHighest-leverage place to find waste.

The most common misunderstanding:

160 B does 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.

SymbolNameMeaning
StaticPrerendered as static HTML at build time.
SSGStatically generated with data or generated route params.
ƒDynamicServer-rendered on demand for each request.
λServerPages 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:

CauseExampleFix if accidental
cookies()Reading auth/session cookies in a pageMove auth checks to the smallest layout/page that needs them.
headers()Reading user agent or host headersAvoid for static marketing/content pages.
searchParamsRendering based on query stringKeep the static shell separate from query-driven UI.
fetch(..., { cache: 'no-store' })Always fetching fresh dataUse revalidate where freshness can be bounded.
export const dynamic = 'force-dynamic'Explicit dynamic route configRemove it unless the page really needs request-time rendering.
Uncached data inside a shared layoutDynamic logic in app/layout.tsx or nested layoutsPush 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 JSRead
Under 100 kBStrong. Usually achievable for content-heavy App Router pages.
100-130 kBGood. A reasonable target for many apps.
130-170 kBWatch. Fine for some product apps, but worth checking.
170-200 kBInvestigate. 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:

  1. Look at First Load JS shared by all.
  2. If it is high, inspect shared imports, layouts, providers, and global client components.
  3. Look for routes with unusually large Size.
  4. Check whether static pages unexpectedly became ƒ.
  5. Run a bundle analyzer before making changes.
  6. 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/problemCostWhy it was bad
lodash73 kBUsed for two functions.
@mui/icons-material41 kBBarrel imports pulled too much into the bundle.
recharts45 kBLoaded on pages without charts.
moment.js67 kBUsed for simple date formatting.
framer-motion32 kBOld 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:

MetricBefore
First Load JS412 kB
Shared JS407 kB
Total bundle, uncompressed1.8 MB
PageSpeed mobile38
LCP4.8s
INP340ms
CLS0.02

After one focused pass:

MetricBeforeAfterChange
First Load JS412 kB185 kB-55%
Total bundle, uncompressed1.8 MB780 kB-57%
PageSpeed mobile3882+44 points
LCP4.8s2.1s-56%
INP340ms160ms-53%
CLS0.020.02No change

And the fixes:

FixSaved
Barrel imports and tree-shaking64 kB
Server Component boundaries58 kB
Dynamic imports52 kB
Heavy dependency replacements33 kB
Build config and compression20 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 seeLikely causeBest next step
Size is tiny but First Load JS is highShared JS is largeInspect First Load JS shared by all with bundle analyzer.
One route has much larger Size than othersRoute-specific heavy importLook for charts, editors, maps, carousels, or client-only libraries.
Static page shows as ƒRequest-time API or uncached fetchCheck cookies, headers, searchParams, no-store, and route config.
Every page loads chart/editor codeImported from layout or shared shellMove it down or use next/dynamic.
MUI app has large shared JSBarrel imports or broad icon importsUse path imports and optimizePackageImports.
Good bundle size but bad INPToo much JavaScript executing after loadProfile main-thread work in Chrome DevTools.
Good desktop, poor mobileJS parse/execute cost on slower devicesTest on mobile throttling and reduce hydration work.

Your action plan

  1. Run next build and identify the routes with high First Load JS.
  2. Check whether the problem is route-specific Size or shared JS.
  3. Run ANALYZE=true next build.
  4. Fix large shared dependencies first.
  5. Move non-interactive logic out of Client Components.
  6. Dynamically import components that are not needed for the initial view.
  7. Replace heavy dependencies used for small jobs.
  8. Run npx knip to remove unused packages.
  9. Verify production compression.
  10. 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()
  • searchParams
  • fetch with cache: '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:

  1. Fix barrel imports.
  2. Move non-interactive work to Server Components.
  3. Dynamically import heavy UI.
  4. Replace heavy dependencies.
  5. Remove unused packages.
  6. Verify compression.

That sequence usually beats random component-level cleanup because it attacks the JavaScript every route pays for.

D
Declan

Software engineer in Melbourne. Writes guides at PageSpeedFix and builds web performance tools. More at dekk.dev.