React Server Components and the Prop Serialization Wall
Server Components feel magical until you try to pass a Date, a class instance, or a function through the boundary. Here's how to design around the serialization wall instead of fighting it.

Every team we've onboarded to the App Router hits the same wall around week two. Someone passes a Date from a Server Component to a Client Component, ships it, and a week later a Sentry alert fires because a toISOString call exploded on a string. The React Server Components boundary isn't a network boundary, but it behaves like one — and pretending otherwise is the source of most RSC bugs we see in code review.
This post is about the serialization wall: what it actually is, what crosses it cleanly, what doesn't, and the patterns we lean on so our teams stop fighting it.
The wall is real, and it's not HTTP
When a Server Component renders a Client Component and passes props, React serializes those props into a wire format (the RSC payload) and the client deserializes them before hydration. It's not JSON exactly — it's a richer format that can handle Promises, some built-ins, and references to other components — but it has hard rules.
What crosses cleanly:
- Primitives:
string,number,boolean,null,undefined,bigint - Plain objects and arrays of the above
Date,Map,Set,RegExp, typed arraysPromise(it streams)- Other Server Components passed as
childrenor props - References to Client Components themselves
- Server Action references (functions marked
"use server")
What does not cross:
- Arbitrary functions or closures
- Class instances with methods (the data survives, the prototype does not)
Symbol(except registered ones)- Things with circular references that aren't natively handled
- Anything carrying private fields, getters, or behavior
The trap is that the dev server will sometimes let you ship something borderline, and production with minification and streaming will surface the bug later. We've debugged this exact shape twice in the last year.
A concrete failure
// app/orders/page.tsx (Server Component)
import { OrderCard } from './order-card';
import { getOrder } from '@/lib/orders';
export default async function Page() {
const order = await getOrder('abc');
// order is an Order class instance with methods like .isRefundable()
return <OrderCard order={order} />;
}
// app/orders/order-card.tsx
'use client';
import type { Order } from '@/lib/orders';
export function OrderCard({ order }: { order: Order }) {
// Runtime error: order.isRefundable is not a function
return <button disabled={!order.isRefundable()}>Refund</button>;
}
The Order instance gets flattened to its enumerable data. Methods are gone. TypeScript happily compiles because the types match on both sides — but the runtime shape doesn't.
Pattern 1: Pass data, compute on the server
The simplest fix is also the most underused. If the Client Component needs a boolean, send a boolean. Don't send an object and ask the client to compute it.
export default async function Page() {
const order = await getOrder('abc');
return (
<OrderCard
id={order.id}
total={order.total}
refundable={order.isRefundable()}
/>
);
}
This flips the mental model. Client Components become dumb presentational shells; Server Components own the business logic. That's the architecture RSC was designed for, and it tends to make components more testable as a side effect.
Pattern 2: DTOs at the boundary
For anything beyond a couple of props, define an explicit Data Transfer Object. Keep your domain models rich on the server and project them down before they cross the wall.
// lib/orders/dto.ts
export type OrderDTO = {
id: string;
total: number;
currency: string;
status: 'pending' | 'paid' | 'refunded';
refundable: boolean;
placedAt: Date; // Date crosses cleanly
};
export function toOrderDTO(order: Order): OrderDTO {
return {
id: order.id,
total: order.total,
currency: order.currency,
status: order.status,
refundable: order.isRefundable(),
placedAt: order.placedAt,
};
}
The DTO type becomes the contract. Client Components import the DTO type, never the domain class. This also gives you a natural place to strip PII before it hits the client — something we keep finding in audits where teams pass a full user object and accidentally ship passwordHash: null to the browser.
Enforce it with a lint rule
We ship a custom ESLint rule on most projects that bans importing domain classes inside files marked 'use client'. It catches the regression six months later when a junior engineer reaches for the convenient type and reintroduces the problem.
Pattern 3: Server Actions instead of callbacks
"Can I pass an onClick from a Server Component to a Client Component?" comes up in almost every code review. Regular functions: no. Server Actions: yes, because they're a reference, not a closure.
// app/orders/page.tsx
import { refundOrder } from './actions';
import { RefundButton } from './refund-button';
export default async function Page() {
const order = await getOrder('abc');
return (
<RefundButton
orderId={order.id}
onRefund={refundOrder} // Server Action reference, this is fine
/>
);
}
// app/orders/actions.ts
'use server';
export async function refundOrder(orderId: string) {
// ...
}
The client receives a callable proxy that, when invoked, makes an RPC back to the server. The function body never ships. This is the only way to give a Client Component a function prop that actually works across the boundary.
A caveat: don't treat Server Actions as a free callback mechanism for everything. Each invocation is a network round trip with overhead. For pure UI state, keep the handler in the Client Component. For mutations, Server Actions are the right tool.
Pattern 4: Children over props for heavy trees
When a Client Component needs to render server-rendered content inside itself — a tab panel, a modal, a sidebar — pass it as children instead of trying to render it from props.
// Server Component
<ClientTabs>
<ServerHeavyChart data={data} />
</ClientTabs>
The ClientTabs component is interactive (handles tab switching) but the chart inside is a Server Component that ran on the server, its serialized output streamed into the client tree. The client never sees the chart's data or logic — just the rendered React node reference.
This is the escape hatch for the rule "Client Components can't import Server Components." They can't import them, but they can receive them as children or props.
Pattern 5: Stable serializable IDs, not object references
A subtle one: when you build maps and lookups on the server, don't try to share the Map itself with the client if the values contain anything non-serializable. Send the data and let the client rebuild what it needs, or send just the slice that's relevant.
// Bad: sending a huge Map across the wall
return <ClientList items={hugeMap} />;
// Better: send only what the UI needs
const visible = Array.from(hugeMap.values())
.slice(0, 50)
.map(toItemDTO);
return <ClientList items={visible} />;
The RSC payload is shipped to the browser. Every byte counts toward your Core Web Vitals — specifically the resource load time that feeds into LCP when the payload is large enough to delay hydration of above-the-fold content. We've trimmed payloads from 400KB to under 60KB on a single page just by introducing DTOs.
Pattern 6: Type the boundary explicitly
TypeScript can't see the serialization wall by default. A Date looks identical on both sides; a class instance does too, until runtime. We mark boundary types with a brand to make accidental misuse obvious in review:
export type Serializable<T> = T & { readonly __serializable: true };
export type ClientProps<T> = {
[K in keyof T]: T[K] extends Function
? never
: T[K] extends Date | string | number | boolean | null | undefined
? T[K]
: T[K] extends object
? ClientProps<T[K]>
: never;
};
It's not perfect — TypeScript's structural typing means a class instance still matches its data shape — but ClientProps<T> will at least scream when someone tries to pass a function or a class with methods. Combine it with the lint rule from Pattern 2 and you've covered most of the surface area.
Where we'd start
If you're auditing an existing App Router codebase, do this in order:
- Grep for
'use client'files that import domain classes or ORM model types. Each one is a potential bug. - Add a
dto.tsnext to each domain module and route all Client Components through it. - Audit Server Action usage — anything called more than twice per page interaction probably wants to be local client state instead.
- Measure your RSC payload size on the three highest-traffic routes. If any are above 100KB, DTOs will pay for themselves immediately.
The serialization wall isn't a limitation to route around. It's the contract that lets RSC work at all. Design for it, and the App Router stops feeling like a series of mysterious runtime errors and starts feeling like the architecture it actually is.
If you want a second set of eyes on an App Router migration, that's the kind of thing our web development team does most weeks.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Cache Tags in Next.js: How We Stopped Nuking the Entire CDN on Every Publish
A war story about how `revalidateTag` and a disciplined tagging scheme replaced our nightly full-cache purge — and the four gotchas we hit getting there in production.

Streaming SSR and Suspense Boundaries: Where to Draw the Line
Streaming SSR is free performance — until it isn't. Where you place Suspense boundaries decides whether your page feels fast or stutters its way through a waterfall of spinners.

Partial Prerendering in Production: What Breaks When You Turn It On
Partial Prerendering looked like free performance on the demo slide. Then we shipped it. Here's what actually breaks when you flip the flag on a real Next.js app — and how we'd roll it out now.
