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.

7 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.

The convention only works if everybody shares the same model of what a server component is, so read the rendering model once, properly. Half the boundaries we delete turn out to be forms, and there a server action removes the client component instead of shrinking it. If the bundle is already large and the question is which boundaries to move first, we measure that.

Related questions

Does use client mean the component runs only in the browser?
No. It marks where the client bundle starts. Everything imported below it goes to the browser, and the component itself still renders on the server for the initial HTML.
How do we find where the boundary drifted?
The bundle analyser, read as an import graph instead of a treemap. You are looking for one client component near the top of a tree that dragged everything under it across.
Can a server component sit inside a client component?
Yes, passed as children or as a prop. That is the pattern that keeps a boundary shallow, and the one most teams do not reach for.
Is a context provider at the root a problem?
It is the most common cause of a boundary that sits too high. Wrap the smallest subtree that needs the context, and if that genuinely is the whole application, keep the provider thin so what crosses is small.

← Back to all articles

Call us+1 848 272 7583WhatsApp+90 850 308 5436Emailinfo@codefacture.comContact page