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.
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#
// 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.
// app/ClickButton.tsx
'use client';
export function ClickButton() {
function handleClick() {
console.log('clicked');
}
return <button onClick={handleClick}>Click me</button>;
}// 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#
// ❌ 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:
// 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>;
}// 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:
// app/actions.ts
'use server';
export async function likePost(postId: string) {
await db.posts.update({ where: { id: postId }, data: { likes: { increment: 1 } } });
}// app/page.tsx — Server Component
import { likePost } from './actions';
import { LikeButton } from './LikeButton';
export default function Page({ postId }: { postId: string }) {
return <LikeButton onLike={likePost} />;
}// 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#
grep -rn "onClick=\|onChange=\|onSubmit=" appand check every file lacking"use client"at the top — those are the candidates.- 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. - 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.
// 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>}
</>
);
}// 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.
Related fixes & guides
- Fix: Next.js middleware not triggering
- Fix: NextRouter was not mounted
- cookies() Returns a Promise in Next.js 15/16: Fix
- Next.js Webhook Handling and Event-Driven Architecture
- Next.js 16 Removed next lint: ESLint 9 Migration
- Next.js 15 Middleware: Auth, Rate Limiting & Edge Guide
- Super expression must either be null or a function: Next.js
- Fix: useRouter query undefined on first render
- use client vs use server in Next.js: The Real Difference
- Fix 'Hydration failed' in Next.js: 8 Root Causes (2026)

