Skip to content

Functions cannot be passed directly to Client Components

Functions cannot be passed directly to Client Components: three messages, one rule. Find the exact prop, then pick the fix: Server Action, element, or move it.

React reactjs nextjs react-server-components debugging
BKG
Reading Progress

On This Page

Somebody on your team adds an icon prop to the shared Button, the Storybook story looks great, and the first real page that uses it from a Server Component returns a 500. The log says this:

⨯ Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it.
  <... label="x" icon={function Icon}>
                      ^^^^^^^^^^^^^^^
    at stringify (<anonymous>)
    at stringify (<anonymous>) {
  digest: '2883839225'
}

That's Next 15.5.28 in dev. "Functions cannot be passed directly to Client Components" is one of three sentences React's server renderer can throw for the same underlying mistake. Which one you get depends on the prop's name and where it sits. All three mean the same thing: a plain function tried to cross the server-to-browser boundary, and the wire format has no way to write one down.

The three messages, as your terminal prints them

Here's every variant I produced, copied from the server log. Not the browser console. That matters, and I'll come back to why.

# Variant 1: the prop name looks like an event handler (matches on[A-Z])
⨯ Error: Event handlers cannot be passed to Client Component props.
  <... label="x" onSave={function f}>
                        ^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.

# Variant 2: any other function-valued prop
⨯ Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it.
  <... label="x" save={function save}>
                      ^^^^^^^^^^^^^^^

# Variant 3: the function is JSX children
⨯ Error: Functions are not valid as a child of Client Components. This may happen if you return Icon instead of <Icon /> from render. Or maybe you meant to call this function rather than return it.
  <... label="x" children={function Icon}>
                          ^^^^^^^^^^^^^^^

The caret line is the useful part. It names the component that received the prop (the <... is React abbreviating the element), the prop key, and the function's name. In a production build the same error prints in a different shape, and the function name is whatever the minifier left behind:

⨯ Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it.
  {label: "x", icon: function d}
                     ^^^^^^^^^^
    at ignore-listed frames {
  digest: '1651326557'
}

function d is my Icon after minification. Don't search your codebase for d. Search for the key, icon.

What the user's browser gets in production is a 500 page and a digest string. The message itself never leaves the server. That's why people paste the browser console into a search box and find nothing useful.

Environment for everything below: Next.js 16.4.3 with React 19.3.1, plus Next 15.5.28 with React 19.1.10 and Next 14.2.35 with React 18.3.1, run on 2026-10-27, App Router, Node 22.

One Button, seventeen routes

I wanted every shape of this bug in one project so I could compare them. One client component, many server pages that feed it something different.

mkdir r16 && cd r16
npm init -y >/dev/null
npm i next@16.4.3 react@19.3.1 react-dom@19.3.1
npm i -D typescript@5.9.3 @types/react@19.3.0 @types/node@22
mkdir -p app/{a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q}
// app/layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) {
  return <html><body>{children}</body></html>;
}
// app/Button.tsx
'use client'; // this is the line that makes the file a client boundary

export function Button(props: Record<string, any>) {
  const Icon = props.icon;
  return (
    <button onClick={() => props.save?.()}>
      {props.label}
      {typeof Icon === 'function' ? <Icon /> : null}
    </button>
  );
}
// app/b/page.tsx   <- the plain case: a closure from a Server Component
import { Button } from '../Button';

export const dynamic = 'force-dynamic'; // render per request so the error shows up at request time

export default function Page() {
  const save = () => {};
  return <Button label="x" save={save} />; // function crosses the boundary here
}

Build and request it:

npx next build           # passes. More on that in a moment
npx next start -p 3017 &
curl -s -o /dev/null -w '%{http_code}\n' localhost:3017/b
500

You reproduced it when the 500 comes back and the server log (not the curl output) shows the "Functions cannot be passed directly" line with save={function save} under the caret.

If you instead see Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported., you passed a class instance, and that's a sibling bug with a different fix. I got it from a new Money(5) prop. If you see Only global symbols received from Symbol.for(...), same family, different value.

The other routes, with the prop swapped each time:

RouteWhat the server page passesResult on 16.4.3
/aonSave={fn}500, "Event handlers cannot be passed…"
/bsave={fn}500, "Functions cannot be passed directly…"
/cicon={Icon} (a local component)500, "Functions cannot be passed directly…"
/h<Button>{Icon}</Button>500, "Functions are not valid as a child…"
/iitems={[{ render: () => 1 }]}500, same as /b, caret on render
/mfmt={Intl.NumberFormat('en').format}500, caret shows function with no name
/n<Link href="/" onClick={…}> from next/link500, "Event handlers cannot be passed…"
/o<Image loader={…}> from next/image500, "Functions cannot be passed directly…"
/gMap, Date, 10n, undefined200, all fine
/ean inline 'use server' async function200

Two rows deserve a second look. /n and /o have no 'use client' in your code at all. next/link and next/image are client components, so the boundary is inside the library, and it bites you anyway. And /g is the reminder that the serializer is generous about data. It's functions it refuses.

Do 14, 15 and 16 disagree?

Not on the sentences. I ran /a, /b, /c on all three majors, and /h on 15 and 16, in dev mode. Same three messages, same caret pointer in JSX form (<... label="x" save={function save}>). The differences are cosmetic.

Next / ReactWordingLog shape
14.2.35 / 18.3.1same three messagesprinted twice per request, at stringify (<anonymous>) frames, digest: "…" after the stack
15.5.28 / 19.1.10sameprinted once, stringify frames, digest inside { … }
16.4.3 / 19.3.1sameprinted once, stack frames collapsed to ignore-listed frames

In production builds, I only ran 16.4.3, and there the description switched from JSX form to the object form shown earlier. I'd expect 14 and 15 to match since the serializer code is the same family, but I didn't run it, so treat that as unverified.

React's own error decoder has this exact sentence as minified error #375 (react.dev/errors/375). In my Next runs the server-side message was never minified, even in a production build. You'll only see "#375" if you're on a different Flight integration that ships React's minified server bundle.

Is there a React 19 behaviour change that makes this go away? No. A plain function still can't cross. The docs list Server Functions as the only functions allowed in props.

What actually crossed the wire

Plain words: a Server Component runs on the server and produces a description of the UI, not HTML and not a JS bundle. That description goes down the network to the browser as a text stream, and the browser's copy of React rebuilds a tree from it. Everything in that stream has to be writable as text. A function is code plus a closed-over scope. There's no honest way to write that as text, and sending its source would drag the scope with it.

So React makes you choose one of three honest representations: don't send it (move it to the client side), send a pointer to something both sides already know about, or send a ticket that says "call this on the server." The error is React telling you that you picked none of them.

Here's the part I got wrong first. I saw "Event handlers cannot be passed" on onSave, renamed the prop to save to dodge the word "event", and React cheerfully gave me a different error. Same bug, new sentence. The rename felt like progress for about four seconds. The name only decides which message prints; it never decides whether the prop is legal.

How the serializer decides which sentence to throw

I read the server-side serializer in the development bundle that ships inside Next 16.4.3 (react-server-dom-webpack-server.node.development.js under next/dist/compiled). When it meets a function value and the function isn't a client reference, a server reference, or a temporary reference, it goes down a short list of checks, in this order:

  1. Does the prop name match ^on[A-Z]? Throw the "Event handlers" message.
  2. Is this value the children of a JSX element? Throw "Functions are not valid as a child of Client Components", with the function's displayName or name spliced into the sentence ("you return Icon instead of <Icon />").
  3. Otherwise, throw "Functions cannot be passed directly…", and describe the parent object so the caret lands on the right key.

That's all there is. The regex is why onSave and onClick get their own message and save and render don't. The children branch is why {Icon} tells you to call or render it.

Two other kinds of function pass cleanly, and they are the fixes:

  • A client reference: a function exported from a 'use client' module. When a Server Component imports it, what it actually holds is a stub. The serializer writes a module pointer instead of code.
  • A server reference: an async function marked 'use server'. The serializer writes an ID.

You can see the difference in the production payload. I built the project and pulled the page with curl. A client component passed as a prop, icon={StarIcon} from a 'use client' file, serializes as a string pointer into a module row:

a:I[70379,["$2","$8"],"StarIcon"]
...
"label":"x","icon":"$a"

A Server Action imported from a 'use server' file serializes as a different kind of pointer:

"label":"x","onSave":"$ha"

Note $a versus $ha. A plain $a is a module reference; the h marks a server reference. No code in either one. And an element, icon={<StarIcon />}, is neither: it goes down as ordinary tree data, ["$","$La",null,{...}]. That's the escape hatch most people want and don't know they have.

Read the digest, then bisect the props

My first two attempts at fixing this, before I understood the list above, both went nowhere.

Wrong turn one: make it a Server Action. The error says "unless you explicitly expose it by marking it with 'use server'", so I put the directive inside the function, in the page:

export default function Page() {
  function save() { 'use server'; console.log('x'); }
  return <Button label="x" save={save} />;
}
⨯ ./app/p/page.tsx:4:12
Error: Server Actions must be async functions.
> 4 |   function save() { 'use server'; console.log('x'); }
    |            ^^^^

A compile error, not a runtime one. Server Actions have to be async. Adding async fixes that, but whether it should be a Server Action is a different question I'll get to.

Wrong turn two: put 'use client' on the page. It works. /q returned 200. But now the page is a client module. Whatever it was doing on the server (database call, secret, await) either breaks or moves to the browser. I dropped this for anything that wasn't already a trivial page.

Now the checklist, for your own app.

# 1. Read the SERVER log, not the browser console.
#    dev: the terminal running `next dev`. prod: stdout of `next start`, or your host's function logs.
#    Look for the caret line. It names the prop key, and in dev the receiving component.

# 2. Browser shows only a digest? Search server logs for it.
grep -r "1651326557" ./logs     # the digest value from the page's error id
#    One hit = you found the message. Zero hits = logs are sampled or on another instance.

# 3. Caret says `function` with no name, or a minified `d`?
#    Ignore the name. Grep your server file for the PROP KEY instead.
grep -rn "icon=" app/

# 4. Key is nested (`{render: function render}`)? Walk up: it's an array/object
#    prop. Comment out props on the client component one at a time until the 500 stops.

# 5. Confirm after the fix. The payload should hold a pointer, not code:
curl -s localhost:3017/b | grep -o '"save":"[^"]*"'
#    "$h..." = server reference. "$..." = module reference. Missing = you moved it client-side.

If step 1 shows the key on a component you didn't write (<Link onClick>, <Image loader>, a chart's tooltipFormatter), the fix is still yours to make: you're the one calling it from the server side.

500 on a Server Component page?
 ├─ log says "Event handlers..." or "Functions cannot be passed..." ─► find the prop key
 │     ├─ key is a callback that runs on click/submit ─► Option A or B below
 │     ├─ key is a component (icon, as, Icon)         ─► Option C
 │     └─ key is nested in an object/array            ─► Option D
 ├─ log says "...not valid as a child..."              ─► Option C
 └─ log says "Only plain objects..."                   ─► different bug: class instance

Which cause, how often

RankCauseHow often it's the culpritThe tellFix
1Click/change/submit callback written in a Server ComponentMost of what I seeKey starts with on, or is a verb like saveA or B
2Component passed as a prop (icon={Icon}, as={Comp})Common in design systemsKey is a noun, value is capitalizedC
3Third-party client component with a function prop (next/link, next/image, chart formatters)Common, easy to missYou never wrote 'use client' anywhere nearbyA, or wrap it (D)
4Function nested in a config object or array (table columns, items)ModerateCaret shows {render: function render}D
5Function as childrenLess common"not valid as a child" messageC
6Anonymous or bound function, e.g. Intl.NumberFormat().formatRareCaret shows bare function with no nameD

Number 6 is the one that cost me the most time, because the message gave me nothing to grep. Once you know the prop key is fmt, it's obvious.

Pick the fix by what the function does

The question to ask first: when this function runs, does it need the browser (click, focus, local state), or the server (database, secret, cookies)?

Option A, the function needs the browser: define it in the client component. This is the right answer most of the time. A click handler belongs next to the element it handles. The server page passes data (id, label) and the client component builds the handler.

 // app/b/page.tsx
-const save = () => { /* ... */ };
-return <Button label="x" save={save} />;
+return <Button label="x" id="draft-1" />;
 // app/Button.tsx
 'use client';
-export function Button(props) {
+export function Button({ id, label }: { id: string; label: string }) {
+  const save = () => { /* uses id, runs in the browser */ };
   return <button onClick={save}>{label}</button>;
 }

Option B, the function needs the server: make it a real Server Action. Put it in a module with 'use server' at the top, mark it async, and import it into the Server Component. This is what the error message is literally offering. I tested /l: onSave={save} imported from an actions.ts marked 'use server' returned 200, and the payload showed the $h… server reference.

The docs are blunt about the cost: the function becomes a public endpoint, the arguments are controlled by the client, and you have to check authorization inside it (react.dev: 'use server'). Don't mark a function 'use server' to silence this error unless you want an HTTP route that does what the function does.

Option C, the thing is a component: pass an element, not the function. icon={<StarIcon />} serializes as tree data and works whether StarIcon is a client or a server component. Passing the component itself works only if it's exported from a 'use client' file (/j, 200). A local function component (/c) fails. For children, render it: {<Icon />}, not {Icon}.

Option D, the function is nested or from a library: hoist or wrap. Move the config containing functions into the client file (a columns.tsx with 'use client'), or write a thin client wrapper around the library component:

// app/ui/FormattedNumber.tsx
'use client';

export function FormattedNumber({ value }: { value: number }) {
  return <span>{new Intl.NumberFormat('en').format(value)}</span>;
}

The server page passes value, a number, which always crosses.

I'd ship A for browser behaviour, B only for things that really need the server, and C for icons. Reaching for 'use client' on the whole page is the shortcut that quietly costs the most.

The silencers: 'use client' on the page file, which "fixes" it by moving the whole page's work into the browser bundle; and renaming the prop to end in Action to satisfy the editor (see below), which fixes nothing at runtime.

Design the props so a function never needs to cross

The pattern that removes this bug class: server pages pass data and elements, client components own behaviour. If a prop is a verb, it probably belongs on the other side. Here is a runnable version of the shape I'd actually ship, tested on Next 16.4.3 with React 19.3.1 (tsc --noEmit clean, route returned 200):

// app/ship/actions.ts
'use server';

export async function saveDraft(id: string): Promise<void> {
  // arguments come from the browser: validate them, and check the user here too
  if (!/^[a-z0-9-]{1,40}$/.test(id)) throw new Error('bad id');
  console.log('saved draft', id);
}
// app/ship/Star.tsx
'use client';
export function Star() { return <span aria-hidden>★</span>; }
// app/ship/SaveButton.tsx
'use client';

import { useTransition, type ReactNode } from 'react';

type Props = {
  label: string;
  icon?: ReactNode;                           // an element, not a component function
  saveAction: (id: string) => Promise<void>;  // only ever a Server Action reference
  id: string;
};

export function SaveButton({ label, icon, saveAction, id }: Props) {
  const [pending, start] = useTransition();
  return (
    <button disabled={pending} onClick={() => start(() => saveAction(id))}>
      {icon}
      {pending ? 'Saving…' : label}
    </button>
  );
}
// app/ship/page.tsx
import { SaveButton } from './SaveButton';
import { Star } from './Star';
import { saveDraft } from './actions';

export const dynamic = 'force-dynamic';

export default function Page() {
  return <SaveButton id="draft-1" label="Save" icon={<Star />} saveAction={saveDraft} />;
}

The Action suffix on saveAction isn't decoration. Next's TypeScript editor plugin uses it, as the next section explains.

Catching it before the deploy

Here's the unpleasant bit I confirmed: next build passed on my failing project. The routes were force-dynamic, so nothing rendered at build time and the error waited for the first request. I only tested dynamic routes. For a statically prerendered route I'd expect the build to fail, but I haven't run that, so check it on your project before you rely on it.

What does help:

  • A smoke test against the production build. After next build, start the server and request one URL per page template. Fail the job on any non-200:
npx next start -p 3017 & sleep 3
for r in / /dashboard /settings /posts/example; do
  code=$(curl -s -o /dev/null -w '%{http_code}' localhost:3017$r)
  [ "$code" = 200 ] || { echo "FAIL $r $code"; exit 1; }
done
  • The Next TypeScript plugin, with limits. Reading client-boundary.js in 16.4.3, it reports "Props must be serializable for components in the 'use client' entry file" when a function-typed prop isn't named action or doesn't end in Action. That check lives on the client file's side and is a name heuristic. It can't see what a Server Component passes at the call site, and a prop renamed onSaveAction satisfies it while the runtime check still sees a plain function. I read the rule's source; I didn't wire up an editor to watch it fire.
  • Types that pin the boundary. Type client-component props so callbacks are (…) => Promise<void> named *Action and visual slots are ReactNode. A plain () => void prop then looks wrong in review.
  • Playwright with a console/response guard. Fail the test on any 500 from the app origin.
  • Production signal. Alert on the 5xx rate for server-rendered routes right after a deploy, and log the digest. The browser only ever has the digest; the message lives in your server logs, so make sure those are retained and searchable.

This behaves the same on React 18.3.1 / Next 14 through React 19.3.1 / Next 16 in my runs. After a React or Next major, rerun the routes above. The sentences have been stable across these three; what changes is the log formatting.

Team convention worth writing down: a prop that's a verb is a Server Action or it lives in the client file.

What to pin to the monitor

  • The error names the prop key. Grep for the key. The function name may be minified or empty.
  • The prop name only chooses the message. on[A-Z] gets its own sentence; renaming never makes it legal.
  • Elements cross. Components-as-functions from a local file don't. Pass <Icon />.
  • 'use server' means "public endpoint." Use it because the work belongs on the server, not to quiet a red screen.

Searches that land here

How do I pass a function from a Server Component to a Client Component? You can't pass an ordinary function. Pass data and define the function in the client component, or pass a Server Action (an async function from a 'use server' module). Verified on React 18.3.1 through 19.3.1 with Next 14 to 16.

Why does Next.js say "Event handlers cannot be passed to Client Component props"? The prop name matches on followed by a capital letter, so React uses that wording for any function passed under such a name. Same root cause as the "Functions cannot be passed directly" message; the rename trick only swaps one for the other.

Can I add 'use client' to the page to make this error go away? Yes, and the 500 stopped in my test. The cost is that the whole page is now a client module, so server-only work in it has to move. For a real fix, see the options above, and the sibling boundary problem in the useState-in-a-server-module article.

Is it safe to mark every callback 'use server'? No. Each one becomes an endpoint the client can call with arguments it controls, so each needs input validation and an authorization check (react.dev).

If the same page also fails with a mismatch between server and browser output after you fix this, that's a different bug, covered in React hydration mismatch. If your client component then throws on a hook call, check Invalid hook call when your hooks are fine. And if a Next.js route fails right after you touch a dynamic segment, params must be awaited is the other thing that breaks Server Component pages in 15 and 16.

Reactreactjsnextjsreact-server-componentsdebugging

BKG

Engineer and writer behind CODELZ. I read the stack trace, reproduce the bug, and explain why it happens, not just how to silence it. Plus honest reviews of the books and tools worth your time.

Comments