
How to Analyze and Reduce JavaScript Bundle Size in Next.js?
Every kilobyte of JavaScript you send to the browser has to be downloaded, parsed, compiled, and executed before the page becomes fully interactive. On a fast laptop that cost is easy to miss. On a mid-range phone over a congested mobile connection, an oversized bundle shows up as a sluggish Interaction to Next Paint, a long main-thread block during hydration, and visitors who give up before the page responds. Next.js already does a lot for you through automatic code splitting and tree shaking, but a single careless import, such as a full date library, an icon set, or a chart package pulled into the root layout, can undo most of that work.
This article covers how to measure and shrink JavaScript bundles in a modern Next.js App Router project: why the size columns disappeared from next build in Next.js 16, how to use the new Turbopack Bundle Analyzer and the older @next/bundle-analyzer for Webpack, how to read the results, and the specific techniques that consistently remove the most weight, from moving work into Server Components to lazy loading, optimizePackageImports, and replacing heavy dependencies.
Why Bundle Size Still Matters with Server Components
The App Router changed the bundle size conversation. Server Components render on the server, and their code never reaches the browser. Only Client Components, the files marked with "use client" and everything they import, become client JavaScript. That means two projects with identical UI can ship wildly different amounts of JavaScript depending on where the "use client" boundaries sit.
| What ends up in the client bundle | What stays on the server |
|---|---|
Files with "use client" and their imports | Server Components and their imports |
| Libraries used inside Client Components | Data fetching, ORMs, database drivers |
| React and the Next.js client runtime | Markdown parsing, syntax highlighting done at render |
| Polyfills for the browser targets | Route Handlers and Server Actions bodies |
The practical consequence: the biggest bundle wins usually come from moving work across that boundary, not from micro-optimizing individual components. If you want a refresher on how Next.js splits code automatically, see how does Next.js handle code splitting.
Where Did the Size Numbers in next build Go?
If you upgraded to Next.js 16 and wondered why next build no longer prints a First Load JS column, that was deliberate. Next.js 16 removed the size and First Load JS metrics from the build output because they were inaccurate for server-driven architectures using React Server Components, and the Turbopack and Webpack implementations disagreed on how to count the Client Component payload.
That leaves two kinds of measurement:
- Bundle analysis to see which modules make up each route's client JavaScript and why they are included.
- Real page measurement with Lighthouse, Chrome DevTools, or field data such as Vercel Analytics or your own Web Vitals reporting, to see what visitors actually download and how it affects Core Web Vitals.
Use the analyzer to find problems and the browser tools to confirm that a fix made a difference.
Analyzing Bundles with the Turbopack Bundle Analyzer
Turbopack is the default bundler for both next dev and next build in Next.js 16. Starting with Next.js 16.1, it includes an experimental Bundle Analyzer built on Turbopack's module graph. It needs no plugin and no config change:
# Terminal
npx next experimental-analyze
The command analyzes the application without producing a build, then starts a local server, on port 4000 by default, with an interactive view. In the UI you can:
- Filter by route, so you look at one page's bundle at a time.
- Switch between client and server views.
- Filter by type: JavaScript, CSS, or JSON.
- Click any module in the treemap to see its size and the full import chain that pulled it in, including chains that cross from a Server Component into a Client Component or through a dynamic import.
The import chain view is what makes this tool valuable. A treemap tells you that moment is large; the import chain tells you that it arrived through components/ui/DatePicker.tsx, which is imported by app/layout.tsx, which is why it is on every route.
Saving the Analysis for Before-and-After Comparisons
To keep a snapshot instead of serving the UI, use the --output flag. The files are written to .next/diagnostics/analyze:
# Terminal
npx next experimental-analyze --output
cp -r .next/diagnostics/analyze ./analyze-before
# ...make your changes...
npx next experimental-analyze --output
cp -r .next/diagnostics/analyze ./analyze-after
Keeping a "before" copy makes it easy to confirm that a refactor really removed the module you targeted and did not pull in something new.
Analyzing Bundles with @next/bundle-analyzer for Webpack
If your project still builds with Webpack, because you rely on a custom webpack config or you run next build --webpack, use the @next/bundle-analyzer plugin. It is also what you will use on Next.js 15 and earlier:
# Terminal
npm install -D @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);
Add a script so nobody has to remember the environment variable:
{
"scripts": {
"analyze": "ANALYZE=true next build --webpack"
}
}
Running npm run analyze produces a Webpack build and opens three HTML treemap reports: client.html, nodejs.html, and edge.html. For bundle size, the client report is the one that matters. On Windows, use the cross-env package to set the variable in the script.
How to Read the Results
Whichever analyzer you use, work through the client view with these questions:
- What is in the shared chunks? Code that every route loads, typically from the root layout and its Client Components, has the biggest impact because every visitor pays for it on the first page view.
- Which single packages are largest? Sort by size. A few packages usually account for most of the weight.
- Is anything duplicated? Two versions of the same library, or both
lodashandlodash-es, show up as near-identical blocks. - Is anything here that should not be? Server-only code, large JSON files, full locale bundles, or a whole icon set where you use three icons.
- Why is it here? Follow the import chain to the file that introduced it. The fix belongs in that file.
Write down the top five offenders and their sizes before changing anything. Then fix them one at a time and re-run the analyzer after each change.
Techniques That Reduce Bundle Size
1. Move Work into Server Components
The most effective technique in the App Router is to stop shipping code that does not need to run in the browser. Libraries that only transform data into markup, such as syntax highlighters, Markdown parsers, date formatters, and sanitizers, can run in a Server Component, which sends only the finished HTML.
Here is a common anti-pattern: a code block highlighted in the browser.
// components/CodeBlock.tsx (before)
"use client";
import { Highlight, themes } from "prism-react-renderer";
export function CodeBlock({
code,
language,
}: {
code: string;
language: string;
}) {
return (
<Highlight code={code} language={language} theme={themes.github}>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={className} style={style}>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</pre>
)}
</Highlight>
);
}
The same output rendered on the server, with Shiki, ships no highlighting code at all:
// components/CodeBlock.tsx (after)
import { codeToHtml } from "shiki";
export async function CodeBlock({
code,
language,
}: {
code: string;
language: string;
}) {
const html = await codeToHtml(code, { lang: language, theme: "github-dark" });
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Shiki's output is generated from the code string you pass in, not from user input, so injecting it as HTML is safe here.
2. Push the "use client" Boundary Down
A "use client" directive at the top of a large component makes that component and everything it imports client code. Often only a small part, like a button with an onClick, actually needs interactivity.
// app/products/[id]/page.tsx
import { getProduct } from "@/lib/products";
import { AddToCartButton } from "./AddToCartButton";
import { ProductGallery } from "./ProductGallery";
type Props = { params: Promise<{ id: string }> };
export default async function ProductPage({ params }: Props) {
const { id } = await params;
const product = await getProduct(id);
return (
<main>
{/* Server-rendered: no JavaScript shipped for these */}
<h1>{product.name}</h1>
<ProductGallery images={product.images} />
<p>{product.description}</p>
{/* The only interactive leaf */}
<AddToCartButton productId={product.id} />
</main>
);
}
// app/products/[id]/AddToCartButton.tsx
"use client";
import { useTransition } from "react";
import { addToCart } from "./actions";
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => startTransition(() => addToCart(productId))}
>
{isPending ? "Adding..." : "Add to cart"}
</button>
);
}
Only AddToCartButton and React's transition code reach the browser. The page, the gallery markup, and the data-fetching code do not. Audit your root layout in particular: a header that became a Client Component to support a mobile menu toggle can drag the entire navigation, its icons, and its helpers into every route. Extract the toggle into its own small Client Component and keep the rest on the server.
3. Lazy Load Heavy Client Components
Some interactive features are big and are not needed for the first render: rich text editors, maps, charts, video players, emoji pickers, and modal content. Load them on demand with next/dynamic, which splits them into separate chunks:
// app/dashboard/ReportPanel.tsx
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
const RevenueChart = dynamic(() => import("@/components/RevenueChart"), {
loading: () => <div className="h-80 animate-pulse rounded bg-gray-100" />,
});
const ExportDialog = dynamic(() => import("@/components/ExportDialog"));
export function ReportPanel() {
const [showExport, setShowExport] = useState(false);
return (
<section>
<RevenueChart />
<button onClick={() => setShowExport(true)}>Export</button>
{showExport && <ExportDialog onClose={() => setShowExport(false)} />}
</section>
);
}
RevenueChart loads in its own chunk in parallel with the page, and ExportDialog is not downloaded at all until the user clicks Export. Note that the ssr: false option can only be used inside a Client Component; calling dynamic with ssr: false in a Server Component is an error.
You can also import a library only when it is needed, inside an event handler:
// components/DownloadCsvButton.tsx
"use client";
export function DownloadCsvButton({
rows,
}: {
rows: Record<string, unknown>[];
}) {
async function handleClick() {
const { unparse } = await import("papaparse");
const blob = new Blob([unparse(rows)], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "report.csv";
link.click();
URL.revokeObjectURL(url);
}
return <button onClick={handleClick}>Download CSV</button>;
}
For more patterns, see what are dynamic imports and how they are used in Next.js and how to implement lazy loading in Next.js.
4. Use optimizePackageImports for Barrel-Heavy Libraries
Many libraries expose everything through a single index file, a so-called barrel file. Importing one icon from a barrel can force the bundler to process, and sometimes include, far more than you use. The optimizePackageImports option rewrites those imports to point at the individual modules:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["@acme/ui", "my-icon-library"],
},
};
export default nextConfig;
Next.js already applies this optimization automatically to many popular packages, including lucide-react, date-fns, lodash-es, @heroicons/react, react-icons, @mui/material, @mui/icons-material, recharts, rxjs, and antd, so you do not need to list those. Add your own design system package and any other library that exports hundreds of modules from one entry point.
5. Replace or Trim Heavy Dependencies
Some packages are simply large. The analyzer makes them obvious; the fix is to replace them or import less of them.
| Heavy choice | Lighter alternative |
|---|---|
moment with all locales | date-fns, dayjs, or the built-in Intl.DateTimeFormat |
lodash (CommonJS, whole library) | lodash-es with named imports, or native array methods |
axios in Client Components | The built-in fetch |
uuid in the browser | crypto.randomUUID() |
| A full charting suite for one sparkline | A small SVG component |
| A UI kit used for two components | Copy-in components or headless primitives |
Platform APIs are free: Intl handles dates, numbers, relative times, and plural rules; URLSearchParams parses query strings; structuredClone deep copies objects. Before adding a dependency to a Client Component, check whether the browser already does the job.
6. Keep Large Data Out of Client Components
Importing a JSON file into a Client Component embeds the whole file in the bundle. A 300 KB list of countries, a translation file for every language, or a big configuration object all become JavaScript the browser must parse.
Load that data in a Server Component and pass down only what the client needs as props:
// app/checkout/page.tsx
import countries from "@/data/countries.json";
import { CountrySelect } from "./CountrySelect";
export default function CheckoutPage() {
const options = countries.map((c) => ({ value: c.code, label: c.name }));
return <CountrySelect options={options} />;
}
The full file, with its flags, phone codes, and currency data, stays on the server. Only the code and name pairs travel to the browser as serialized props.
7. Keep Server-Only Code Out by Accident
Sometimes server code leaks into the client because a shared utility file imports something heavy, such as an ORM, an SDK, or a crypto library. Mark server-only modules so that the build fails if a Client Component imports them:
// lib/db.ts
import "server-only";
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();
Install it with npm install server-only. Split mixed utility files into a server file and a client-safe file so that formatting helpers do not drag database code along with them.
8. Load Third-Party Scripts Wisely
Analytics, chat widgets, and A/B testing tools are often the heaviest JavaScript on a page, and they do not show up in your bundle analyzer at all because they load from other domains. Use next/script with strategy="lazyOnload" or afterInteractive so they do not compete with your own code during hydration, and audit them with the Network panel. The details are in how to load third-party scripts efficiently with next/script.
Verifying the Improvement
After each change, confirm the result with real measurements:
- Re-run the analyzer and compare against your saved "before" output. The module you targeted should be gone from the client view or moved into an on-demand chunk.
- Check the Network panel. Build and start production with
npm run buildandnpm start, open DevTools, filter by JS, disable cache, and reload. Note the total transferred size for the route, compressed and uncompressed. - Run Lighthouse in a throttled mobile profile. Look at Total Blocking Time and the "Reduce unused JavaScript" audit, which lists scripts with a high proportion of unexecuted code.
- Watch field data. Interaction to Next Paint from real users is the metric that ultimately reflects whether less JavaScript made the page feel faster.
Consider adding a budget to code review: if a pull request adds a dependency to a Client Component, the author runs the analyzer and notes the size change in the description.
Common Problems and Fixes
- The build output shows no First Load JS column. That is expected in Next.js 16. Use
next experimental-analyzeor@next/bundle-analyzer, plus Lighthouse. @next/bundle-analyzerproduces no report. The plugin only works with Webpack. Runnext build --webpack, or use the Turbopack analyzer instead.- A library appears on every route. It is imported from the root layout or a component used in it. Follow the import chain, then move it to the route that needs it or lazy load it.
dynamicwithssr: falsethrows an error. It is only allowed in Client Components. Move thedynamiccall into a file marked with"use client".- Tree shaking does not remove unused exports. The package ships CommonJS or has side effects. Use an ES module version, import from specific subpaths, or add it to
optimizePackageImports. - Bundle shrank but the page is not faster. Third-party scripts, unoptimized images, or slow server responses may dominate. Check the full waterfall, not just your own JavaScript.
Bundle Size FAQ
The numbers were inaccurate for apps built with React Server Components, and the Turbopack and Webpack implementations disagreed on how to count the Client Component payload. Next.js now recommends the Bundle Analyzer for composition and tools like Lighthouse for real downloaded size.
If you build with Turbopack, which is the default in Next.js 16, use next experimental-analyze. It is built into Next.js 16.1 and later and shows full import chains. If you build with Webpack or use an older Next.js version, use the @next/bundle-analyzer plugin.
No. Server Components and the libraries they import run only on the server. The browser receives their rendered output. Only Client Components and their imports become client JavaScript.
No. Next.js already optimizes a list of popular libraries automatically, including lucide-react, date-fns, lodash-es, react-icons, and the MUI packages. Add your own design system or other barrel-heavy libraries that are not on that list.
There is no single number, but many teams aim to keep compressed client JavaScript for a typical content page well under a few hundred kilobytes, with content pages often far lower. The better test is field data: if Interaction to Next Paint and Total Blocking Time are good on mid-range phones, your budget is working.
Conclusion
Reducing bundle size in Next.js starts with measurement. Since Next.js 16 no longer prints size columns during the build, use the Turbopack Bundle Analyzer with next experimental-analyze, or @next/bundle-analyzer on Webpack, to see which modules make up each route's client JavaScript and exactly which import brought them in.
Then apply the fixes that remove the most weight: keep rendering work in Server Components, push "use client" down to small interactive leaves, lazy load heavy features with next/dynamic, use optimizePackageImports for barrel-heavy libraries, replace oversized dependencies with platform APIs, and keep large data and server-only code out of the client. Verify each change with the analyzer and Lighthouse, and keep watching field data, so the bundle stays small as the application grows. For broader speed work beyond JavaScript, see how to optimize performance in a Next.js app.
Here are some useful references for going deeper on bundle optimization:
- Next.js Docs: Optimizing package bundling — the Turbopack Bundle Analyzer, @next/bundle-analyzer, and fixes for large bundles.
- Next.js Docs: optimizePackageImports — configuration and the list of packages optimized by default.
- Next.js Docs: Lazy Loading — next/dynamic, React.lazy, and loading external libraries on demand.
- Next.js Docs: Upgrading to Version 16 — the removal of build size metrics and the switch to Turbopack by default.
- web.dev: Reduce JavaScript payloads with code splitting — the performance principles behind smaller bundles.


