How to Reduce Next.js First Load JavaScript
This guide is part of Next.js Performance, within Framework Performance. "First Load JS" in Next.js build output is the compressed JavaScript a browser downloads for the first visit to a route: the shared framework and app chunks plus the route's own chunks. It is a strong predictor of hydration cost and early INP on mobile, where each 100KB of compressed JavaScript means roughly a third of a second or more of main-thread work.
First Load JS grows in predictable ways: a client component high in the tree (a layout or provider with "use client"), a large library imported where only a small part is used, a heavy widget included statically on every page, polyfills for old browsers, and analytics SDKs bundled instead of loaded as scripts. Each has a direct fix.
Rapid Diagnosis
- Run
next buildand read First Load JS per route. - Run the bundle analyzer (
ANALYZE=true next buildwith@next/bundle-analyzer) and inspect the client chunks. - Search for
"use client"in layouts, providers and shared components. - List dependencies imported in client components and their sizes.
Root Cause Analysis
1. Client boundaries too high. Everything below a "use client" boundary that is imported ships to the client.
2. Heavy libraries in client code. Date, markdown, syntax highlighting, charting and icon libraries.
3. Statically imported widgets. Comments, chat, maps and carousels included on every page load.
4. Shared providers. Global providers wrapping the app pull their dependencies into the shared chunk.
Step-by-Step Resolution
1. Move client boundaries to the leaves
Keep layouts, pages and content components as Server Components. Put "use client" only on small interactive components (buttons, forms, toggles), and pass data from the server as props.
2. Move non-interactive work to the server
Format dates, render markdown and highlight code in Server Components, so those libraries never reach the client.
// Server Component: markdown rendered on the server, no client cost.
import { marked } from 'marked';
export default async function Post({ slug }) {
const post = await getPost(slug);
return <article dangerouslySetInnerHTML={{ __html: marked.parse(post.body) }} />;
}
// trade-off: rendering untrusted markdown to HTML requires sanitisation; do it on
// the server once rather than in every visitor's browser.
3. Defer heavy client components
'use client';
import dynamic from 'next/dynamic';
const Comments = dynamic(() => import('./Comments'), { loading: () => <div style={{ minHeight: 400 }} /> });
export default function CommentsSection({ id }) {
const [show, setShow] = React.useState(false);
return show ? <Comments id={id} /> : <button onClick={() => setShow(true)}>Show comments</button>;
}
// trade-off: loading on click adds a short delay when the user asks for comments;
// load on visibility instead if most readers open them.
4. Trim shared dependencies
Replace heavy libraries with lighter ones, import specific modules instead of whole packages, use optimizePackageImports for supported libraries, and drop polyfills for browsers outside your support targets.
Verification
Re-run next build and compare First Load JS per route. In the analyzer, confirm removed libraries no longer appear in client chunks. Record a lab trace on a throttled profile: hydration tasks should be shorter. In the field, compare INP and TBT-related metrics per route.
Worked Example: A Documentation Site
A documentation site built with the App Router had 210KB First Load JS on every docs page. The analyzer showed a syntax highlighter (60KB), a markdown parser (30KB) and a search modal (45KB) in the shared chunk, because the docs layout was a client component that used usePathname for active link highlighting and imported the search modal statically. The team moved active-link logic into a small client NavLink component, made the layout a Server Component, rendered markdown and highlighting on the server, and loaded the search modal with next/dynamic on first keyboard shortcut or click. First Load JS dropped to 95KB, TBT on mobile by 380ms, and INP p75 from 220ms to 140ms.
Budgets in CI
Bundle size regresses one dependency at a time. Add a CI step that parses next build output (or .next/ build manifests) and fails if First Load JS for key routes grows beyond a set budget or by more than a set percentage. Tools exist for this, or a small script can compare against a stored baseline. Pair it with the bundle analyzer report as a build artefact, so reviewers can see what changed when the check fails.
// scripts/check-first-load.mjs — fail if a route exceeds its budget (KB).
const budgets = { '/': 110, '/blog/[slug]': 120, '/product/[id]': 140 };
const out = await (await import('node:fs/promises')).readFile('build-output.txt', 'utf8');
for (const [route, kb] of Object.entries(budgets)) {
const m = out.match(new RegExp(`${route.replace(/[[\]]/g, '\\$&')}\\s+.*?([\\d.]+) kB\\s*$`, 'm'));
if (m && Number(m[1]) > kb) { console.error(`${route}: ${m[1]} kB > ${kb} kB`); process.exitCode = 1; }
}
// trade-off: parsing human-readable build output is brittle across versions;
// prefer reading build manifests if your Next.js version exposes them.
Hydration Beyond Bundle Size
Smaller bundles reduce download and parse time, but hydration cost also depends on how many client components render on load. A page with 90KB of JavaScript that hydrates 500 client components can still produce long tasks. Keep client components small and few, use Suspense boundaries to split hydration, and avoid rendering hidden interactive UI (menus, modals) on load.
Common Mistakes
"use client"in the root layout. The most common cause of large bundles.- Formatting libraries in client components. Format on the server instead.
- Static imports of rarely used widgets. Use
next/dynamic. - Measuring uncompressed sizes. First Load JS is compressed; compare like with like.
Edge Cases
Context providers. Providers must be client components; place them as low as possible and keep their dependencies small.
Third-party UI libraries. Some require client components for everything; consider lighter or server-compatible alternatives for static parts.
Pages Router. Without Server Components, focus on dynamic imports, dependency trimming and moving logic to getStaticProps/getServerSideProps.
Shared chunks. Code used by many routes lands in shared chunks loaded everywhere; keep shared code lean.
FAQ
What is First Load JS?
The compressed JavaScript required for the first visit to a route, including shared chunks, as reported by next build.
Does "use client" make a component render only on the client?
No. Client components still render on the server for the initial HTML, but their code ships to the browser and they hydrate.
How do I find which library is largest?
Use @next/bundle-analyzer and inspect the client chunks for the route.
Should I use next/dynamic with ssr: false?
Only for components that need browser APIs during render. Otherwise keep SSR so the HTML includes the content, and defer only the JavaScript.
Does reducing First Load JS improve LCP?
Sometimes, by reducing competition for bandwidth and main-thread time. The bigger effects are usually on INP and TBT.
What is optimizePackageImports?
A Next.js option that rewrites imports from supported libraries so only the modules you use are bundled, avoiding barrel-file bloat.
Can I check bundle size in pull requests?
Yes. Compare First Load JS from next build against a baseline in CI and comment or fail when routes grow.
Do Server Actions add client JavaScript?
A small amount for the client-side invocation. The action's implementation stays on the server.
Does Turbopack change bundle size?
Bundler choice can affect output slightly, but First Load JS is driven mostly by what your code imports and where client boundaries sit. The same fixes apply.
Related
- Next.js performance — the topic overview.
- Choosing next/script loading strategies — third-party code outside the bundle.
- JavaScript bundle optimization — general bundle techniques.