C.W.K.
Stream
Lesson 06 of 08 · published

The Serialization Boundary

~22 min · serialization, props, boundary

Level 0Curious
0 XP0/68 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

What can cross the line

When a Server Component passes props to a Client Component, the data must serialize. The boundary is roughly "JSON-shaped, plus a few React extras."

Allowed ✅Blocked ❌
Strings, numbers, booleansFunctions
Arrays, plain objectsClass instances
Dates (serialized as strings)Symbols
null / undefinedDOM nodes / streams
FormDataCircular references
React elements (JSX) and Promises

The big consequence

You can't pass a function from server to client. If you want the client to call back into server logic, define a Server Action and pass that down (the framework wraps it in a serialized POST endpoint reference).

The composition trick

JSX elements serialize. That means a Server Component can pass other Server Components as children to a Client Component — the Client Component renders them but doesn't import them. This is the cleanest way to wrap a server-rendered subtree in client-side interactivity.

Code

Pass serializable props·tsx
// app/page.tsx — Server
import { LikeButton } from '@/components/LikeButton';

export default async function Page() {
  const post = await getPost();
  return (
    <article>
      <h1>{post.title}</h1>
      {/* ✅ string + number — serializable */}
      <LikeButton postId={post.id} initialCount={post.likes} />

      {/* ❌ Functions can't cross — won't compile */}
      {/* <LikeButton onLike={() => handleLike()} /> */}
    </article>
  );
}
Client receives plain props·tsx
'use client';
import { useState } from 'react';

export function LikeButton({
  postId,
  initialCount,
}: {
  postId: string;
  initialCount: number;
}) {
  const [count, setCount] = useState(initialCount);
  return (
    <button onClick={() => setCount(c => c + 1)}>♥ {count}</button>
  );
}
Pass server JSX as children·tsx
// app/page.tsx — Server
import { Accordion } from '@/components/Accordion';
import { ProductDetails } from '@/components/ProductDetails';

export default async function Page() {
  const product = await getProduct();
  return (
    <Accordion>
      {/* Server-rendered subtree handed to a Client wrapper */}
      <ProductDetails product={product} />
    </Accordion>
  );
}

// components/Accordion.tsx — Client
'use client';
import { useState } from 'react';

export function Accordion({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {open && children}
    </>
  );
}

External links

Exercise

Take a Client Component that currently receives a callback prop. Replace the callback with a Server Action passed in (covered later) or with the children pattern. Document why the rewrite improved the boundary.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.