Everyone can type "use client". Almost nobody can say what survives the trip across it — and then something breaks: next build dies at prerender, the error names no file and no import chain, and the prop that killed it was an arrow one level down inside an object called options.
Here's the uncomfortable secret: the boundary is one serializer. React walks every prop you hand a client component, encodes each value it has a branch for, and throws on the first one it doesn't. This post reads those branches out of React 19's Flight source — one file, no framework — and shows the two traps that pass code review and fail the build anyway.
What crosses
A prop is legal if the serializer has a branch for it. Everything else falls into one prototype check and throws. The whole contract fits on a screen:
// app/page.tsx — a Server Component. Every comment is the serializer's verdict.
export default function Page() {
return (
<Chart
title="Q3"
data={{ rows: [1, 2, 3] }}
when={new Date()}
seen={new Set([1])}
index={new Map()}
rows={fetchRows()} // an un-awaited Promise; the client calls use(rows)
bytes={new Uint8Array(8)} // ArrayBuffer, DataView, every typed array
upload={new File([], 'a.csv')} // there is no File branch — a File is a Blob
form={new FormData()}
stream={new ReadableStream()}
kind={Symbol.for('chart')} // global symbols cross; Symbol('chart') throws
Slot={Legend} // a client component: a function, and a client reference
save={saveRow} // a "use server" function: a server reference
err={new Error('boom')} // crosses — and arrives empty in production
// no branch — every one of these throws at render
match={/q3/}
href={new URL('https://x.dev')}
cache={new WeakMap()}
user={new User('ada')}
bare={Object.create(null)}
onPick={(id) => select(id)}
/>
);
}
Four of those lines are the ones people get wrong:
-
new Error()crosses, and production empties it. In dev, Flight outlines{ message, stack, env }and returns'$Z' + id. In production the entire branch isreturn '$Z'— the marker, with nothing behind it. The build stays green, the prop arrives, and it carries no message. -
A class instance is not rejected for being a class. The check is on the prototype:
proto !== ObjectPrototype && (proto === null || getPrototypeOf(proto) !== null)throws. An instance holding nothing but data fields, shaped exactly like a POJO, still throws —{...instance}is the fix — and so doesObject.create(null), on a message readingClasses or null prototypes are not supported.The most plain object you can build is not a plain object. A plain object from another realm, meanwhile, passes: the check steps up one level and finds that realm'sObject.prototype. -
new RegExp(),new URL()andnew WeakMap()are not data. The built-in branches end atDate; everything past them falls into that prototype check.RegExp,WeakMapandWeakSetsat in rsc-gate's whitelist until 0.2.0, precisely because they feel like data. -
The rule is not "no functions". A client component passed as a prop is a function, but it is a client reference, and a
"use server"function is a server reference.<Chart Slot={Legend} />is legal. Only a function React cannot name throws, and the message then depends on what you called the prop:/^on[A-Z]/getsEvent handlers cannot be passed to Client Component props.,childrengetsFunctions are not valid as a child of Client Components..., anything else getsFunctions cannot be passed directly to Client Components.... None of the three names your file.
Trap #1: options={{ ... }} is not a config object
Hand a client chart its configuration and you'll probably write this:
<Chart options={{ data, onSelect: (p) => setSel(p) }} /> // "it's a config object"... right?
Wrong — and one expression proves it. getPrototypeOf({ onSelect: fn }) returns Object.prototype, so the wrapper sails straight past the check from item 2. React then serializes the object by descending into it, reaches the arrow, and throws at prerender. You get the event-handler message, because the name tested against /^on[A-Z]/ is parentPropertyName — the object key, not the JSX attribute. Your "config object" is a function prop with an extra pair of braces.
Real checking is classify, then descend:
<Chart options={{ onSelect: fn }} /> // object member
<Chart items={[fn]} /> // array element
<Chart cb={flag ? fn : undefined} /> // ternary branch
<Chart cb={maybeFn ?? fn} /> // ?? and || branches
<Chart handlers={{ onPick() {} }} /> // method shorthand
<Chart cb={makeHandler()} /> // opaque — stays ok, on purpose
makeHandler() answers nothing statically, so the walk stops there and the prop stays ok. That miss is deliberate, and it is the same discipline as item 4: flag only what React itself would name. And onSelect dies inside options for the same reason Slot={Legend} lives — accept or reject is decided on the value, and only the error message ever reads the name. Review reads prop names; the serializer reads leaves.
Trap #2: thinking the missed hazard is the expensive bug
Writing the checker is smaller than people expect: two questions, is the tag a client boundary, and does every leaf of every prop survive the walk above. What is hard is not what it finds. It is what it must refuse to say.
Version 0.2.0 started failing builds on server-only leaks, which was overdue — the old report announced the leak and exited 0. But the leak test was one line, and it matched the raw specifier, before resolution:
if (SERVER_ONLY_PACKAGES.has(imp.specifier)) leak(file);
A project that aliases "server-only": ["./lib/shim"] in tsconfig paths — a real pattern, used to stop a test runner blowing up on the real package — imports a harmless local module that throws nothing. It got a leak verdict and exit code 2. Red CI on green code. 0.2.0 is deprecated on npm.
The fix is that line plus the question it forgot to ask. It is only the package if the project cannot resolve it away:
if (SERVER_ONLY_PACKAGES.has(imp.specifier) && resolver.resolve(file, imp.specifier) === null) leak(file);
Writing this post turned up a second one, in the symbol check. Flight does not reject symbols; it rejects symbols it cannot name — if (Symbol.for(name) !== value) throw. A symbol from the global registry round-trips through its key and crosses the boundary; the thrown message says so out loud. rsc-gate matched Symbol(...) and Symbol.for(...) alike, so it failed --strict on a prop React is perfectly happy with. That is 0.3.1, and there is now a fixture passing a global symbol on the must-exit-0 side of the suite — which is the actual lesson both times. The gate firing on healthy code is not a bug like the others. It is the one that gets the tool deleted.
Run it
The boundary map, the prop verdicts and the leak detection need no build, no .next/ and no React internals: it is a TypeScript-compiler-API pass over source. On shadcn-ui/taxonomy — 95 modules, 22 boundaries — it takes about a second, on a clone with no npm install behind it:
npx rsc-gate@latest ./my-app --no-build
rsc-gate v0.3.1 — ~/shop
8 modules · 2 "use client" · 1 client-bundled · 1 boundary
BOUNDARIES server → client
app/page.tsx → components/ProductList.tsx imports: ProductList
(run `next build` to see per-boundary bundle cost)
CLIENT-BUNDLED no "use client", ships to the browser anyway — and here is why
utils/format.ts
app/page.tsx
→ components/ProductList.tsx ("use client")
→ utils/format.ts
PROPS ACROSS BOUNDARIES what server code hands to client components
app/page.tsx:10 <ProductList> (components/ProductList.tsx)
products ok
onSelect ✖ function — NOT serializable
functions are not serializable across the server→client boundary — next build fails at prerender (pass a Server Action or move the handler into the client component)
MODULES
[server] app/layout.tsx
[server] app/page.tsx
[server] components/Header.tsx
[client] components/ProductList.tsx
[client] components/ui/Button.tsx
[server] components/ui/Card.tsx
[shared] components/ui/index.ts
[client*] utils/format.ts
[shared] = evaluated in BOTH environments · [client*] = client-bundled without a directive
onSelect ✖ function is the prerender failure, named and located, before there is a build. utils/format.ts has no directive and ships to the browser anyway, because a "use client" module imports it — the same run, moving. Add --strict and it exits 2 instead of 0. The analysis is identical; the exit code is the whole product.
The prop check is 1 of 7 sections
The report prints seven, and only two of them are correctness bugs. Non-serializable props are one. The other is a module importing "server-only" that the client bundle can still reach — through a require(), a bare specifier under baseUrl, a dynamic import(), a tsconfig extends chain or a workspace package. There the failure mode is silence: an empty report and a leaking project look identical. rsc-gate checks both, and writes down where each rule is drawn:
- concepts.md — why a barrel re-export is not a client module: the bundler tree-shakes it, so following it blindly manufactures false positives
-
decisions.md — ADR-001, the spike that planted
onSelect={fn}, watchednext builddie, and found no file path anywhere in the error -
api.md —
analyzeProject()hands back the same analysis the CLI prints, for a pipeline that would rather not shell out
232 tests. CI asserts the --strict exit code of every fixture in both directions: 2 for the fourteen carrying a real hazard or a real leak, 0 for the healthy ones. That second assertion did not exist until a healthy project went red.
It is deliberately a gate, not a dashboard. {...props} is never failed on, because "cannot verify" is not "wrong". The boundary map and the byte count are the framework's home turf. The rules in this post are React's, not Next's, and they break your build, not your bundle size — so they belong in the PR, not the deploy.
npx rsc-gate@latest --strict # exit 2 on a hazard or a leak, so the PR does not merge
If it ever fires on a project of yours that is actually healthy, that is the highest-severity bug this tool can have — file it.
Top comments (0)