NextjsPassing Props Across the Boundary

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.

Warning
Functions, class instances, Symbols, Map/Set instances, and Date objects (in most cases) cannot be passed as props from a Server Component to a Client Component. Passing a function as a prop this way will throw an error at build or runtime, since a function closure cannot be serialized and sent to the browser.

TSX
// 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.

TSX
// 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>
  )
}

TSX
// 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
Note
Server Actions are an explicit exception to the "no functions" rule. A function marked with 'use server' (or defined in a file with that directive at the top) is specially handled by Next.js — it gets serialized into a reference that the client can call, which triggers a request back to the server to actually run the function. This is how you can pass an onSubmit-style server action straight down into a Client Component form.

TSX
// 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.

Tip
If you hit a serialization error, it usually means a Client Component is a level too high in the tree. Try pushing the 'use client' boundary further down, closer to the actual interactive element, so less data (and fewer non-serializable values) has to cross it.

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.