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 fail loudly or leak silently.
You have a server-rendered invoice page. It reads the invoice on the server, renders the details, and ends with a MarkPaidButton, a Client Component because it has to respond to a click. The page defines the handler, the button needs it, so you pass it down:
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: Functions cannot be passed directly to Client Components unless you explicitly expose them. Earlier lessons in this chapter showed you where the boundary is: 'use client' marks the entry into the browser-side subgraph, and server-only blocks anything that must never ship there. What you don’t yet know is the contract on the boundary itself: which values are allowed to cross it.
Two facts carry most of the weight. First, the error you just hit: a function prop fails instantly, and once you can read the message, the fix is two short moves. Second, the quieter and more dangerous one: an over-wide object, say a full invoice row carrying a customer’s private notes, crosses with no error at all and lands in plain text in the browser for anyone to read. The wire rejects a function loudly but accepts a leak without a word.
One model governs both, and you have met it before. In the first chapter you learned structuredClone, the algorithm that deep-copies an object and drops functions and DOM nodes along the way. That algorithm is very nearly the rule for what crosses this boundary, plus a short list of React-specific additions.
What the RSC payload is
Section titled “What the RSC payload is”The serialization rules ahead feel arbitrary until you can see what they describe, so start with the thing itself: the RSC payload.
When the server renders your Server Component tree, it produces two things. The first is HTML, which the browser paints immediately, so the user sees the invoice before any JavaScript runs. The second is the RSC payload: a serialized stream the React runtime in the browser replays to rebuild the component tree. It is not HTML; it is a recording of what the server rendered, in a format React can read back.
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. Turning a prop into something that can travel down a network connection and be rebuilt faithfully on the other side is what this lesson is about.
One fact matters for a later section: the payload is an ordinary network response. It arrives over HTTP next to the HTML, and like any network response, you can open it in your browser’s DevTools and read it.
So when this lesson talks about “what crosses the wire,” picture lane two: that payload, with your props baked in. The rules below govern what is allowed into it.
The structured-clone baseline
Section titled “The structured-clone baseline”The set of values that cross the wire is structuredClone plus a handful of React extensions.
You met structured clone in chapter 1 as a way to deep-copy an object: it carries the data but drops functions and DOM nodes, which cannot be recreated. The same algorithm runs the RSC wire, because the job is the same. Take a value, turn it into something that survives a trip across a boundary, and rebuild it on the far side. There the boundary was a copy in memory; here it is a network connection.
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, andsymbol(with one catch, below). - Built-in collections of serializable values:
Array,Map,Set,TypedArray,ArrayBuffer. Date, a first-class built-in, crosses untouched.- Plain objects: object-literal shapes,
{ ... }, whose properties are themselves serializable.
Map and Set cross directly. Older guidance to convert a Map to an array first is stale; current React serializes both natively. Still, if the other side only iterates the values and never touches the Map or Set API, prefer a plain array or object: one fewer moving part, and the plainest shape that does the job.
The word “plain” carries weight. A plain object is an object-literal shape, the kind you write with {}. A row you read from your database with Drizzle is plain, so it crosses without complaint. But an object with a custom prototype, meaning an instance of a class, falls out of the structured-clone set entirely. That distinction is the whole of the next section.
The symbol catch. A symbol crosses only if it was registered with Symbol.for('x'). A bare Symbol('x') does not, because each call to Symbol() mints a new, unique symbol with no global identity for the other side to look up. Symbol.for('x') 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.
Legal props are nothing exotic, just the values above flowing into a Client Component:
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 values that cross with a few that do not. You have not formally met some of them, but the structured-clone instinct gets you most of the way: 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.
'paid' — a stringnew Date()new Map([['x', 1]])new Uint8Array([1, 2, 3]){ id, total } — a plain object literal42n — a bigintSymbol.for('invoice')() => markPaid(id) — a functionnew Invoice() — a class instanceSymbol('invoice')document.body — a DOM nodeWhat React adds beyond structured clone
Section titled “What React adds beyond structured clone”Beyond plain data, React adds four things to the wire: rendered trees, deferred work, and references to code that lives elsewhere.
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, and render the page shell now while the value resolves and streams in later. The client reads the Promise with React.use() and Suspense, the subject of the next chapter.
JSX crosses. A Server Component’s already-rendered output can be passed to a Client Component as a prop or as children, and the Client Component renders it without importing it. This is “wrap, don’t import” from the wire’s side: the server-rendered tree travels inside the payload as serialized JSX.
References to components cross, not the components themselves. When the payload needs a Client Component, it does not ship the component’s code, because that code was already bundled for the browser separately. The payload carries only a reference, effectively “mount component number 42 here.”
Server Action references cross. A 'use server' function crosses as an opaque ID, never as a function body. The client receives a handle it can call, and calling it fires a request to the server, where the real function runs. This is the one function-shaped value the wire carries. How you declare, validate, and wire up actions is a later chapter.
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.
Structured clone carries the data; these four extensions carry the trees, the deferred work, and the references. Everything that legitimately crosses 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”The other side of the contract is the values the wire turns away, the mirror image of “plain data with no behaviour”:
- Functions and closures. A server function closes over server-side memory and cannot 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 wire has no constructor on the other side to rebuild the instance. A
{}has a prototype both sides agree on; anew 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. WeakMapandWeakSet, whose contents you cannot enumerate by design.
An Error is a partial case: its message survives the trip, but the full object, stack and all, does not. How errors flow across the boundary is its own topic later; for now, know it is not a clean round-trip.
You don’t need to memorize this list. React tells you exactly what went wrong and names the offending prop, and two messages cover almost everything you will hit.
Pass a plain function and 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.
Pass a class instance and 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 trips people up: the wording shifts with the direction of travel. Both messages above are the Server Component to Client Component prop form you are learning here. When the same value goes the other way, as an argument to a Server Action, the text reads “…passed to Server Actions…” instead. It is the same rule with two phrasings, so read what the message names.
Move the handler, or make it a Server Action
Section titled “Move the handler, or make it a Server Action”Back to the page from the introduction: you passed a click handler down to a button, and the wire said no. The fix is a two-branch decision, and the order matters, because most people reach for the heavier branch first.
The version you started with, a function defined in the Server Component and handed down as onClick, cannot cross, so the build fails. Replace it, don’t patch it.
Move the handler into the Client Component first; this is usually the answer. 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. The handler belongs inside the 'use client' leaf, written there with useState and a local function, where it was always going to run. Reaching for a Server Action when no server work is happening is the most common over-correction.
Make it a Server Action only when the work must run on the server. If the function is a mutation, touches the database, or reads a secret, it is not a click handler, it is a Server Action: you define it with 'use server' and pass its reference down, so the opaque ID crosses while the body stays on the server. The full mechanics are a later chapter; for now, “this work must happen on the server” is the signal that picks this branch over the first.
Here is the same intent in all three forms.
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.
'use client';
export const MarkPaidButton = ({ id }: { id: string }) => { const handleClick = () => console.log('paid', id);
return <button onClick={handleClick}>Mark paid</button>;};The common answer. For pure browser interaction, define the handler inside the client leaf. The handler never crosses the wire; only id, a string, does. Reach for this first.
export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) { const invoice = await getInvoice((await params).id);
return <MarkPaidButton id={invoice.id} onMarkPaid={markPaidAction} />;}For server-side work. markPaidAction is a 'use server' function: its reference crosses, and the body runs on the server. Use this only when the work truly belongs there (the full pattern is a later chapter).
The class-instance rejection has a twin in your data that surprises people. A Drizzle invoice row is a plain object and crosses fine, and a new Date(...) is a built-in and crosses too. But a date library value, such as a dayjs() result or a Temporal.PlainDate, is a class instance and will not cross. Shape data into plain values at the edge where it leaves the server: turn that Temporal value into an ISO string or epoch number before it becomes a prop, and keep the rich object on the server where it was created. The rule is that Date crosses, library date objects don’t, and you encode them as strings; full date handling across the boundary is a later chapter.
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?
formatCurrency inside InvoiceRow and drop the prop entirely.formatCurrency with 'use server' and pass the action reference down instead.async and doesn’t close over server-only data.InvoiceRow calls format in the browser it throws, because the function body never made the trip.'use server' would ship the formatting to the server and back for no reason — the classic over-correction.Client Component props are public: pass only the slice you need
Section titled “Client Component props are public: pass only the slice you need”Everything above was a value the wire rejects, loudly, at build time, with a message that names the prop. This section is a value the wire accepts, silently, that you never meant to send.
The RSC payload is an ordinary network response you can open in DevTools and read. So every prop on a Client Component sits in that response, in plain text, readable by anyone who opens the Network tab. The framework guards the type contract, so it stops 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.
The wire treats the two failures differently. A function is rejected loudly: a build error you cannot miss. An over-wide object is accepted silently: a security incident with no symptoms.
Take MarkPaidButton. It needs one string, the invoice ID, to tell the server which invoice to mark. But the page already holds the whole row, so the path of least resistance is to pass the whole row.
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.
export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) { const invoice = await getInvoice((await params).id);
return <MarkPaidButton id={invoice.id} />;}Only the id crosses. One string, the single field the button needs. Everything else stays on the server, out of the payload entirely. Narrow the prop to the fields the UI actually reads.
This is more than wasteful. Here is what the leak looks like on the wire, the kind of response you would find in the Network tab, with the field that should never have left the server sitting right in it.
That one careless prop generalizes into a rule with no exceptions: never put a secret in props. Connection strings, API tokens, password hashes, a user’s full record, none of it goes on a Client Component, because anything reachable from a Client Component is reachable from the browser. This is the boundary from the first lesson of the chapter, where secrets stay on the server and only variables you deliberately prefix with NEXT_PUBLIC_ reach the browser. A secret in a prop breaks that rule by the back door: the wire ships it either way, prefix or no prefix.
The fix is always the same move: pass only the slice you need. Pass { id }, or { id, status }, the exact fields the UI reads, never the whole row. Two payoffs, both the kind a reviewer cares about. Security: nothing private leaks if nothing private crosses. Performance: a narrower prop is a smaller payload, and a smaller payload reaches the browser faster.
One more sort makes 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{ id, status } — a hand-picked slicecustomer recordprocess.env.STRIPE_SECRET_KEYinternalNotes columnTwo transports that must agree
Section titled “Two transports that must agree”The server sends the browser two transports over one request: the HTML and the RSC payload. They travel together and have to agree. The next lesson covers what happens when the browser re-runs your Client Components over the server’s HTML and the two versions diverge: the moment React calls hydration, and the bug it produces when they don’t line up.
External resources
Section titled “External resources”React's official reference, with the exact list of prop types that cross the boundary and the ones that are rejected.
Next.js docs on what the RSC payload carries, passing data via props, and keeping secrets off the client.
MDN's reference for the algorithm the RSC wire is built on — the supported types, and why functions and DOM nodes are dropped.