Skip to content

Where `use client` Actually Costs You

The client boundary is not a performance setting, it is a bundle boundary. Here is how to find where yours has drifted upward, and what moving it back down is worth.

4 min read

use client does not mean "this component runs on the client." Every component in a Next.js application renders on the server at least once.

What use client actually declares is a boundary: from this module downward, everything gets compiled into the JavaScript bundle and shipped to the browser so it can hydrate. That is the cost. Not the rendering — the shipping.

Once you hold that definition, the common mistakes become obvious.

The upward drift

Boundaries drift toward the root, one reasonable decision at a time.

A designer asks for a dropdown in the header. The header needs useState, so use client goes at the top of Header.tsx. The header imports Nav, which imports NavItem, which imports a formatDate utility that imports a date library. None of that needed to be interactive. All of it is now in the bundle, on every page, for every visitor.

Six months later the layout is a client component and nobody remembers why.

Finding your actual boundary

The build output tells you where it went wrong. Look at First Load JS per route:

Route (app)                      Size  First Load JS
┌ ○ /                          1.2 kB         184 kB
├ ○ /about                     0.8 kB         184 kB
└ ○ /pricing                   2.1 kB         186 kB

When every route carries the same large baseline, the weight is in the shared layout, not in the pages. That is the signature of a client boundary that has climbed into the layout tree.

For the detail, run the bundle analyser:

npm install --save-dev @next/bundle-analyzer
ANALYZE=true npm run build

You are looking for two things: libraries you did not expect, and libraries that appear in the shared chunk when they should be in one route's chunk.

Pushing the boundary down

The fix is almost always the same shape — keep the interactive leaf as a client component, keep everything around it on the server.

Instead of this:

'use client';
 
import { heavyMarkdownRenderer } from 'some-large-lib';
 
export function Article({ content, comments }) {
  const [showComments, setShowComments] = useState(false);
 
  return (
    <article>
      {heavyMarkdownRenderer(content)}
      <button onClick={() => setShowComments(true)}>Comments</button>
      {showComments ? <Comments data={comments} /> : null}
    </article>
  );
}

Do this:

// Article.tsx — server component, no 'use client'
import { heavyMarkdownRenderer } from 'some-large-lib';
import { CommentsToggle } from './CommentsToggle';
 
export function Article({ content, comments }) {
  return (
    <article>
      {heavyMarkdownRenderer(content)}
      <CommentsToggle>
        <Comments data={comments} />
      </CommentsToggle>
    </article>
  );
}
// CommentsToggle.tsx — the only client component
'use client';
 
export function CommentsToggle({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(false);
 
  return (
    <>
      <button onClick={() => setOpen(true)}>Comments</button>
      {open ? children : null}
    </>
  );
}

The markdown library stays on the server. Comments stays a server component even though it is rendered inside a client component — because it is passed as children, it was already rendered on the server and arrives as serialised output rather than as code.

Children passed through a client component do not cross the boundary. That one rule resolves most of the cases where people believe they are forced to go client-side.

Why this shows up in INP, not just load time

Interaction to Next Paint measures how long the main thread takes to respond when a visitor taps something. Every kilobyte of JavaScript in the bundle is parsed, compiled and executed before the page can respond to anything, and hydration competes with the visitor's first tap.

On a mid-range Android device — which is most of the world — 300KB of JavaScript is roughly a second of main-thread work before hydration completes. A tap during that second gets queued. That is a failed INP, and no amount of CSS work will fix it.

Server Components are not primarily a rendering optimisation. They are a way of not sending the code in the first place.

Things that do not need to be client components

From the audits we run, the recurring false positives:

  • Formatting dates and numbers. Intl works on the server. If the concern is the visitor's timezone, pass the formatted string down, or render the <time> element with a dateTime attribute and let CSS or a tiny leaf component handle the display.
  • Reading a theme. The data-theme attribute plus CSS handles it with no JavaScript. The toggle in this site's header is a client component of about thirty lines and carries no state at all.
  • Fetching data on mount. If it is not user-specific, fetch it on the server. If it is, fetch it on the server behind Suspense.
  • Analytics. Load it in a <Script> with strategy="afterInteractive" or "lazyOnload", not inside a component.
  • Icon libraries. Icons are markup. Import the individual SVG, not the barrel file that pulls in two thousand of them.

The review rule we use

In every codebase we work in, use client is treated the way a dangerously prefix is treated: it is allowed, it is sometimes correct, and it never passes review without a sentence explaining why the boundary belongs at that exact file rather than one level lower.

That single convention is worth more than any bundle-size tooling, because it catches the problem at the moment it is introduced rather than eighteen months later when someone finally opens the analyser.

Back to all articles