Fix: Event handlers cannot be passed to Client props

An onClick from a Server Component can't be passed as a prop — functions aren't serializable across the RSC boundary. Where "use client" goes.

Mahdi Ben RhoumaMahdi Ben Rhouma · Updated

Event handlers cannot be passed to Client Component props. <button onClick={...} /> If click is a function, make sure it's decorated with "use client" or only imported from a Client Component. is a compile-time boundary check, not a runtime bug: React Server Components render on the server and stream **serialized** output to the client, and a JavaScript function is not serializable — there is no wire format for a closure. The fix is always about which file gets ["use client"](https://react.dev/reference/rsc/use-client), never about the onClick` syntax itself.

Why this only broke with the App Router#

In the Pages Router, every component ships to the browser as JavaScript, so a function prop is just a normal reference. The App Router renders Server Components on the server by default and sends the client only a description of the resulting HTML plus references to Client Component chunks. A function defined in a Server Component has no client-side identity to reference — the build catches this and fails instead of shipping a component that silently does nothing on click.

Shape 1: the handler is defined in the Server Component itself#

tsx
// app/page.tsx — a Server Component (no "use client")
export default function Page() {
  function handleClick() {
    console.log('clicked');
  }
 
  // ❌ handleClick is a server-only function, cannot cross the boundary
  return <button onClick={handleClick}>Click me</button>;
}

The fix is to push the interactive piece into its own Client Component and render that from the Server Component — not to sprinkle "use client" at the top of page.tsx, which would turn the whole page (data fetching included) into a Client Component.

tsx
// app/ClickButton.tsx
'use client';
 
export function ClickButton() {
  function handleClick() {
    console.log('clicked');
  }
  return <button onClick={handleClick}>Click me</button>;
}
tsx
// app/page.tsx — stays a Server Component
import { ClickButton } from './ClickButton';
 
export default function Page() {
  return <ClickButton />;
}

Shape 2: passing a Server Component's handler down as a prop#

tsx
// ❌ Server Component tries to hand a function to a Client child
import { Counter } from './Counter'; // 'use client'
 
export default function Page() {
  function onIncrement() {
    console.log('incremented');
  }
  return <Counter onIncrement={onIncrement} />;
}

Even though Counter is a Client Component, onIncrement is still defined in a Server Component — the function has to be created inside the client boundary. If the counter's logic genuinely needs server data, fetch that data in the Server Component and pass it as a plain serializable value (a number, string, or object), then let the Client Component own its own event handler:

tsx
// app/Counter.tsx
'use client';
import { useState } from 'react';
 
export function Counter({ initial }: { initial: number }) {
  const [count, setCount] = useState(initial);
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
tsx
// app/page.tsx
import { Counter } from './Counter';
 
export default async function Page() {
  const initial = await getInitialCount(); // server-only data fetch
  return <Counter initial={initial} />; // a number, not a function
}

Shape 3: it needs to call a Server Action#

This is the case that looks like it should need a function prop but does not. Server Actions are the one exception the framework serializes specially — a function marked "use server" compiles down to a reference the client can invoke over the network, so it is passed exactly like a normal prop:

tsx
// app/actions.ts
'use server';
 
export async function likePost(postId: string) {
  await db.posts.update({ where: { id: postId }, data: { likes: { increment: 1 } } });
}
tsx
// app/page.tsx — Server Component
import { likePost } from './actions';
import { LikeButton } from './LikeButton';
 
export default function Page({ postId }: { postId: string }) {
  return <LikeButton onLike={likePost} />;
}
tsx
// app/LikeButton.tsx
'use client';
 
export function LikeButton({ onLike }: { onLike: (id: string) => Promise<void> }) {
  return <button onClick={() => onLike('123')}>Like</button>;
}

If the build still throws on this pattern, the action file is missing the top-level "use server" directive, or the function was defined inline inside a Server Component instead of imported from a dedicated "use server" module.

Verifying the fix#

  1. grep -rn "onClick=\|onChange=\|onSubmit=" app and check every file lacking "use client" at the top — those are the candidates.
  2. Run next build; this error is caught at build time, so a clean build is a real signal here, unlike hydration bugs that only show up at runtime.
  3. Confirm the interactive island is as small as possible — a whole page marked "use client" to fix one button loses server rendering for everything else on it.

The pattern that looks like the bug but isn't: children as a slot#

A common overreaction to this error is converting an entire layout to a Client Component because it wraps some interactive piece — a modal, a dropdown, a tab panel — around server-rendered content. That's unnecessary: a Client Component can accept JSX through its children prop, and JSX is serializable (it's a tree of elements and plain data, not a function), so a Server Component can render deep, data-fetched content and still hand it to a Client Component wrapper without tripping this error at all.

tsx
// app/InteractiveModal.tsx
'use client';
import { useState } from 'react';
 
export function InteractiveModal({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Open</button>
      {open && <div className="modal">{children}</div>}
    </>
  );
}
tsx
// app/page.tsx — a Server Component, stays server-rendered
import { InteractiveModal } from './InteractiveModal';
import { PostBody } from './PostBody'; // fetches from a database, server-only
 
export default async function Page({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return (
    <InteractiveModal>
      <PostBody post={post} />
    </InteractiveModal>
  );
}

PostBody never crosses the client boundary as a component reference the way a function would — its rendered output is what gets passed down, so getPost() and any other server-only data fetching inside it keeps running exclusively on the server. This is the same "interactivity at the leaves" idea the three error shapes above solve for event handlers, applied to composition instead: the fix for both problems is to keep the Client boundary as small as possible and let Server Components stay in charge of layout and data. For the broader architectural reasoning behind minimizing client boundaries, see Next.js Server Actions vs API Routes: When to Use Each, which covers the same server/client split for mutations instead of composition.

Further reading: Fix "cookies() should be awaited" Error in Next.js 15, Fix: react-dom/server Import Error in Next.js App Router.

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Related fixes & guides