Skip to content
Chapter 30Lesson 4

What crosses the RSC wire

The serialization contract for the RSC payload, which prop values cross from a Server Component to a Client Component and which ones either fail loudly or leak silently.

You have a server-rendered invoice page. It reads the invoice on the server, renders the details, and at the bottom sits a MarkPaidButton, a Client Component because it has to respond to a click. The page defines the click handler and the button needs it, so you do the obvious thing:

app/invoices/[id]/page.tsx
export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) {
const invoice = await getInvoice((await params).id);
const handleClick = () => console.log('marking paid', invoice.id);
return <MarkPaidButton onClick={handleClick} />;
}

The build refuses, with something like Functions cannot be passed directly to Client Components unless you explicitly expose them. You wrote idiomatic React, and the framework drew a line through it.

You already know where that line is. In the earlier lessons of this chapter you learned that 'use client' marks the entry into the browser-side subgraph, and that server-only blocks anything that must never ship to the client. What you don’t yet know is the contract on the line itself: which values are allowed to pass through it. That is the subject of this lesson.

Two facts about that contract carry most of the weight. The first is the error you just hit: passing a function as a prop fails instantly, and once you can read the message, the fix is two short moves. The second is quieter and more dangerous. An over-wide object, say a full invoice row with a customer’s private notes on it, crosses silently, with no error at all, and lands in plain text in the browser where anyone can read it. The wire rejects a function loudly, but it accepts a leak without a word.

Underneath both is a single model, and it is one you have met before. Back in the first chapter you learned structuredClone, the algorithm that deep-copies an object and drops functions and DOM nodes along the way. That same algorithm is, very nearly, the rule for what crosses this boundary, plus a short list of React-specific additions. You are not learning a new rule today; you are recognizing an old one in a new place.

You have heard the term RSC payload named in passing. Before any rules land, it helps to know what the thing physically is, because serialization rules feel arbitrary until you can see what they describe.

When the server renders your Server Component tree, it produces two things, not one. The first is HTML, the markup the browser paints immediately, so the user sees the invoice before any JavaScript runs. The second is the RSC payload : a serialized stream of instructions that the React runtime in the browser uses to rebuild the component tree. It is not HTML. It is closer to a recording of what the server rendered, written in a format React can replay.

That stream says things like “here is some resolved JSX,” “render the Client Component with ID 42 here,” and “resolve this Promise with this value.” When it points to a Client Component, it carries that component’s props, serialized into the payload format. That serialization step is what this lesson is about. A prop sitting on a Client Component has to be turned into something that can travel down a network connection and be rebuilt, faithfully, on the other side.

One fact is worth locking in now, because a later section depends on it. The payload is an ordinary network response: it arrives over HTTP, right next to the HTML, and like any network response, you can open it in your browser’s DevTools and read it.

Server renders the tree once
HTML → painted immediately
RSC payload → reconciles the React tree
Client Component props are serialized here — this lesson.
one render → two transports
Browser receives both
One server render, two transports. The HTML paints the page; the RSC payload rebuilds the tree — and carries every Client Component prop, serialized, in the open.

So when this lesson talks about “what crosses the wire,” picture lane two: that payload, with your props baked into it. The rules below are the rules for what is allowed into lane two.

Structured clone: the baseline you already know

Section titled “Structured clone: the baseline you already know”

The rule is less exotic than the error message suggested: the set of values that cross the wire is structuredClone plus a handful of React extensions.

You met structured clone in the first chapter as a way to deep-copy an object, and you saw there that it copies the data but quietly drops functions and DOM nodes, because those cannot be meaningfully recreated. That same algorithm is the spine of the RSC wire, because the job is identical: take a value, turn it into something that survives a trip across a boundary, and rebuild it on the far side. In the first chapter the boundary was a copy in memory. Here it is a network connection. The rules barely change.

So most of “what crosses” is already in your head. These values cross because structured clone can carry them:

  • Primitives: string, number, boolean, null, undefined, bigint, and symbol (with one catch, below).
  • Built-in collections of serializable values: Array, Map, Set, TypedArray, ArrayBuffer.
  • Date, a first-class built-in. A Date crosses untouched.
  • Plain objects: object-literal shapes, { ... }, whose properties are themselves serializable.

Two of these trip people up, so they are worth a sentence each.

Map and Set cross directly. You may have seen older guidance that you must convert a Map to an array before passing it across; that is stale. Current React serializes both natively. That said, if the component on the other side only iterates the values and never touches the Map or Set API, prefer a plain array or object anyway. It is one fewer moving part, and it matches the project’s habit of reaching for the plainest shape that does the job.

The word “plain” in “plain object” carries real weight. It means an object-literal shape, the kind you would write with {}. A row you read from your database with Drizzle is a plain object, so it crosses without complaint. But the instant an object carries a custom prototype, meaning it is an instance of a class, it falls out of the structured-clone set entirely. That single distinction is the whole of the next section, so keep it in mind.

There is one genuinely new wrinkle, the symbol catch. A symbol crosses only if it was registered in the global symbol registry with Symbol.for('x'). A bare Symbol('x') does not cross, because each call to Symbol() makes a brand-new, unique symbol with no global identity, so there is nothing for the other side to look up and reconstruct. Symbol.for('x') is different: it is a lookup by key into a shared registry, so the far side can ask for the same key and get the same symbol back. A symbol crosses only if both sides can name it.

Passing legal props looks like this: nothing exotic, just the values above flowing into a Client Component.

app/invoices/[id]/page.tsx
export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) {
const invoice = await getInvoice((await params).id); // data layer: Unit 5
return (
<MarkPaidButton
id={invoice.id}
issuedAt={invoice.issuedAt}
labels={new Set(invoice.labels)}
/>
);
}

The fastest way to make this stick is to sort values yourself. The drill below mixes the values that cross with a few that do not. You have not formally met some of them, but the structured-clone instinct gets you to most of them: does this carry data the other side can rebuild, or behaviour and identity that only exist here? Drag each into the right bucket.

Sort each value by whether it can be passed as a prop from a Server Component to a Client Component. Drag each item into the bucket it belongs to, then press Check.

Crosses the wire Serializable — survives the trip and rebuilds
Does not cross The build errors, or the value can't be rebuilt
'paid' — a string
new Date()
new Map([['x', 1]])
new Uint8Array([1, 2, 3])
{ id, total } — a plain object literal
42n — a bigint
Symbol.for('invoice')
A Drizzle invoice row
() => markPaid(id) — a function
new Invoice() — a class instance
Symbol('invoice')
document.body — a DOM node

Structured clone covers the data, but React also renders trees, kicks off work, and wires up server behaviour, and none of that is plain data. So the wire adds four things structured clone never had to handle. Each one gets a full treatment elsewhere in the course; here, the job is just to know they cross and what they are.

Promises cross. A Server Component can pass a Promise<T> straight down as a prop. This unlocks a pattern you will lean on constantly: kick off a slow fetch on the server, hand the still-pending Promise to a Client Component, render the page shell now, and let the value resolve and stream in later instead of blocking the whole page on it. The client reads the Promise with React.use() paired with Suspense, but that consumption side is the whole point of the next chapter, so leave it for now. The point today is that Promises cross.

JSX crosses. Already-rendered Server output can be handed to a Client Component as a prop or as children, and the Client Component renders it without ever seeing where it came from. This is the “wrap, don’t import” pattern from earlier in the chapter, seen from the wire’s side: the server-rendered tree travels inside the payload as serialized JSX, and the Client Component drops it into place without importing it. The Client Component is the frame, and the server-rendered picture slots into it.

References to components cross, not the components themselves. When the payload needs a Client Component, it does not ship that component’s code, because the code was already bundled for the browser separately. The payload carries only a reference, effectively “mount component number 42 right here.” This is the mechanism behind the diagram two sections back: lane two does not carry your button’s source, it carries a pointer to it.

Server Action references cross. A 'use server' function does cross the wire, but as an opaque ID, never as a function body. The client receives a handle it can call, and calling it fires a request back to the server, where the real function runs. This is the one function-shaped value the wire carries, and it is the bridge to the next section, where you will hit functions that don’t cross. The full Server Action surface, meaning how you declare them, validate input, and wire them to forms, is a whole later chapter; here it is just that the action reference crosses.

Here are all four in one Server Component, read in steps. InvoicePanel is the 'use client' shell, InvoiceHeader is a Server Component, and markPaidAction comes from a 'use server' actions file.

export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) {
const { id } = await params;
return (
<InvoicePanel
invoice={getInvoice(id)}
header={<InvoiceHeader id={id} />}
onMarkPaid={markPaidAction}
/>
);
}

A Promise prop. getInvoice(id) is not awaited here, so the still-pending Promise crosses, and the client resolves it with React.use() (next chapter). The server does not wait, so the shell renders now.

export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) {
const { id } = await params;
return (
<InvoicePanel
invoice={getInvoice(id)}
header={<InvoiceHeader id={id} />}
onMarkPaid={markPaidAction}
/>
);
}

A JSX prop. InvoiceHeader renders on the server, its output crosses as serialized JSX, and InvoicePanel drops it in without importing it. This is “wrap, don’t import” from the wire’s side.

export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) {
const { id } = await params;
return (
<InvoicePanel
invoice={getInvoice(id)}
header={<InvoiceHeader id={id} />}
onMarkPaid={markPaidAction}
/>
);
}

A Server Action reference. The function body never crosses, only an opaque ID. The client calls it, and the work runs back on the server. This is the single function-shaped value the wire carries.

1 / 1

That completes the model. Structured clone carries the data, and the four React extensions carry the trees, the deferred work, and the references. Everything that legitimately crosses the wire is on one of those two lists.

What gets rejected, and the two ways to pass a function

Section titled “What gets rejected, and the two ways to pass a function”

Now the other side of the contract: the values the wire turns away. The list is the mirror image of “plain data with no behaviour”:

  • Functions and closures. A function defined on the server closes over server-side memory and cannot meaningfully run in the browser. The one exception is the Server Action reference from the last section, which crosses precisely because it isn’t shipping the body.
  • Class instances, and any object with a custom or null prototype. The class definition lives on the server, and the wire has no constructor on the other side to rebuild the instance against. A {} has a known prototype both sides agree on; a new Invoice() does not.
  • DOM nodes, which belong to one document and cannot be serialized.
  • Symbols not registered with Symbol.for, which have no shared identity, as you saw.
  • WeakMap and WeakSet, whose contents you cannot even enumerate by design.

Errors are a partial case worth one sentence. An Error’s message survives the trip, but the full object, stack and all, does not. How errors actually flow across the boundary is its own topic later in the course; for now, just know it is not a clean round-trip.

You don’t need to memorize this list. You will read it off the error message instead, because React tells you exactly what went wrong and points at the offending prop. Two messages cover almost everything you will hit.

When you pass a plain function, the message is along these lines:

Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it.

When you pass a class instance, it’s this, near-verbatim:

Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.

One detail catches people out: the wording shifts with the direction of travel. The message above is the Server Component to Client Component prop form, the one you are learning here. When the same kind of value goes the other way, as an argument to a Server Action, the text says “…passed to Server Actions…” instead. It is the same rule with two phrasings, so don’t let the second one confuse you; read what it names.

Back to the page from the introduction. You wanted to pass a click handler down to a button, and the wire said no. The fix is a decision with two branches, and the order matters, because most people reach for the heavier branch first.

The wrong version is the one you started with: a function defined in the Server Component, handed down as onClick. It cannot cross, so the build fails. The fix is to replace it, not to patch it.

The first right move, and usually the correct one, is to put the handler in the Client Component. If the behaviour is pure browser interaction, such as toggling something open, marking a row in local state, or showing a confirmation, then nothing needs to cross at all. The handler belongs inside the 'use client' leaf, written there with useState and a local function, where it was always going to run anyway. This is the answer far more often than people expect, and reaching past it for a Server Action when no server work is happening is the most common over-correction. Try this branch first.

The second right move is to make it a Server Action. If the function genuinely has to run on the server, because it is a mutation, touches the database, or reads a secret, then it is not a click handler, it is a Server Action. You define it with 'use server' and pass its reference down. The opaque ID crosses, the body stays on the server, and the client invokes it through a generated request. The full mechanics are a later chapter; for now, recognize that “this work must happen on the server” is the signal that pushes you to this branch instead of the first.

Here is the same intent in all three forms.

app/invoices/[id]/page.tsx
export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) {
const invoice = await getInvoice((await params).id);
const handleClick = () => console.log('paid', invoice.id);
return <MarkPaidButton onClick={handleClick} />;
}

Build error. handleClick is a plain function, so it cannot cross the wire, and React names it in the serialization error. This is the starting point, not a fix.

The class-instance rejection has a twin in your data, and it is the one that surprises people. A Drizzle invoice row is a plain object, so it crosses fine. A new Date(...) is a built-in, so it crosses fine too. But the moment you reach for a date library, such as a dayjs() value or a Temporal.PlainDate, you are holding a class instance, and it will not cross. The durable habit here, the one experienced engineers reach for without thinking, is to shape data into plain values at the edge where it leaves the server. Turn that Temporal value into an ISO string or an epoch number before it becomes a prop, and keep the rich object on the server where it was created. The idea is to pass the data the UI needs and rebuild meaning on the far side, rather than passing behaviour across the wire. The full story on handling dates and times across the boundary is a later chapter; for now, the rule is that Date crosses, library date objects don’t, and you encode them as strings.

Here is a quick check before the last section. Read the scenario and pick the diagnosis plus the fix.

A Server Component renders <InvoiceRow format={formatCurrency} />. formatCurrency is a helper defined right there in the Server Component, and InvoiceRow is a 'use client' leaf that calls it only to format a label for display. Which option correctly states what happens and lands on the lighter of the two valid fixes?

The build refuses to compile, because a plain function isn’t serializable. No server work is involved here, so define formatCurrency inside InvoiceRow and drop the prop entirely.
The build refuses to compile, so the fix is to tag formatCurrency with 'use server' and pass the action reference down instead.
It compiles and runs fine — a function is allowed across as long as it isn’t async and doesn’t close over server-only data.
It compiles, but the first time InvoiceRow calls format in the browser it throws, because the function body never made the trip.

Slow down for this section. Everything above was about values the wire rejects, and it rejects them loudly, at build time, with a message that names the prop. This section is about a value the wire accepts, silently, that you did not mean to send.

Recall the fact planted back in the first section: the RSC payload is an ordinary network response, and you can open it in DevTools and read it. Now connect that to props. Every prop you put on a Client Component is sitting in that response, in plain text, readable by anyone who opens the Network tab. The framework guards the type contract, so it will stop you passing a function. It does not guard the contents, and it has no opinion on whether the object you passed is wider than the UI needs.

So the wire treats the two failures very differently. It rejects a function loudly, and it accepts an over-wide object silently. One is a build error you cannot miss. The other is a security incident with no symptoms, and it is the reason this lesson exists rather than just the function error.

To make it concrete, the MarkPaidButton needs an invoice ID, one string, so it can tell the server which invoice to mark. But the page already has the whole row in hand, so the path of least resistance is to pass the whole row.

app/invoices/[id]/page.tsx
export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) {
const invoice = await getInvoice((await params).id); // full row: id, status, internalNotes, customer…
return <MarkPaidButton invoice={invoice} />;
}

The whole row is now in the browser. Every field, including internalNotes, the joined customer record, and anything else on that row, is serialized into the RSC response and readable in DevTools. The button reads exactly one of them, and no error warns you.

To see why this matters more than “it’s a bit wasteful,” look at what the leak actually looks like on the wire. The payload below is the kind of thing you would find in the Network tab, and the field that should never have left the server is sitting right there in it.

GET /invoices/inv_8b21?_rsc=1a2f9 200 OK
Headers Preview Response
// renders <MarkPaidButton> — props serialized below
3:["$","$L7",null,{
"id": "inv_8b21",
"status": "sent", "total": 48200,
"internalNotes": "customer disputes charge — do not auto-remind",
"customer": { "name": "Acme Ltd", "email": "ap@acme.io" },
"createdAt": "2026-05-31T09:14:00Z"
}]
Shipped to the browser. Readable by the user. Never read by the button.
The leaked row, as it arrives in the browser. The button only ever reads `id` — but `internalNotes` made the trip too, in plain text, where anyone with DevTools open can read it.

That single careless prop generalizes into a rule with no exceptions: never put a secret in props. Database connection strings, API tokens, password hashes, a user’s full record, none of it goes on a Client Component, ever, because anything reachable from a Client Component is reachable from the browser. This is the same boundary you met in the first lesson of the chapter, where secrets live on the server and the only environment variables that reach the browser are the ones you deliberately prefixed with NEXT_PUBLIC_. Passing a secret as a prop breaks that exact rule by the back door, since the wire ships it to the browser just the same, prefix or no prefix.

The fix is the same move every time, and it is the durable senior habit of this whole lesson: pass only the slice you need. Pass { id }, or { id, status }, the exact fields the UI reads, never the whole row. There are two payoffs, and both are the kind a reviewer cares about. The first is security: nothing private can leak if nothing private crosses. The second is performance: a narrower prop is a smaller payload, and a smaller payload reaches the browser faster. “Narrow the boundary,” the principle from earlier in the chapter, is also what keeps these secrets on the server.

One more sort will make the slicing instinct automatic. For each value, decide whether it is safe to pass as a prop or should stay on the server.

Match each value to the verdict on passing it as a prop to the MarkPaidButton client leaf. Click an item on the left, then its match on the right. Press Check when done.

invoice.id
Safe — one non-sensitive field the button actually reads.
{ id, status } — a hand-picked slice
Safe — exactly the fields the component renders, nothing wider.
The full customer record
Keep on the server — a wide object full of personal fields the UI never touches.
process.env.STRIPE_SECRET_KEY
Keep on the server — a secret in a prop is a secret in the browser’s Network tab.
A Drizzle row with an internalNotes column
Keep on the server — slice it down to the public fields before it becomes a prop.

The wire’s contract, in one line: structured clone plus four React extensions cross, functions and class instances are rejected, and secrets and over-wide objects leak silently. That is the model. Three concrete capabilities come out of it:

  • Sort any value into crosses or doesn’t-cross by asking the structured-clone question: is this plain data the other side can rebuild, or behaviour and identity that only exist on the server?
  • Read the two boundary errors and pick the fix without guessing. A function rejected as a prop means you either move the handler into the client leaf (usually) or make it a Server Action (when the work belongs on the server); a class instance rejected means you shape it into a plain object at the boundary.
  • Catch a leak before it ships by slicing the object down to the exact fields the UI reads, for both security and payload size.

You have now seen that the server sends the browser two things over one request: the HTML and the payload. They travel together, and they have to agree. The next lesson covers what happens when the browser takes that HTML, re-runs your Client Components on top of it, and the two versions do not line up. That is the moment React calls hydration, and you will see the bug it produces when they diverge.