Skip to content
DERKONLINE

Shrink Your Next.js Bundle With Server Components

How React Server Components and the right client boundaries strip megabytes of JavaScript off the wire.

Derrick S. K. Siawor8 min read

Open the network tab on a typical React app and watch the JavaScript pile up. A megabyte, sometimes two, of bundled code that the browser has to download, parse, and execute before the page becomes interactive. Much of that code does work the user never sees: formatting dates, building markup from data, running logic that produces a result and then sits there inert. It shipped to the browser because, in the old React model, everything ran in the browser. There was no other place for it to run.

React Server Components change that premise, and the consequence for bundle size is not incremental. Code in a Server Component costs zero bytes on the client. Not smaller, not better compressed, not code-split for later. Zero. It runs on the server, produces output, and its source never touches the browser. Used well, this strips megabytes of JavaScript off the wire. Used carelessly, it gives you all the complexity and none of the benefit. The difference is entirely about where you draw your client boundaries.

What "zero bytes on the client" actually means

The mental shift is that components now run in one of two places, and you choose which. A Server Component executes only on the server. It can fetch data directly from your database, render markup, run whatever logic it needs, and the result is sent to the browser as HTML. Fetching that data without stacking sequential round trips is its own discipline, the subject of Next.js server components without the waterfall tax. The component's code, the libraries it imports, the logic it contains, none of that is included in the client bundle, because none of it needs to run in the browser. It already ran, on the server, and the browser received only the output.

This is qualitatively different from code splitting, which people sometimes confuse it with. Code splitting still ships the code; it just ships it later, in a separate chunk, downloaded when needed. A Server Component does not ship the code at all. If you render a page with a Server Component that imports a heavy date-formatting library and a markdown parser, neither library appears in your client bundle. They ran on the server. The browser got formatted dates and parsed markup, not the tools that produced them.

The default in the Next.js App Router is that components are Server Components unless you say otherwise. So the baseline is already zero-client-JS for everything you do not explicitly opt into the browser. The work is keeping it that way except where interactivity genuinely requires the browser.

The 'use client' boundary is the whole game

You opt a component into the browser with the 'use client' directive at the top of the file. That component, and importantly everything it imports, becomes part of the client bundle, because it now needs to run in the browser to handle the interactivity it provides. Buttons with click handlers, inputs with state, anything that responds to the user in real time has to be a Client Component, because that responsiveness lives in the browser.

The mistake that erases all the benefit is marking too much as client. If you put 'use client' at the top of a large page component, you have just pulled that entire component and its whole import tree into the browser bundle, including all the parts that did not need to be there. The directive is contagious downward: a Client Component's children that it imports come along into the bundle too. So one 'use client' at the wrong level can drag your entire page into the browser and undo the zero-bytes default everywhere beneath it.

The discipline is to push the client boundary as far down the tree as possible. Keep the page, the layout, the data fetching, and the static markup as Server Components, and isolate interactivity into small, specific Client Components at the leaves. A product page is a Server Component that fetches the product and renders its details; only the "add to cart" button, with its click handler and loading state, is a Client Component. The button ships to the browser. The page does not.

Server component tree with one use client leaf showing what ships zero bytes versus hydrates

// product-page.jsx: Server Component (no directive, runs on server)
import { AddToCartButton } from './add-to-cart-button';

export default async function ProductPage({ id }) {
  const product = await db.products.find(id); // direct server data access
  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} /> {/* the only client JS */}
    </main>
  );
}
// add-to-cart-button.jsx: Client Component (small, leaf, interactive)
'use client';
import { useState } from 'react';

export function AddToCartButton({ productId }) {
  const [busy, setBusy] = useState(false);
  // click handler, state, browser interactivity
}

Everything in ProductPage, the data fetch, the markup, the product details, is zero bytes on the client. The only JavaScript that ships is the small button. That is the pattern that produces the dramatic bundle reductions: a tree that is mostly server, with interactivity pushed to small leaves.

Hydration is the cost you are managing

The reason all of this matters for performance is hydration. When a Client Component reaches the browser, React has to hydrate it: download the component's JavaScript, then walk the server-rendered HTML attaching event handlers and wiring up state to make it interactive. Hydration is work, and it is proportional to how much of your page is client code. A page that is almost entirely Client Components has to hydrate almost the entire page, which means downloading a large bundle and doing a lot of attaching before anything responds to a click.

Server Components do not hydrate, because there is nothing to make interactive; they are already-rendered HTML. So every component you keep on the server is a component that costs no hydration, no bundle weight, and no parse time in the browser. The smaller your client surface, the less there is to download and hydrate, and the faster the page becomes interactive. This is the same lever behind cutting your INP below 200ms before it tanks rankings, since less client JavaScript means a less crowded main thread when the user clicks. Minimizing the client boundary is not just about bundle size on the wire; it is about how much work the browser has to do before the user can actually use the page, and the same crowded main thread is what breaks up long tasks so your main thread stays responsive sets out to clear.

What to watch and how to keep it honest

The trap with this architecture is that it is easy to slowly leak interactivity upward until your bundle quietly bloats again. A component needs a tiny bit of state, someone adds 'use client' to it, and because it sits high in the tree it pulls a large subtree into the browser. The bundle grows, and nobody notices until the page feels heavy.

Keep it honest by watching the bundle. Next.js reports the client JavaScript per route at build time; track it and treat a sudden jump as a regression to investigate, a per-route version of the budget in taming the third-party scripts wrecking your page speed. When a route's client bundle grows, find the 'use client' directive that caused it and ask whether it could have been pushed lower, or whether the interactive bit could have been extracted into a smaller leaf component while the rest stayed on the server. Often the fix is to split a component: the static, data-rendering part stays a Server Component, and only the genuinely interactive sliver becomes a Client Component. The work the browser still has to do is then easier to keep snappy with optimistic UI and smart skeletons.

This is the kind of architectural discipline that separates a fast app from a slow one with the same features, and it is central to how we build full-stack web applications that stay light as they grow. The bytes you do ship still need to arrive fast, which is where slashing time to first byte with streaming server rendering takes over from where the bundle work leaves off. The framework gives you the zero-client-JS default for free; keeping it requires drawing the boundaries deliberately and defending them as the app evolves.

The leaner app

The picture this produces is an application that ships a fraction of the JavaScript a traditional React app would. The data fetching, the heavy libraries, the markup generation, all of it runs on the server and arrives as HTML. Only the parts that genuinely need the browser, the buttons, the inputs, the live interactions, ship as client code, isolated into small leaves that hydrate quickly. The user gets a page that paints fast because it is mostly HTML, and becomes interactive fast because there is little to hydrate.

The architecture rewards restraint. The less you ask the browser to run, the faster your app feels, and Server Components make "less" the default instead of something you fight for. The work is just keeping your client boundaries small and pushing them down, so that the megabytes that used to ship to every user simply never leave the server.