Props that travel over a wire
There is one more rule of the server/client split, and it bites real teams weekly. When a server component renders a client component, the props it passes do not stay inside one program. The server serializes them, meaning it writes them down as data, sends them to the browser as part of the page payload, and React rebuilds them there.
So props crossing the server → client boundary must be serializable: strings, numbers, booleans, null, and plain arrays and objects made of those.
What cannot be written down and rebuilt: functions, class instances, database handles. Pass a function from a server component to a client component and Next.js stops you with the error every Next.js developer eventually meets:
Error: Functions cannot be passed directly to Client Components
Without this rule the framework would have to ship arbitrary server code to the browser, which is exactly what server components exist to prevent.
The broken delete button
A server component tries to hand its client child a callback:
// app/posts/page.js (server) import DeleteButton from "./delete-button"; // "use client" export default async function Posts() { const posts = await getPosts(); return posts.map((post) => ( <DeleteButton key={post.id} onDelete={() => db.posts.remove(post.id)} // ✗ function prop, build error /> )); }
The fix follows from the rule: send data across the boundary and keep behavior on the side where it runs.
// server: pass the id (a string, so it serializes cleanly)
<DeleteButton key={post.id} postId={post.id} />Inside DeleteButton, the click handler is defined locally and calls the server through a proper channel, either a server action or an API route. Both are covered in Unit 6.
One deliberate exception exists. A server action may be passed as a prop, because Next.js sends a secure reference to it rather than the function's code.
Which props are legal across the boundary
A prop like post={{ title: "Hello", tags: ["react"] }} crosses without complaint. Props travel as serialized data, so plain objects of strings, numbers, booleans, arrays, and nested plain objects are all fine.
Functions, live database connections, and class instances are not. None of them can be written down as data and rebuilt in the browser, and shipping their code would leak server internals to every visitor. The single exception is a server action, which Next.js passes as a secure reference rather than as code.
| Prop value | Crosses the boundary |
|---|---|
"hello", 42, true, null | ✓ |
["react", "next"] | ✓ |
{ title: "Hi", tags: ["a"] } | ✓ |
() => save() | ✗ |
new Date() instance methods, class instances | ✗ |
| an open database client | ✗ |
| a server action | ✓ (as a reference) |
canCrossBoundary: the serializability check as code
The check Next.js effectively runs on your props can be written out in plain JavaScript. canCrossBoundary(value) returns true when a value could be serialized and rebuilt in the browser: strings, numbers, booleans, null, and arrays or plain objects whose contents all pass the same test. It returns false for functions, and for anything that contains one at any depth.
function canCrossBoundary(value) { if (typeof value === "function") return false; if (Array.isArray(value)) return value.every(canCrossBoundary); if (value !== null && typeof value === "object") { return Object.values(value).every(canCrossBoundary); } return true; } console.log(canCrossBoundary({ title: "Hi", tags: ["react", "next"] })); console.log(canCrossBoundary({ title: "Hi", onSave: () => {} })); console.log(canCrossBoundary([1, "two", { ok: true }]));
Output
true false true
Reading the implementation
- The function check comes first, because that is the hard no and no amount of recursion will rescue it.
- The recursion is what makes the check deep: arrays are validated with
value.every(canCrossBoundary), objects withObject.values(value).every(canCrossBoundary). A function buried three levels down still fails the whole prop. typeof null === "object"in JavaScript, which is a long-standing quirk, sonullhas to be excluded before anything is treated as an object.
Fixing a formatDate prop the right way
A server component passes formatDate={(d) => d.toLocaleDateString()} to a "use client" component and the build fails. There are two tempting repairs, and only one of them is correct.
The right move is to define the formatting function inside the client component, or in a shared utility file that the client component imports, and pass only the date data across the boundary as a string or a timestamp.
The wrong move is to serialize the function's source code as a string and evaluate it in the browser. That is precisely what the framework forbids. It would ship server code to the client and break the security model that server components exist to provide.
Why it works out that way
- The rule from this lesson: send data across the boundary, keep behavior on the side where it runs.
- Behavior that the browser needs should originate in browser code, so it lands in the client bundle honestly instead of being smuggled in as data.