"Functions Cannot Be Passed to Client Components": Surviving the RSC Boundary
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
If your team is adopting the App Router, this error is a rite of passage. So are its cousins: class instances that arrive as empty objects, Date props that used to be strings, and the moment someone slaps "use client" on a layout file and your bundle gains 200KB.
The boundary is a network, even when it isn't
The mental shift: props crossing from a Server Component into a Client Component are serialized — turned into data, embedded in the RSC payload, shipped to the browser, and revived there. It doesn't matter that both components live in one repo and might render in one process. Architecturally, that prop crossing is a network hop.
Serializable: primitives, plain objects and arrays, Date, Map, Set, FormData, typed arrays, promises of serializable things, and JSX. Not serializable: functions, class instances, symbols. A function is code plus captured scope; there's no honest way to mail that to a browser.
The three shapes of the bug
Shape 1: The callback prop
Pages-era muscle memory — fetch in a server context, pass onSelect down:
// app/reports/page.tsx (Server Component)
export default async function ReportsPage() {
const reports = await getReports();
return (
<ReportTable
reports={reports}
onRowSelect={(id) => trackSelection(id)} // ❌ function across the boundary
/>
);
}
The fix is to move the owner of the interaction, not to convert the page. The table's interactivity lives in a client component; the server passes data and, when the "callback" is really a mutation, a Server Action:
// ReportTable.tsx
"use client";
export function ReportTable({
reports,
archiveReport, // ✅ Server Actions are the one legal "function prop"
}: {
reports: Report[];
archiveReport: (id: string) => Promise<void>;
}) { /* ... */ }
// actions.ts
"use server";
export async function archiveReport(id: string) {
await db.reports.archive(id);
revalidatePath("/reports");
}
Server Actions get away with it because they don't serialize the function — they serialize a reference to it, and invocation becomes an RPC back to the server.
Shape 2: The class instance that arrives hollow
The design system's date-range picker took a DateRange class instance with methods like .overlaps(). Across the boundary, it arrived as { start: ..., end: ... } — data survives, prototype doesn't. Nothing crashes until someone calls range.overlaps() and gets TypeError: range.overlaps is not a function — at runtime, possibly in production, because TypeScript has no idea the boundary exists.
The durable fix is a rule: DTOs cross the boundary; behavior lives on each side. Plain types in, pure functions where needed:
type DateRangeDTO = { start: string; end: string };
export function rangesOverlap(a: DateRangeDTO, b: DateRangeDTO): boolean { /* ... */ }
Shape 3: "use client" creep
The seductive fix for both shapes above is marking the parent "use client". But the directive doesn't mark a file — it marks a boundary, and everything that file imports becomes client-bundled too, transitively. Mark a layout and you've effectively opted your subtree out of Server Components.
The counter-pattern is composition: a client component can't import server components, but it can render them via children:
// ThemePanel.tsx — client shell, knows nothing about data
"use client";
export function ThemePanel({ children }: { children: React.ReactNode }) {
const [expanded, setExpanded] = useState(false);
return (
<section data-expanded={expanded}>
<button onClick={() => setExpanded((e) => !e)}>Toggle</button>
{children} {/* ✅ server-rendered content passes through */}
</section>
);
}
// page.tsx — server, composes them
export default async function Page() {
const usage = await getComponentUsageStats(); // stays on the server
return (
<ThemePanel>
<UsageReport data={usage} />
</ThemePanel>
);
}
The server content is rendered on the server and threaded through the client shell as an already-materialized slot. This one pattern resolved most of our 47 CI failures.
The rules we ended up painting on the wall
By the end of the migration these five rules were literally in the PR template:
- Props crossing to
"use client"must be data, not behavior. DTOs only. - Mutations cross as Server Actions, never as callbacks.
- Interactivity goes in leaf components; push the boundary down, not up.
- Client shells receive server content via
children, not imports. - Every new
"use client"directive names its reason in the PR description.
Six months post-migration, the design system serves both worlds from one codebase, and the error count for the boundary is roughly zero per week. The boundary stopped hurting exactly when the team stopped fighting it and started designing for it.
Keep Reading
05 Jul 2026
Stale Closures: Why Your WebSocket Handler Sees State From the Past
05 Jul 2026
Next.js Served Yesterday's Prices: Untangling the Four Caching Layers
09 Jul 2026
Maximum Update Depth Exceeded: A Field Guide to Infinite Render Loops
08 Jul 2026
The Search Box That Showed the Wrong Results: Effect Race Conditions