DEV Community

Seydi Charyyev
Seydi Charyyev

Posted on

What actually crosses the React Server Component boundary

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)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Four of those lines are the ones people get wrong:

  1. new Error() crosses, and production empties it. In dev, Flight outlines { message, stack, env } and returns '$Z' + id. In production the entire branch is return '$Z' — the marker, with nothing behind it. The build stays green, the prop arrives, and it carries no message.
  2. 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 does Object.create(null), on a message reading Classes 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's Object.prototype.
  3. new RegExp(), new URL() and new WeakMap() are not data. The built-in branches end at Date; everything past them falls into that prototype check. RegExp, WeakMap and WeakSet sat in rsc-gate's whitelist until 0.2.0, precisely because they feel like data.
  4. 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]/ gets Event handlers cannot be passed to Client Component props., children gets Functions are not valid as a child of Client Components..., anything else gets Functions 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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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}, watched next build die, and found no file path anywhere in the error
  • api.mdanalyzeProject() 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
Enter fullscreen mode Exit fullscreen mode

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)