"use client" is the most misunderstood directive in modern React. Not because it's complicated, but because the common explanation ("add it when you need useState") describes when you're forced to use it and says nothing about where it should go. Those are different questions, and the second one decides how much JavaScript your users download.
This post is the mental model I wish every tutorial started with, applied to the thing you build most often: full page sections. Heroes, pricing tables, navbars, FAQs. By the end you'll know exactly which parts of a section belong on which side of the boundary, and why the answer is almost never "the whole file."
The model: two module graphs, not two locations
Forget "server vs browser" for a second. The App Router splits your imports into two graphs:
- The server graph: components that render to a serialized description of UI (the RSC payload). Their code never ships to the browser. They can be
async, read the database, touch the filesystem, hold secrets. - The client graph: components that ship as JavaScript, hydrate in the browser, and can hold state, effects, and event handlers.
"use client" is not a per-component switch. It marks a module boundary: the file it sits in, plus everything that file imports, joins the client graph. That transitive part is where teams lose. One directive at the top of a big section file quietly drags every import (icons, card, badge, helper utilities) into the client bundle.
// pricing-section.tsx
"use client"; // one line...
import { Check } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PlanCard } from "./plan-card";
import { compareFeatures } from "./compare-features";
// ...and now every one of these modules ships to the browser,
// whether or not it does anything interactive.use client does not mean client-only rendering
Client components still render to HTML on the server during the initial request. The directive doesn't skip SSR; it opts the module into hydration. The HTML arrives either way. What changes is whether the component's code is also downloaded, parsed, and re-executed in the browser.
Since both kinds produce initial HTML, here's what actually differs:
| Server component | Client component | |
|---|---|---|
| Renders to initial HTML | Yes | Yes (SSR) |
| Code ships in the JS bundle | No | Yes |
| Hydrates / re-renders in browser | No | Yes |
useState, useEffect, event handlers | No | Yes |
async/await data access, secrets | Yes | No |
| Cost of adding one more | ~Free | Bundle + hydration time |
Read the last row again. Server components are close to free at runtime. Client components are the thing you budget.
Rule one: push the boundary to the leaves
A typical marketing section is 95% static markup and 5% interaction. A pricing table is headings, feature lists, and prices; the interactive part is one billing-period toggle. The naive version makes the whole section a client component because the toggle lives inside it. The right version inverts that: the section stays server, and the toggle becomes a tiny client leaf.
// pricing-section.tsx (server component, no directive)
import { PlanCard } from "./plan-card";
import { BillingToggle } from "./billing-toggle";
export async function PricingSection() {
const plans = await getPlans(); // runs on the server, near your data
return (
<section className="py-24">
<h2 className="text-3xl font-semibold tracking-tight">Pricing</h2>
<BillingToggle />
<div className="mt-10 grid gap-6 md:grid-cols-3">
{plans.map((plan) => (
<PlanCard key={plan.id} plan={plan} />
))}
</div>
</section>
);
}// billing-toggle.tsx (the only client code in the section)
"use client";
import { useState } from "react";
export function BillingToggle() {
const [yearly, setYearly] = useState(false);
return (
<button
type="button"
onClick={() => setYearly(!yearly)}
className="cursor-pointer rounded-full border px-4 py-1.5 text-sm"
>
{yearly ? "Billed yearly" : "Billed monthly"}
</button>
);
}The bundle now contains a toggle, not a pricing system. Multiply this across ten sections on a landing page and the difference is not subtle.
Here's the same principle as a refactor. Before: the directive sits at the top of the section. After: it moves down into the one component that needs it.
- "use client";
-
import { ChevronDown } from "lucide-react";
+ import { FaqItem } from "./faq-item";
export function FaqSection({ items }: FaqSectionProps) {
return (
<section className="py-24">
<h2>Frequently asked questions</h2>
{items.map((item) => (
- <details key={item.q}>...</details>
+ <FaqItem key={item.q} {...item} />
))}
</section>
);
}The heading, the section shell, and the mapping logic go back to the server graph. Only FaqItem (the thing with the open/close state) pays the client tax.
Rule two: when the shell must be client, pass children through it
Sometimes the interactive part isn't a leaf. Think of a collapsible container, a carousel, or a tabs shell: the wrapper owns state, but the content inside is static. Making the wrapper client does not force the content to follow it, because of one under-used property of the boundary: children pass through.
// collapsible.tsx
"use client";
import { useState, type ReactNode } from "react";
export function Collapsible({ summary, children }: { summary: string; children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div className="rounded-xl border">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full cursor-pointer px-4 py-3 text-left font-medium"
>
{summary}
</button>
<div hidden={!open} className="border-t px-4 py-3">
{children}
</div>
</div>
);
}// Still a server component:
<Collapsible summary="What's included in Pro?">
{/* This stays in the server graph. Its code never ships. */}
<PlanDetails plan={plan} />
</Collapsible>PlanDetails renders on the server, and its output is slotted into the client shell as already-rendered UI. The client component receives it the way it receives a string prop: as data, not as code. This is the single most effective pattern for keeping heavy content (markdown, data-driven lists, images with server-computed URLs) out of the bundle while still wrapping it in interaction.
The composition test
Before adding "use client" to a component that has children, ask: does the wrapper need to know what's inside, or just where to put it? If it only positions, shows, or hides its children, take them as ReactNode and let the server fill them in.
Rule three: respect the serialization line
Props that cross from a server component into a client component travel inside the RSC payload, which means they must be serializable. Plain objects, arrays, strings, numbers, booleans, and JSX all cross fine. Functions do not.
// ❌ A function can't be serialized into the payload
<Hero onCtaClick={() => analytics.track("cta")} />
// ✅ Plain data crosses the boundary; behavior lives on the client side
<Hero cta={{ label: "Start free", href: "/signup" }} />This constraint looks annoying and is actually a design gift. It forces section components toward a props shape that is pure data: labels, hrefs, image URLs, arrays of items. A section defined by serializable props is a section you can render anywhere (server, client, static export), store in a CMS, or generate from a database row. If you've noticed that well-built shadcn-style blocks take exactly this kind of flat, data-only props object, this is the deeper reason why.
Case study: the mega menu that vanished from your HTML
Here's where the "SSR still happens" nuance earns its keep. A navbar is usually a client component (it owns open/close state), and client components do render on the server. But they render with their initial state. So this innocent pattern:
// ❌ Panel exists only after a click
{open && (
<div className="absolute top-full">
<MegaPanel groups={groups} />
</div>
)}produces server HTML in which the mega menu does not exist. open is false on the server, so the panel (with all its section links, descriptions, and keywords) is absent from the document. Crawlers indexing the page never see your most link-dense navigation. Users on slow connections click and wait for hydration before anything can open.
The fix is to keep the panel mounted and toggle visibility with CSS:
// ✅ Always in the DOM, hidden until open
<div
className={cn(
"absolute top-full transition-opacity",
open ? "visible opacity-100" : "invisible opacity-0 pointer-events-none"
)}
>
<MegaPanel groups={groups} />
</div>Same interaction, same visual result, but the server HTML now contains every link. This applies to any content that matters for SEO or first paint and happens to live behind client state: mega menus, tab panels with real content, accordion bodies. Conditional rendering removes it from the document; conditional visibility keeps it there.
Here's a live navbar from the registry. View the page source and you'll find its navigation fully present in the HTML, before any JavaScript runs:
And for contrast, an interactive section that genuinely earns its client status. The accordion below owns real state, so it ships JS, but notice it's one section doing so, not the whole page:
The hydration mismatch tax
One more consequence of "client components render twice, once on the server and once in the browser": both renders must agree. Anything nondeterministic produces the dreaded hydration mismatch warning, and React may throw away the server HTML and re-render from scratch.
The usual suspects:
// ❌ Different on server and client by definition
<span>{new Date().toLocaleTimeString()}</span>
<span>{Math.random().toString(36).slice(2)}</span>
// ❌ Depends on browser-only state during render
<span>{window.innerWidth > 768 ? "Desktop" : "Mobile"}</span>The escape hatches, in order of preference:
- Compute on the server and pass down. Timestamps, formatted dates, and IDs can be generated in a server component and passed as props. One source of truth, no mismatch possible.
- Defer to after mount. For values that only exist in the browser (viewport, localStorage, theme), render a neutral placeholder first, then update in
useEffect. You saw this exact pattern if you've read the source of theme switchers: they render nothing untilmountedis true. useIdfor generated IDs. It's designed to produce matching values across server and client renders.
Don't reach for suppressHydrationWarning
It silences the message, not the problem. The mismatch still costs a client re-render, and it hides future, unrelated mismatches in the same subtree. Reserve it for the one legitimate case: a value you genuinely cannot know on the server and have deliberately chosen to patch after mount, like the html element's theme class.
The checklist
When you build or install a page section, run it through this:
- Default to server. No directive until something forces one.
- Find the actual interaction. Usually a toggle, a carousel control, a form. Extract it into the smallest possible client leaf.
- Wrapper needs state but content doesn't? Client shell,
children: ReactNode, server content through the middle. - Props crossing the boundary are plain data. Labels, hrefs, arrays. No functions, no class instances.
- SEO-relevant content behind client state? Mount it always, hide it with CSS, toggle visibility instead of existence.
- Nondeterminism in a client component? Move it to the server, or behind
useEffect, or intouseId.
None of this requires new libraries or clever abstractions. It's one directive, placed deliberately instead of reflexively.
Every block in the Beste UI registry is built against this exact discipline: server-first sections, data-only props, interactive islands where interaction actually lives, and CSS-visibility for anything a crawler should see. Install one, open the file, and check where the directive sits. The boundary is always at the leaves.