React Server Components and the Serialization Boundary: Debugging the Errors Nobody Warned Us About
Server Components look magical until you hit a serialization wall. Here's how the boundary actually works, why 'functions cannot be passed to client components' keeps appearing, and how to design around it.

The first time a junior on our team shipped a Server Component that passed a Prisma client instance into a Client Component, the build didn't fail. The dev server didn't complain until they clicked a button. Then the console lit up with Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". That error, and its five or six cousins, is the single biggest source of confusion we see teams hit when they move from the Pages Router to the App Router.
The root cause is almost always the same: people don't have a working mental model of the RSC serialization boundary. This post is our attempt to fix that, with the exact patterns we use in production Next.js 15 and React 19 codebases.
What the Serialization Boundary Actually Is
When a Server Component renders, React produces a payload that describes the UI tree. Part of that tree is server-rendered HTML, but the interesting part is the RSC payload: a serialized instruction set that tells the client how to hydrate Client Components and what props to pass them.
That payload has to survive a JSON-ish serialization step. Not literal JSON.stringify — React's format supports more, including promises, Date, Map, Set, BigInt, typed arrays, and references to Client Components themselves. But it does not support:
- Functions (unless they're Server Actions marked with
"use server") - Class instances with methods (a Prisma client, a Date subclass with custom methods, a Zod schema)
- Symbols that aren't well-known
- Anything with a live reference to a closure (event handlers, callbacks)
The boundary exists in exactly one place: the props passed from a Server Component to a Client Component. Server-to-server passing is just JavaScript. Client-to-client passing is just JavaScript. But the moment a value crosses from a server file into a "use client" file's props, it has to be serializable.
Why the Error Messages Are Confusing
The errors are runtime, not compile-time, because Next.js can't always know statically whether a value is serializable. A Date looks fine. A Date with a .customFormat() prototype method attached upstream in a helper does not. TypeScript won't save you here — the types compile, the runtime blows up.
The Four Errors You Will Actually See
Here's the shortlist we keep in our onboarding doc, with what each one really means.
1. "Functions cannot be passed directly to Client Components"
Somebody passed a callback prop from a Server Component to a Client Component:
// app/page.tsx (Server Component)
import { ProductCard } from './product-card';
export default async function Page() {
const products = await getProducts();
return products.map(p => (
<ProductCard
product={p}
onAdd={() => addToCart(p.id)} // 💥
/>
));
}
Fix: either mark addToCart as a Server Action and pass a reference, or move the click handler into the Client Component and pass just the id.
// app/actions.ts
'use server';
export async function addToCart(id: string) { /* ... */ }
// app/page.tsx
import { addToCart } from './actions';
import { ProductCard } from './product-card';
export default async function Page() {
const products = await getProducts();
return products.map(p => (
<ProductCard product={p} addToCart={addToCart} />
));
}
2. "Only plain objects can be passed to Client Components"
This one bites hardest when you pass ORM results directly. A Prisma model instance is a plain object. A Mongoose document is not — it has getters, setters, and a prototype chain. Same for a Sequelize row, or anything that wraps data in a class.
// ❌ Mongoose document has methods
const user = await User.findById(id);
return <Profile user={user} />;
// ✅ Strip it to a plain object
const user = (await User.findById(id))?.toObject();
return <Profile user={user} />;
Our rule: any data crossing the boundary goes through an explicit DTO. Not because it's clever, but because the DTO is a natural place to drop fields the client shouldn't see anyway (password hashes, internal flags, etc.).
3. "Objects with toJSON methods are not supported"
Decimal.js, some Date libraries, and a few date-fns adjacent utilities put a toJSON on the prototype. React refuses to silently call it. You have to convert first.
4. "Classes or null prototypes are not supported"
Usually a Set or Map that came from a library wrapper, or a Object.create(null) dictionary from a parser. Spread it into a fresh object or convert it explicitly.
The Pattern That Prevents 90% of This
We enforce a boring but effective convention in our Next.js repos: every data source has a toDTO function, and Server Components only pass DTOs across the boundary.
// lib/dto/product.ts
import type { Product as DbProduct } from '@prisma/client';
export type ProductDTO = {
id: string;
name: string;
priceCents: number;
inStock: boolean;
};
export function toProductDTO(p: DbProduct): ProductDTO {
return {
id: p.id,
name: p.name,
priceCents: p.priceCents,
inStock: p.stockCount > 0,
};
}
The DTO is the contract. It's typed, it's a plain object, and it's obvious what the client sees. When somebody adds a new field, they add it here — which forces a conversation about whether the client should see it.
Server Actions Are the Only Exception
Server Actions look like functions on the client side, but they're serialized as opaque references — the client only gets an ID that maps back to the server implementation. That's why you can pass them across the boundary and call them like functions in event handlers.
The gotcha: closures inside Server Actions capture serialized values, not live references. If you do this:
export default async function Page({ params }: { params: { id: string } }) {
const record = await db.find(params.id);
async function update(formData: FormData) {
'use server';
await db.update(record.id, formData.get('name')); // record is serialized
}
return <EditForm action={update} />;
}
That record reference is baked into the encrypted action payload sent to the client. Two problems: it's bigger than you think, and it can leak fields you didn't mean to expose. The safer pattern is to capture only primitives and refetch inside the action.
Debugging in Practice
When an error fires, the stack trace is usually unhelpful — it points at React internals, not at your prop. Two techniques we use:
- Bisect the props. Comment out props one at a time until the error goes away. The offender is almost never what you assumed.
- Run everything through
structuredClonein dev. IfstructuredClone(myProp)throws, RSC serialization will too (with some caveats around promises and Client Component refs). Wrap your DTO factories in a dev-only assertion:
export function assertSerializable<T>(v: T, label: string): T {
if (process.env.NODE_ENV !== 'production') {
try {
structuredClone(v);
} catch (e) {
throw new Error(`${label} is not serializable: ${(e as Error).message}`);
}
}
return v;
}
Drop that at the edge of any data fetching helper and you'll catch problems at the source instead of five components deep.
The Cost People Don't Talk About
Everything that crosses the boundary is serialized into the RSC payload, which is shipped to the browser. A 400-row table with 30 fields each is a real number of kilobytes over the wire. We've seen App Router pages accidentally ship 200KB of RSC payload because someone passed a full order history to a Client Component that only needed the last five entries.
Our heuristic: if a Client Component only renders a slice, filter and shape on the server. The DTO layer is again the right place — have toOrderSummaryDTO for lists and toOrderDetailDTO for the detail page. Yes, it's more code. It also keeps your Largest Contentful Paint honest.
Where We'd Start
If you're inheriting an App Router codebase that keeps hitting these errors, do three things this week:
- Add a
lib/dto/directory and move any prop that crosses a"use client"boundary through a typed DTO factory. - Add the
assertSerializablehelper and wire it into your data-fetching layer in dev. - Audit your Server Actions for captured closures — replace them with primitive params and refetches inside the action body.
None of this is glamorous, but it turns a class of runtime mysteries into boring, predictable code. If you want a second set of eyes on an App Router migration, our team does this kind of review as part of our web development engagements.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Streaming Suspense Boundaries: Why Your LCP Got Worse After the Refactor
We wrapped everything in Suspense expecting faster pages. Instead LCP got worse. Here's what streaming actually does to Core Web Vitals, and how to place boundaries so the numbers move the right way.

The React 19 useActionState Migration That Broke Our Forms
We swapped useFormState for useActionState across a mid-sized Next.js app. Here's what silently broke, what the docs don't tell you, and the patterns we ended up standardising on.

Partial Prerendering in Production: What Actually Ships and What Breaks
Partial Prerendering promised the best of static and dynamic. After shipping it on a few real apps, here's what actually works, what silently degrades, and where the sharp edges hide.
