open atlas
↑ Back to track
React, zero to senior RCT · 11 · 02

The serialization boundary: use client splits the module graph

use client marks a module-graph cut: everything a client file imports joins the bundle. Props crossing must serialize — plain data, Date, Map yes; functions and class instances no. Children put server output inside a client shell; use server is the reverse channel.

RCT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The security review found it in fifteen minutes with view-source. A profile page — server component — fetched the current user with the ORM and passed the row straight into a client component: <ProfileCard user={user} />. The ORM returned a plain object, so serialization succeeded without a whisper, and the Flight payload inlined into every profile page contained the full row: email, phone, stripeCustomerId, passwordHash, mfaSecret. No request was hacked; the server politely published its database columns to anyone who pressed Ctrl+U. The same audit caught the inverse failure a week earlier: a shared format.ts helper had been imported into a 'use client' file, and format.ts imported config.ts, which read process.env.STRIPE_SECRET_KEY into an exported constant — one hop later the secret was sitting in a public chunk. Neither bug is exotic. Both are the same misunderstanding: 'use client' is not a component annotation, it is a cut through the module graph, and props crossing that cut are serialized and published. The boundary has exact rules, and this lesson is those rules.

In ten minutes you will know exactly which values can cross the server-to-client boundary, which ones silently leak your database columns, and how the module graph cut works — because both bugs in the Hook are entirely avoidable once you see the rules clearly.

A directive on a module, not a flag on a component

'use client' goes at the top of a file, and it means: from here on, this module and everything it imports, transitively belong to the client bundle. It is a graph cut, not a component property. Mark a 40-line Tabs.tsx and you have not added 40 lines to the bundle — you have added Tabs.tsx plus its imports, plus their imports, until the graph bottoms out. One careless import { format } from "../lib/format" where format.ts pulls a charting utility pulls the chart library too. This is also why a server component imported by a client file silently becomes a client component: there is no marker on the component itself, only its position in the graph relative to the nearest 'use client' cut. The corollary discipline: keep client files small and leaf-like, and treat every import added to one as a bundle-size and secrecy decision.

The defensive tool for the secrecy half is the server-only package: import it in any module that must never reach the client (db.ts, config.ts, anything touching process.env), and the build fails the moment some refactor drags that module across the cut. It costs one import line and converts a silent leak into a compile error. The mirror package client-only exists for modules that must never run server-side.

What may cross: the serialization rules

Props passed from a server component to a client component are serialized into the Flight payload. The allowed list is wider than JSON but strict: primitives (string, number, bigint, boolean, undefined, null, symbols only via Symbol.for), plain objects and arrays, Date, Map, Set, TypedArrays and ArrayBuffer, FormData, JSX produced by server components, Promises (the client unwraps them with use() — this is how streaming data crosses), and references to server functions. The rejected list is what defines the architecture: functions (other than 'use server' functions), class instances (anything whose prototype is not Object.prototype — an ORM entity, a Temporal object, your domain model), and unregistered symbols. A rejected value throws at render time with a serialization error naming the prop.

The dangerous case is the one that does not throw. Most ORMs return plain objects — so a full database row sails through serialization and lands, column by column, in a payload any user can read. The Hook’s passwordHash leak is this exact shape. When you review a PR that passes a model object as a prop, ask yourself: which columns am I comfortable publishing to anyone with view-source? The rule that survives audits: treat the server→client prop surface as a public API. Map rows to explicit DTOs at the boundary — { id, name, avatarUrl } — the same way you would never return SELECT * from a public endpoint.

Quiz

A server component passes four props to a client component: a Date, a Map of settings, a formatRow function, and a full ORM user row (plain object with passwordHash). What happens to each?

Interleaving without importing: the children pattern

The graph cut seems to imply that anything below a client component must be client too — it does not, and the escape hatch is the oldest prop in React. A client component may not import a server component, but it can receive server-rendered output as children (or any JSX-typed prop), because server-rendered JSX is on the serializable list:

// Tabs.tsx — 'use client': owns activeTab state, ~2 kB
export function Tabs({ children }: { children: React.ReactNode }) {
  const [active, setActive] = useState(0);
  return <div>{/* tab headers */}{children[active]}</div>;
}

// page.tsx — server component
<Tabs>
  <ReportPanel data={await db.reports.q3()} />   {/* stays server */}
  <AuditPanel rows={await db.audit.recent()} />  {/* stays server */}
</Tabs>

The panels render on the server — direct database access, zero bundle weight — and arrive inside the payload as already-resolved trees; Tabs just decides which one to show. The composition rule in one line: the importer determines the environment, the renderer does not. A client shell holding server children is the workhorse layout of every serious RSC codebase, and reaching for it is what separates a surgical 'use client' leaf from the page-level directive that drags a whole route into the bundle.

The reverse channel: server functions

Data flows server→client through props; the only sanctioned way back is a server function. Mark a function (or a whole module) with 'use server', and React replaces it, in the client bundle, with a serializable reference; calling it from the browser issues a POST under the hood, with arguments serialized by the same Flight rules and the return value streamed back. Two production consequences follow directly. First, every server function is a public, unauthenticated-by-default endpoint — the framework wires the route for you, and an attacker does not need your UI to call it; validate and authorize inside the function, every time, as you would any handler. Second, the argument and return types obey the same serialization rules as props — no class instances in, no class instances out — so the DTO discipline applies in both directions.

Quiz

A client file imports formatPrice from lib/format.ts; format.ts also imports config.ts which reads process.env.STRIPE_SECRET_KEY into an exported constant. The build succeeds. What just happened, and what would have prevented it?

Recall before you leave
  1. 01
    State the serialization rules for props crossing server to client — what passes, what throws, and which case neither throws nor is safe?
  2. 02
    Why is use client a module-graph statement rather than a component flag, and what two patterns keep the bundle and the secrets under control?
Recap

The boundary between server and client is a cut through the module graph, declared by use client at the top of a file. Everything that file imports, transitively, joins the client bundle — which makes every import added to a client file a bundle-size and secrecy decision, and makes the well-run codebase keep its client files as small interactive leaves. Unmarked modules are environment-neutral and join whichever graph imports them; that neutrality is what lets a shared helper drag a config module holding a Stripe secret into a public chunk, and the one-line guard is the server-only package, which turns that drag into a build error. Props crossing the cut are serialized into the Flight payload under exact rules: primitives, plain objects and arrays, Date, Map, Set, TypedArrays, FormData, server-rendered JSX, Promises and server-function references pass; functions and class instances throw at render time. The case that neither throws nor is safe is the plain-object ORM row — it serializes silently and publishes every column to view-source, which is why the prop surface is treated as a public API with explicit DTO mapping. Interleaving does not require importing: a client shell receives server-rendered children as props, keeping heavy panels on the server while a two-kilobyte Tabs owns the state. And the only road back across the cut is a use server function — a real POST endpoint with Flight-serialized arguments, public by construction, so validation and authorization live inside it, every time. Now when you see a fat prop crossing into a client component, you will reach for the DTO checklist before the PR ships — not after a security review does it for you.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.