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.
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 buildYou 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.
Intlworks on the server. If the concern is the visitor's timezone, pass the formatted string down, or render the<time>element with adateTimeattribute and let CSS or a tiny leaf component handle the display. - Reading a theme. The
data-themeattribute 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>withstrategy="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.
