Skip to content
advanced Phase 3 · Server and Client Components

Props Serialization Constraints

Understand which values can cross from server to client.

45m
0 problems
Topic Progress 0%

The Type Table That Governs Every Server → Client Prop

The advice you will hear is "props must be JSON." It is half right and fully misleading. Server → Client props are serialized by React's Flight protocol, which carries more than JSON — and rejects things JSON would happily mangle. This is the enforcement layer of the boundary you drew in Crossing the Client Boundary: props are the only channel between the two environments, and the protocol decides what fits through it.

Here is the table to memorize; it is what React itself documents as serializable:

Value Crosses? What the client receives
string, number, boolean, null, undefined, bigint Yes Same value — bigint included, which plain JSON cannot represent
symbol Only Symbol.for('x') globals The same global symbol; local Symbol('x') throws
Array, Map, Set, TypedArray, ArrayBuffer Yes Real Map/Set instances, not plain objects
Date Yes A real Date, not an ISO string
Plain objects ({ a: 1 }) Yes Plain object
JSX elements (server or client components) Yes Rendered output / component reference
Promises Yes A promise the client reads with use()
Server Functions ('use server') Yes An endpoint reference, callable via RPC
Arbitrary functions / event handlers Throws —
Class instances, null-prototype objects Throws —

Each "No" has a specific message, and quoting them in an interview signals you have actually hit them:

Event handlers cannot be passed to Client Component props.
If you need interactivity, consider converting part of this to a Client Component.
Functions cannot be passed directly to Client Components unless you
explicitly expose it by marking it with "use server".
Only plain objects, and a few built-ins, can be passed to Client Components
from Server Components. Classes or null prototypes are not supported.
Only global symbols received from Symbol.for(...) can be passed to Client
Components. The symbol Symbol.for("myTag") cannot be found among global symbols.

That last one hides a real production trap: null-prototype objects. Some ORMs hand back rows whose prototype is null (you can see it in console.inspect output as [Object: null prototype]), and React rejects them even though they are "plain" by every other definition. Dev builds additionally warn from the server console: Only plain objects can be passed to Client Components from Server Components. X objects are not supported. The fix is one line at the fetch boundary, before the data ever approaches a prop:

const rows = rawRows.map((r) => ({ ...r }));  // normal Object.prototype

Two asymmetries with JSON complete the picture. Flight preserves undefined props where JSON.stringify silently deletes them, and it revives Date as Date where JSON coerces it to a string. So if you see Reason: object ("[object Date]") cannot be serialized as JSON. Please only return JSON serializable data types. you are not looking at an RSC prop at all — that is the older Pages Router / getServerSideProps JSON check, a different boundary with different rules. Knowing which encoder ran tells you which rulebook applies.

Finally, a Date that crosses intact can still embarrass you: the server and the browser may sit in different time zones, so formatting the prop with toLocaleString() during SSR and again after hydration can produce different strings — React 19 reports it as a hydration mismatch. Compute locale-dependent formatting after mount, or format to a fixed zone on the server.

Functions Across the Boundary: Two Errors, Two Fixes, One Escape Hatch

The single most common App Router failure is a callback written in server code and handed to something that crosses the boundary. Two signatures, one cause.

Signature 1 — an on* prop. A server page.tsx renders <button onClick={...}>, or passes onClick into a client <Button>:

Error: Event handlers cannot be passed to Client Component props.
  <button onClick={function} children=...>
         ^^^^^^^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.

Signature 2 — any other function prop. Passing formatter={formatPrice} from a server component yields Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". (and if you accidentally return a component reference instead of JSX, React adds Functions are not valid as a child of Client Components.)

Why the wall exists: a function is a live closure over this process. It captures database handles, request-scoped values, maybe secrets. There is nothing to encode — a reference like formatPrice means nothing in the browser, and shipping the closure would ship your backend. Serialization is your security boundary as much as a technical one.

Fix 1 — move the behavior down. The client defines its own handler and receives only data:

// server page: passes data, not behavior
<DeleteButton itemId={item.id} />

// DeleteButton.tsx
'use client';
import { useTransition } from 'react';
import { removeItem } from '../app/actions';

export function DeleteButton({ itemId }) {
  const [pending, start] = useTransition();
  return (
    <button disabled={pending} onClick={() => start(() => removeItem(itemId))}>
      {pending ? 'Deleting…' : 'Delete'}
    </button>
  );
}

Fix 2 — the escape hatch. A Server Function marked 'use server' is serializable: React replaces it with an endpoint ID and the client invokes it over the wire. So a server component may pass an action where it could never pass a callback:

// server component — legal, because removeFromCart is a Server Function
<CartClient onRemove={removeFromCart.bind(null, cartId)} />

bind matters: bound arguments are serialized eagerly, so the payload carries them once, up front. Note where this does not apply: <form action={serverAction}> rendered by a server component is the blessed path — that action prop accepts a Server Function precisely because what crosses is a reference, not code. The symmetry to hold onto — Server Function arguments and return values obey the same table as props, because they travel the same protocol. A Date argument survives; a class instance return value throws the moment it re-crosses.

Promises deserve their own note because they invert the intuition. An unresolved promise is on the allowed list: the server starts the work, sends a reference, and the client component suspends on it with React 19's use() while the value streams in:

'use client';
import { use } from 'react';
export function Reviews({ reviewsPromise }) {
  const reviews = use(reviewsPromise);   // suspends until the row arrives
  return <List items={reviews} />;
}

Three caveats keep this pattern honest: the resolved value must itself be serializable; the promise must have a stable identity across re-renders (create it in the server component and pass it down — a promise constructed inline in client render re-suspends on every render, showing the fallback forever); and rejection surfaces as an error, so wrap the consumer in an Error Boundary as well as <Suspense>. In interview terms: await on the server, use() on the client, serialize everything in between — and that flow continues with route segments in Dynamic Route Segments.

Two Exits, Two Encodings: Flight Props Versus JSON Route Handlers

Your data leaves the server through two different doors, and they do not agree on types. Props cross inside the streamed RSC payload (Flight). Route handlers and your own fetch calls cross as JSON. Predicting each type in both encodings is the difference between a design that works and data that quietly disappears.

Type As a client component prop (Flight) Through Response.json() / fetch JSON
Date Arrives as a real Date Becomes an ISO string — silent type change
Map / Set Arrives as Map / Set JSON.stringify(new Map()) → {} — silent total loss
BigInt Arrives as BigInt Throws TypeError: Do not know how to serialize a BigInt
undefined field Preserved Key deleted from the object
Function Loud render error Silently dropped
Class instance Loud error at the boundary Own enumerable props survive; prototype and methods vanish silently
Error Loud error (it is a class) Usually {} — message/stack are non-enumerable

Read the right column twice: JSON fails silently, Flight fails loudly. A prop mistake interrupts rendering with a message naming the offending value; a route-handler mistake ships {} to a client that calls .get() on it two screens later. When an interviewer asks which is more dangerous, that is the answer — loud boundaries are features.

Loud does not mean instant, though. Statically rendered routes evaluate their props during next build's prerender step, so the error lands in CI; dynamic routes throw on the first request instead. In development the error overlay and the server console both point at the prop's path inside its parent object, which is usually enough to find the offending field without a debugger.

The second cost of props is size: everything you pass is embedded in the document. Open view-source on any App Router page and you will see the payload streaming in as self.__next_f.push(...) chunks — your prop data, inlined, then parsed and re-inflated during hydration. Pass a 10,000-row array to a client table and you have added roughly a megabyte of serialized JSON to the HTML, plus ten thousand React elements for the browser to reconcile — and the router caches payloads, but the first delivery still costs those bytes on the critical path. The fix is architectural, not cosmetic:

Keep the dataset server-side.  Pass what is interactive, not what is displayed.
  1. Server-render the rows; pass only expanded-row ids and the visible window
  2. Paginate/filter on the server; let the island request pages via an action
  3. Aggregate before crossing — pass 30 chart points, not 30,000 events
  4. Measure: compare document size and hydration time before/after

A useful split of responsibilities: the server owns volume (queries, joins, filtering, formatting), the boundary owns only the state the island genuinely mutates — open: Set<string>, page: number, selectedId: string. Small state, small props, fast hydration.

The interview close for this topic is one sentence plus one example: props crossing to Client Components must be serializable because the protocol is a streamed data format, not a function channel — so functions, class instances, and local symbols throw with named errors, while Date, Map, Set, BigInt, and promises ride through intact; when something cannot cross, either plainify it at the fetch boundary or move the behavior behind a 'use server' function and send only data.

Quiz

1. Which set of values can all cross from a Server Component to a Client Component as props?

Question 1 options

2. A Server Component page renders <button onClick={() => bump()}> directly. Which error appears?

Question 2 options

3. A database row arrives as a null-prototype object and React rejects it as a prop. What is the correct fix?

Question 3 options

4. You send new Map([['a', 1]]) through two paths: as a prop to a client component, and as the body of a route-handler JSON response. What happens?

Question 4 options

Flashcards

Question

Which surprising types cross the server/client boundary intact under React Flight?

Answer

Date (as a real Date), Map and Set (as real instances), BigInt, undefined, TypedArrays/ArrayBuffer, promises (read client-side with use()), JSX elements, and Server Functions — all richer than JSON. Local symbols (Symbol('x')), class instances, null-prototype objects and arbitrary functions do not.

Question

What exact error do you get for an event handler defined in server code, and what are the two fixes?

Answer

'Event handlers cannot be passed to Client Component props. ... If you need interactivity, consider converting part of this to a Client Component.' Fixes: move the handler into a client component and pass data instead, or pass a Server Function marked 'use server', which crosses as a serializable endpoint reference.

Question

How do you pass behavior from a server component to a client component legally?

Answer

Only as a Server Function: define it in a 'use server' module (or file), pass it as a prop — action.bind(null, id) pre-serializes bound arguments. Its arguments and return values must themselves be serializable, exactly like any other cross-boundary prop.

Question

What happens when a server component passes an unresolved promise to a client component?

Answer

It crosses as a reference; the client component suspends with React 19's use(promise) until the server streams the resolved value. Requirements: the resolved value must be serializable, the promise identity must be stable across renders (create it server-side), and rejections need an Error Boundary around the Suspense boundary.

Question

Contrast sending a Map through a client prop versus through a JSON route handler.

Answer

As a prop, Flight revives a real Map on the client. Through a route handler, JSON.stringify(new Map()) returns '{}' — total, silent loss; BigInt instead throws 'TypeError: Do not know how to serialize a BigInt'. Flight fails loudly at render; JSON fails silently in transit.

Revision Notes

Key Takeaways

  • 1. Flight is richer than JSON: Date, Map, Set, BigInt, undefined and promises cross intact — but functions, class instances and null-prototype objects throw named errors
  • 2. Quote the error to the interviewer: 'Event handlers cannot be passed to Client Component props' means server-authored behavior tried to cross, not that the child lacks 'use client'
  • 3. The one legal function across the boundary is a Server Function ('use server'), serialized as an endpoint ID with its bound arguments and serializable results
  • 4. JSON boundaries fail silently (Map → {}, function dropped, Date → string) while Flight fails loudly at render — prefer loud boundaries and know which encoding ran
  • 5. Props are embedded in the streamed HTML: keep datasets server-side, cross only interactive state, and measure document size and hydration before and after

Interview Tips

  • • "Which props can't cross to a client component?" — answer with categories and two exact messages: functions (event-handler and 'use server' variants), class instances/null-prototype objects, local symbols; then name what *does* cross — Date, Map, Set, BigInt, promises — to show you read the protocol, not a blog post
  • • "Why can't you pass a closure?" — three reasons: nothing to encode (closures are process-local), it would leak server internals into the browser bundle, and the protocol is a data format with no execution channel; the 'use server' escape hatch exists precisely because behavior must run where the data lives
  • • "Your API returns Dates and Maps and the client component sees strings and {} — what happened?" — you went through JSON (route handler/fetch), not Flight; either pass through props, use a richer serializer on that route, or normalize types at the boundary and say which you chose and why
  • • "How do you keep a client island's payload small?" — server owns queries, joins and filtering; cross only mutable UI state (ids, page numbers, expanded sets); server-render rows and request windows via actions; then quote the before/after document size and hydration numbers

Cheat Sheet

Props Serialization Constraints

Crosses (Flight): primitives incl. bigint and undefined, Symbol.for globals, Array / Map / Set / TypedArray / ArrayBuffer, Date, plain objects, JSX elements, promises, Server Functions.

Throws:

Offender Error
onClick-style props Event handlers cannot be passed to Client Component props.
Any other function Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
Class / null-prototype object Only plain objects, and a few built-ins, can be passed ... Classes or null prototypes are not supported.
Local Symbol('x') Only global symbols received from Symbol.for(...) can be passed to Client Components.

Two encodings:

Type Client prop (Flight) Route handler (JSON)
Date real Date ISO string (silent)
Map/Set real instance {} (silent loss)
BigInt BigInt TypeError: Do not know how to serialize a BigInt
function loud error silently dropped

Fixes: plainify data at the fetch boundary ({ ...row }), or move behavior behind 'use server' (bind args for eager serialization), or send only interactive state — the server owns volume, the island owns open/page/selectedId.

Promises: unresolved promises cross; client reads with use() under <Suspense> + Error Boundary; keep identity stable or it re-suspends forever.

Payload cost: props are inlined in the document (self.__next_f.push(...)) — measure document size and hydration time after every big prop you add.

Continue Learning

Before You Start

Related Reading