Passing Props Across the Boundary
When a Server Component renders a Client Component, any props it passes have to cross a real boundary: the Server Component runs only on the server, but the Client Component needs those prop values available in the browser to hydrate. That crossing imposes a constraint that doesn't normally exist in plain React — props must be serializable.
What "Serializable" Means Here
Next.js serializes props passed from a Server Component to a Client Component so they can be sent over the network and reconstructed in the browser. This works fine for the data types you already use constantly — strings, numbers, booleans, plain objects, arrays, null, and undefined.
// ServerParent.tsx (Server Component)
import ClientChild from './ClientChild'
export default function ServerParent() {
function handleClick() {
console.log('this only exists on the server')
}
// This will error — functions aren't serializable props.
return <ClientChild onClick={handleClick} />
}Worked Example: Fetch on the Server, Pass Plain Data Down
The natural pattern is to do data fetching in the Server Component, shape the result into plain serializable data, and pass just that data down to a Client Component that handles interactivity.
// ProductList.tsx (Server Component)
import { AddToCartButton } from './AddToCartButton'
type Product = { id: string; name: string; price: number }
async function getProducts(): Promise<Product[]> {
const res = await fetch('https://api.example.com/products')
return res.json()
}
export default async function ProductList() {
const products = await getProducts()
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - ${product.price}
<AddToCartButton productId={product.id} />
</li>
))}
</ul>
)
}// AddToCartButton.tsx (Client Component)
'use client'
import { useState } from 'react'
export function AddToCartButton({ productId }: { productId: string }) {
const [added, setAdded] = useState(false)
return (
<button onClick={() => setAdded(true)} disabled={added}>
{added ? 'Added' : 'Add to cart'}
</button>
)
}productId is a plain string — trivially serializable — while all the fetching logic and the shape of the raw API response stay entirely on the server, never sent to the browser as code.
Serializable vs Non-Serializable at a Glance
Type | Passable as a Prop to a Client Component? |
|---|---|
string, number, boolean | Yes |
Plain objects and arrays (JSON-shaped) | Yes |
null, undefined | Yes |
Function / event handler | No |
Class instance | No |
Symbol | No |
Server Action (a specially marked async function) | Yes — explicit exception |
The Server Action Exception
// actions.ts
'use server'
export async function submitFeedback(formData: FormData) {
// runs on the server when invoked
}
// FeedbackForm.tsx (Client Component)
'use client'
import { submitFeedback } from './actions'
export function FeedbackForm() {
return (
<form action={submitFeedback}>
<textarea name="message" />
<button type="submit">Send</button>
</form>
)
}Practical Guidelines
Fetch and transform data in the Server Component; pass down only the plain data the Client Component actually needs.
Never pass a plain callback function as a prop from a Server Component — pass a Server Action instead if you need server-side behavior.
If a Date needs to cross the boundary, convert it to an ISO string first and reconstruct it in the client if needed.
Keep Client Components as small as possible so fewer props need to cross the boundary in the first place.
Respecting the serializable-props boundary keeps a clean separation between server-only logic and client-side interactivity, and is a big part of why App Router apps can ship so much less JavaScript than a fully client-rendered equivalent.