All posts
10 min read

Where the use client Boundary Actually Goes

Most React Server Components advice stops at 'add use client when you need hooks.' That's how entire pages end up client-rendered by accident. Here's the real mental model: two module graphs, one serialization line, and a set of rules for keeping UI sections server-first.

reactserver-componentsnextjs

"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:

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

tsx
// 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 componentClient component
Renders to initial HTMLYesYes (SSR)
Code ships in the JS bundleNoYes
Hydrates / re-renders in browserNoYes
useState, useEffect, event handlersNoYes
async/await data access, secretsYesNo
Cost of adding one more~FreeBundle + 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.

tsx
// 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>
  );
}
tsx
// 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.

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

tsx
// 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>
  );
}
tsx
// 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.

tsx
// ❌ 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:

tsx
// ❌ 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:

tsx
// ✅ 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:

Live · Centered Logo Split Nav
View block
navbar30: a client component, yet its navigation is complete in the server-rendered HTML.

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:

Live · Accordion With Live Media
View block
feature1: an interactive island. The sections around it on a real page stay server-rendered.

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:

tsx
// ❌ 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:

  1. 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.
  2. 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 until mounted is true.
  3. useId for 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:

  1. Default to server. No directive until something forces one.
  2. Find the actual interaction. Usually a toggle, a carousel control, a form. Extract it into the smallest possible client leaf.
  3. Wrapper needs state but content doesn't? Client shell, children: ReactNode, server content through the middle.
  4. Props crossing the boundary are plain data. Labels, hrefs, arrays. No functions, no class instances.
  5. SEO-relevant content behind client state? Mount it always, hide it with CSS, toggle visibility instead of existence.
  6. Nondeterminism in a client component? Move it to the server, or behind useEffect, or into useId.

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.