Skip to content
5 min read

What is actually in your Next.js JavaScript bundle

Shipping less JavaScript starts with knowing what is in it. Reading the build output, running the analyzer, and the four imports that usually explain the size.

On this page
  1. Start with the build output, not a tool
  2. Then look inside, with the analyzer
  3. The four imports that usually explain it
  4. The App Router lever: where the code runs
  5. Verify what you actually shipped
  6. The takeaway

"Ship less JavaScript" is advice nobody argues with and almost nobody acts on, because the first question — less of what? — has no obvious answer. The bundle is a number in the build output and a folder of hashed filenames, and neither of those tells you which import to go and delete.

This is how to turn that number into a short list of names.

Start with the build output, not a tool

Before installing anything, run a production build and read the route table Next prints:

code
Route (app)                              Size  First Load JS
┌ ○ /                                  1.2 kB         112 kB
├ ○ /blog                              1.8 kB         113 kB
├ ● /blog/[slug]                       4.1 kB         148 kB
└ ○ /about                               940 B         111 kB
+ First Load JS shared by all                         106 kB

Two columns, and the second one is the one that matters. Size is the code unique to that route. First Load JS is everything the browser must download and execute before that route is interactive — the route's own code plus the shared chunk.

The shared line at the bottom is the quiet one. It is paid on every route, which means a heavy dependency imported in a root layout costs far more than the same dependency imported in a single page. If the shared chunk is large, no amount of per-page work will help.

So the table gives you the question: is the problem one fat route, or a fat baseline?

Then look inside, with the analyzer

Once you know which chunk to open, @next/bundle-analyzer shows what is in it:

next.config.tsts
import withBundleAnalyzer from "@next/bundle-analyzer";

const analyze = withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" });

export default analyze({
  // your config
});
bash
ANALYZE=true npm run build

It opens a treemap where area is size. The useful habit is to ignore everything small: in almost every bundle I have opened, three or four blocks explain most of the weight and the remaining hundred are noise. Name those blocks, then go and find the import that pulled each one in.

The four imports that usually explain it

Date libraries. A full-featured date library with locale data can be larger than your application code. If you format a handful of dates, Intl.DateTimeFormat is built into the browser and costs nothing:

ts
const formatted = new Intl.DateTimeFormat("en-IN", {
  day: "numeric", month: "short", year: "numeric",
}).format(new Date(iso));

Icon packs imported from the index. import { ChevronDown } from "some-icons" reads as one icon and can resolve to a module that re-exports thousands. Import the specific path, or list the package in optimizePackageImports:

next.config.tsts
export default {
  experimental: { optimizePackageImports: ["some-icons", "some-ui-kit"] },
};

Utility libraries used for two functions. Importing a whole utility package to call debounce and groupBy is a trade most teams would decline if the cost were visible. Either import the two functions from their own paths, or write them — both are under ten lines.

A heavy component rendered rarely. A chart library, a rich-text editor, a map. These belong behind a dynamic import so they load when the thing is actually used:

tsx
import dynamic from "next/dynamic";

const Chart = dynamic(() => import("./Chart"), {
  ssr: false,
  loading: () => <div className="h-64" />,  // reserve the space, or you trade bytes for layout shift
});

That loading placeholder is not decoration. Swapping a lazily-loaded component into a page with no reserved height is exactly how a bundle fix becomes a Cumulative Layout Shift problem.

The App Router lever: where the code runs

Everything above is tuning. The structural fix in the App Router is deciding what runs on the server at all.

A Server Component's code never reaches the browser. The markdown parser, the syntax highlighter, the database client, the date formatting — all of it executes during the render and what ships is HTML. The moment a component is marked "use client", it and everything it imports become part of the client bundle.

Which makes "use client" the most expensive line in a Next.js codebase, and the usual failure is putting it too high:

tsx
// Costly: the whole page, its imports and its children become client code
"use client";
export default function Page() { /* … */ }
tsx
// Better: the page stays on the server, and one small leaf is interactive
export default function Page() {
  return (
    <article>
      <Prose html={html} />        {/* server: parser never ships */}
      <CopyButton text={code} />   {/* "use client" lives in here */}
    </article>
  );
}

Push the boundary down to the leaves that genuinely need state, effects or event handlers. The rest of the tree — and every library it uses — stays on the server. This is the same boundary discipline that keeps the hero out of hydration's way and the reason it appears in my Next.js performance guide as the first thing to check.

Verify what you actually shipped

Two checks, because a bundle fix that is not confirmed tends to come back:

Re-read the route table. Build again and compare First Load JS per route. If the shared line did not move, the dependency is still in a shared path, however many dynamic imports you added.

Read the network panel on a cold load. Throttle to a slow connection, disable cache, and look at what JavaScript is requested before the page becomes interactive. The analyzer shows what exists; the network panel shows what the browser actually fetched for this route, which is the only number a visitor experiences.

It is also worth knowing that Next's own defaults shift between versions — bundling behaviour and image handling both changed in ways that are easy to miss, which I collected in the Next.js 16 notes. A size change you did not cause is usually one of those.

The takeaway

Bundle work goes wrong when it starts with the folder of hashed files. Start with the table instead: find the route, then find the chunk, then find the three names that explain it.

In practice the fix is almost always one of four things — a date library replaced by Intl, an index import narrowed to a path, a rarely-used component moved behind dynamic, or a "use client" pushed down the tree. Everything after that is rounding, and rounding is not worth a sprint.

Frequently asked

What is a good JavaScript bundle size for a Next.js page?

There is no official threshold, but the practical guidance is to keep First Load JS under roughly 150-200 KB compressed for a content page. What matters more than the number is the trend: a page that grows 30 KB in a sprint is a regression worth explaining, whether it started at 90 KB or 300 KB.

How do I see what is inside my Next.js bundle?

Start with the route table that next build prints, which gives per-route and shared First Load JS. For the contents, add @next/bundle-analyzer and run a build with ANALYZE=true; it opens a treemap where the area of each block is its size, so the expensive dependencies are the ones you can see without squinting.

Does using Server Components reduce bundle size?

Yes, and it is the largest single lever in the App Router. A Server Component renders to HTML on the server and sends no component code to the browser, so any library used only there — a markdown parser, a date formatter, a database client — never reaches the client bundle at all.

Why did my bundle grow after adding one small import?

Usually a barrel file. Importing one named export from a package index can pull the whole index into the graph when the package is not tree-shakeable or the import is re-exported through a chain of files. Import from the specific path instead, or add the package to optimizePackageImports in next.config.ts.

Found this useful?Share on XShare on LinkedIn

Related reading