skip to content

In a Next.js App Router app, a client component <FilterBar> renders another client component <Select onChange={handleChange} /> and passes it a function prop. Why does that not produce the "only plain objects can be passed to Client Components" error that the same prop would cause when passed from a page.tsx server component?

level: juniorimportance: should knowfreq 45%

answer

  1. one crossing, not every prop
  2. both components already in the browser
  3. closure is a pointer, not bytes
  4. serialization happens where the payload is written
  5. server-to-server props are equally unrestricted

basics

~20 s

Serialization happens only where a value crosses from the server render into the RSC payload. FilterBar and Select both run in the browser, so the function is handed over as an in-memory reference and is never written to the wire.

solid answer

~50 s

The restriction is not a rule about props in general — it applies at exactly one place: the moment a Server Component puts a value in the props of an element whose type is a Client Component. At that point Next has to write the element into the RSC payload that travels to the browser, and a closure cannot be encoded into that payload, so the render throws. `FilterBar` and `Select` are both inside the client module graph, so their parent-child render happens entirely in the browser: React just builds a props object in memory and calls the child. Nothing is encoded, so functions, class instances, symbols, whatever you like, are all fine. The same is true in the other direction — one Server Component may pass a function to another Server Component, because both run in the same server render and nothing crosses.

code

tsx · 16 lines
tsx
'use client'
import { useState } from 'react'

function Select({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  return (
    <select value={value} onChange={(e) => onChange(e.target.value)}>
      <option value="all">All</option>
      <option value="open">Open</option>
    </select>
  )
}

export function FilterBar() {
  const [value, setValue] = useState('all')
  return <Select value={value} onChange={setValue} />
}

go deeper

for a junior

Be ready to say plainly that the restriction applies only when a server component renders a client component, and that props between two client components are ordinary JavaScript.

for a middle

Explain the mechanism: the crossing is the point where Next writes an element into the RSC payload, and a closure has no representation there. Show that you can locate the crossing by walking up the import chain.

for a senior

Show the judgment call — when an engineer silences the error by hoisting 'use client', you should be able to say what that costs in shipped JavaScript and server-only data access, and argue for pushing data down instead of behaviour.

for a principal

Own the convention: make it explicit in review that the boundary carries data and identifiers, never behaviour or live handles, so the fix for a serialization error is never "move the directive up" by default.

## The boundary is not between two components The mental picture that causes this confusion is "props between components get serialized." They don't. In the App Router, every module ends up in one of two graphs: the server graph, and the client graph rooted at each `'use client'` entry point. Serialization happens at exactly one place — where the server render emits an element whose type lives in the client graph. That element cannot be executed on the server, so Next writes it into the RSC payload as *a reference to the client module plus a serialized copy of its props*, and the browser instantiates it later. Everything else is an ordinary in-memory function call. - Server Component renders a Server Component: same render, same process, nothing encoded. A function prop is legal here. - Client Component renders a Client Component: same browser runtime, nothing encoded. A function prop is legal here. - Server Component renders a Client Component: this is the crossing. Props are serialized. A function prop throws. ## Why a function cannot survive the crossing The payload is a data format. A closure is a pointer into a live heap: it captures variables, an environment, possibly a database handle. There is no representation of that in bytes that the browser could reconstitute — the browser has no access to the server's heap. So the encoder refuses, and the error names the offending prop rather than failing silently, because a silently dropped callback would be far worse to debug. The same reasoning explains the other rejected values. A class instance is a plain object *plus* a prototype full of methods; only the data half could be encoded, so passing one through would quietly change its behaviour. Plain objects, arrays, primitives and a handful of built-ins have a faithful representation, so they pass. ## What this looks like in a real file ```tsx // components/filter-bar.tsx 'use client' import { useState } from 'react' import { Select } from './select' export function FilterBar() { const [value, setValue] = useState('all') // legal: both modules are in the client graph return <Select value={value} onChange={setValue} /> } ``` `select.tsx` does not need its own `'use client'` line — being imported from a client entry point is what puts it in the client graph. That is why the directive is described as an entry point rather than a per-file switch. Contrast it with the crossing: ```tsx // app/page.tsx (no directive => server graph) import { Select } from '@/components/select' export default function Page() { // throws: onChange is a function and this is a server->client crossing return <Select value="all" onChange={(v) => console.log(v)} /> } ``` ## Locating the crossing when the error appears The error tells you the prop, not always the intuition. Two habits fix most cases: 1. Walk up from the failing component until you hit the first module with `'use client'` at the top of the import chain. Everything above it is server; the crossing is the edge you just walked over. 2. Ask what the value *is*, not what it is called. `formatDate` is obviously a function. `logger`, `db`, `client`, an ORM row, a `class Money` value object and anything with methods are the same problem wearing different names. ## The design consequence Because behaviour cannot cross, the crossing has to carry **data**, and the client side supplies the behaviour. In practice that means passing an id, a status string, or a plain shape, and letting the client component decide what to do with it — the interactive parent that owns the callback lives in the client graph, where callbacks are free. This is also why moving `'use client'` "up one level" makes a serialization error disappear: you have not fixed the value, you have moved the crossing above it, so the whole subtree is now client code. That silences the error and ships more JavaScript, which is a trade you should make deliberately rather than by reflex.

  • Can one Server Component pass a function as a prop to another Server Component?
    Yes. Both render in the same server pass, so the prop is an ordinary in-memory reference and nothing is encoded. A layout can hand a formatter or a data-loading helper to a nested Server Component quite legally. The restriction only appears when the receiving element's module lives in the client graph, because that is the only point where props have to be written into the payload.
  • A colleague fixes the error by adding 'use client' to the page. Why is that usually the wrong fix?
    It does not make the value serializable — it moves the crossing above the value, so the page and everything it imports become client code. The error disappears because nothing crosses any more, at the cost of shipping the whole subtree to the browser and losing server-only data access in it. Prefer keeping the boundary low and passing data instead of behaviour.
  • How can you tell, looking at a file, whether its props will be serialized?
    You cannot tell from the file alone — it depends on the import chain. A component is client code if any module above it in the chain starts with `'use client'`. Trace upward: the first such module is the entry point, and the edge into it is the only place props get encoded. Everything below it renders in the browser.

saying these in an interview costs you the question

  • Thinks every prop in a Next.js app gets serialized
  • Believes each client file needs its own 'use client' line
  • Says the error is a TypeScript typing problem
  • Assumes functions can never be props anywhere in the App Router
  • Claims adding 'use client' to the page is the proper fix

context