</>CodeWithKarani

Why Next.js Never Warned You About That Non-Serializable Client Prop

Karani GeoffreyKarani Geoffrey9 min read

A page that has worked for two months breaks in production. The stack trace says something about only plain objects being passable to Client Components. You look at the component, and it takes a product prop straight out of the database. TypeScript is happy. tsc --noEmit passes. next build passed in CI. Your editor never said a word.

Then you rename export default function ProductCard to export function ProductCard and the squiggly line appears immediately.

There are two independent gaps here, stacked, and together they mean you have effectively no static protection against prop serialization bugs. The first is that the Next.js TypeScript plugin that produces this warning is a language service plugin: it runs in your editor and nowhere else. It does not run during next build, it does not run during tsc, and it will never fail CI. The second is that even in the editor, it only inspects named exports. export default function Component(...) slips straight through, which is a problem, because that is the form the official Next.js documentation uses in its own example of this exact mistake.

  • The TS71007 warning is editor-only. It never runs in next build, tsc or next lint, so it can never gate a deploy.
  • It also does not fire on export default components. This is open issue #55332, filed in September 2023, still unfixed.
  • Use named exports for any Client Component that receives props from a Server Component, so you at least get the editor warning.
  • For a real CI gate, constrain the props type with a Serializable helper type. That runs under tsc --noEmit and fails the build.
  • The most common real-world offender is not a function. It is a Prisma Decimal, a Mongoose document, or any other class instance handed straight from a query to a Client Component.

The reproduction

Put both of these in a 'use client' file, open it in an editor with the Next.js plugin enabled, and look at which one gets underlined:

'use client'

type Props = { label: string; onSelect: (id: string) => void }

// Warned about.
export function NamedButton({ label, onSelect }: Props) {
  return <button onClick={() => onSelect(label)}>{label}</button>
}

// Identical problem. Silent.
export default function DefaultButton({ label, onSelect }: Props) {
  return <button onClick={() => onSelect(label)}>{label}</button>
}

The named one produces:

TS71007: Props must be serializable for components in the "use client" entry file, "onSelect" is invalid.

The default one produces nothing. Same file, same props type, same violation.

What you get instead, at runtime

Since nothing caught it statically, here is what eventually surfaces. For a function 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.

And for the case that actually bites people in production, a class instance:

Error: Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.

That second one is the one to memorise, because the object involved usually looks entirely plain when you log it. A Prisma Decimal prints as a number. A Mongoose document prints as its fields. Both are class instances, and both will cross a component boundary in development without complaint until the exact route that renders them gets exercised in production.

Where a bad Client Component prop can be caught Editor (Next.js TS language service plugin) TS71007, but only for named exports. Cannot fail a build. tsc --noEmit Catches nothing. Serializability is not expressible in the prop type by default. next build / next lint Catches nothing. The plugin is not part of either. Runtime, on the request that renders it "Only plain objects ... can be passed to Client Components"
Three checks that feel like they cover this, and none of them do. The only gate that runs in CI is the one you have to build yourself.

Why this happens

The plugin is a language service, not a compiler pass

You enable it in tsconfig.json:

{
  "compilerOptions": {
    "plugins": [{ "name": "next" }]
  }
}

TypeScript language service plugins extend the editor experience. They hook into the service that powers hover, autocomplete and inline diagnostics. tsc deliberately ignores the plugins array, because the compiler is not the language service. So a diagnostic that only the plugin knows about is, by construction, invisible to any command-line type check. This is not a Next.js bug, it is how TypeScript works, but almost nobody realises the implication: TS71007 is advisory, not enforced.

The default export path is not covered

On top of that, the plugin's serializability check walks named export declarations in a 'use client' entry file. A default-exported function declaration is a different AST shape and does not go through the same check. Issue #55332 has been open since September 2023 with no committed fix, and there is a related open report covering the same territory.

The irony is worth pointing out, because it explains why so few people notice: the official use client documentation demonstrates the mistake with export default function Counter({ onClick }), which is precisely the shape the plugin cannot see.

Serializability is not a type-level property

Even a perfect plugin would only get you part of the way. The RSC boundary allows a specific set of values, and TypeScript's type system does not naturally distinguish them:

ValueCrosses the boundary?Why it catches people out
string, number, boolean, null, undefinedYes
DateYesPeople convert it to a string unnecessarily
Plain object, array, Map, SetYes
Prisma DecimalNoTypes as a number-like value, is a class instance
Mongoose documentNoLooks plain when logged, has a prototype
Class instance generallyNoStructural typing means it satisfies a plain-object type
FunctionOnly if it is a Server ActionSame TypeScript type either way

The Prisma Decimal row is the one that produces real incidents. Structural typing means an instance of a class with a toNumber() method satisfies any type that describes its fields, so TypeScript sees nothing wrong. Only React, at render time, notices the prototype.

The fix, step by step

Step 1: Use named exports for Client Components that take props

'use client'

// Named export: the editor plugin can see this one.
export function ProductCard({ product }: Props) { /* ... */ }

Then import it as import { ProductCard } from './product-card'. Pages and layouts still need default exports because the App Router requires them, but leaf components have no such constraint. Making this the house style costs nothing and restores the one warning you were relying on.

Step 2: Add a type-level gate that tsc actually enforces

This is the part that runs in CI. Define what may cross the boundary, then constrain your props type against it:

// lib/serializable.ts
export type Serializable =
  | string
  | number
  | boolean
  | null
  | undefined
  | bigint
  | Date
  | ReadonlyArray<Serializable>
  | { readonly [key: string]: Serializable }

// Fails to compile if T contains anything not in the union above.
export type AssertSerializable<T extends Serializable> = T
'use client'
import type { AssertSerializable } from '@/lib/serializable'

type Props = AssertSerializable<{
  id: string
  title: string
  publishedAt: Date
  price: number          // convert Decimal to number before it gets here
}>

export function ProductCard({ id, title, publishedAt, price }: Props) { /* ... */ }

Now npx tsc --noEmit fails if someone adds a function or a class-typed field to Props. It works on default exports too, because it has nothing to do with export shape.

Two honest caveats. It will not catch a class instance that structurally matches a plain object type, because nothing in TypeScript can. And Server Actions are legitimate function props, so keep them outside the checked object where a reviewer can see them:

type Props = AssertSerializable<{ id: string; title: string }> & {
  // Server Action. The one function that is allowed to cross.
  onSubmit: (formData: FormData) => Promise<void>
}

Step 3: Convert at the boundary, not in the component

Wherever a query result crosses into a Client Component, map it explicitly:

const rows = await prisma.product.findMany()

const products = rows.map((p) => ({
  id: p.id,
  title: p.title,
  price: p.price.toNumber(),      // Decimal -> number
  publishedAt: p.publishedAt,     // Date is fine as-is
}))

return <ProductCard products={products} />

Passing whole ORM rows into components is the underlying habit that causes this. An explicit mapping is four extra lines, makes the boundary visible in review, and stops a schema change from silently pushing a new class-typed column across it. If you are also fighting Prisma in production, treating query results as internal-only data that must be mapped before it leaves the server layer pays off twice.

Step 4: Enforce the export style if it matters to you

If your team keeps drifting back to default exports, eslint-plugin-import's import/no-default-export rule, scoped with an ESLint override to your components directory only, will hold the line. Exclude app/**, where default exports are mandatory.

Verification

# 1. The gate exists and works: temporarily add a function prop to a checked
#    Props type, then run
npx tsc --noEmit
# expect: an error on the AssertSerializable line, non-zero exit

# 2. Remove it again
npx tsc --noEmit
# expect: clean

# 3. The editor warning is back for named exports
#    Hover the props of a named-export client component with a bad prop:
#    expect TS71007 in the problems panel

The important assertion is the non-zero exit in step 1. If your check does not fail a command that CI runs, it is documentation, not enforcement.

What people get wrong

"TypeScript already validates my Client Component props." It validates their shape. It has no concept of serializability, and the one Next.js-specific check that does exist runs only in your editor and only on named exports. Believing otherwise is exactly how a bad prop reaches production, because you stop looking.

"next build would have caught it." It will catch a non-serializable prop only if the route is prerendered at build time and the offending code path actually executes. A route that renders dynamically, or a branch that only runs for logged-in users, will build cleanly and fail for a customer.

"Add 'use client' to the parent so the boundary goes away." This does make the error disappear, and it also pulls the parent, its data fetching and its dependencies into the client bundle. I have seen an entire route tree converted to client components to silence one prop error. You did not fix a serialization bug, you deleted your server rendering.

"Wrap the function in useCallback." This has nothing to do with the problem. The issue is that a function cannot be serialized from server to client at all, not that it changes identity. Either mark it 'use server' so it becomes a Server Action, or define the handler inside the Client Component where it belongs.

When it is still broken

  • Find the offending prop from the runtime error. Both React messages include the component and the prop name in the frames underneath. Read past the first line of the stack; the useful part is the JSX fragment React prints with a caret under the bad prop.
  • Suspect the ORM first. Prisma Decimal, Mongoose documents, MongoDB ObjectId, and anything from a library that wraps values in a class. JSON.parse(JSON.stringify(x)) makes the error go away and quietly destroys Date objects and precision, so map fields explicitly instead.
  • Check whether the plugin is enabled at all. Confirm "plugins": [{ "name": "next" }] is in tsconfig.json and that your editor is using the workspace TypeScript version rather than its bundled one. A surprising number of "the warning never appears" reports are just this.
  • Watch the two issues rather than working around them forever. Both remain open with no committed timeline, so treat named exports plus a type-level gate as the permanent answer, not a stopgap.

The wider point, and the reason I now put an explicit mapping at every server-to-client boundary: a boundary that the type system cannot see is a boundary you have to make visible by hand. Next.js gives you a warning that is genuinely useful and genuinely partial. Build the check that runs in CI, and treat the editor squiggle as a bonus.

Frequently asked questions

Why does the TS71007 serializable props warning not appear for my component?
Two reasons, and both may apply. The Next.js check only inspects named exports, so export default function Component is never flagged; that is open issue #55332, filed in September 2023. And the warning comes from a TypeScript language service plugin that runs only in your editor, so it never appears in tsc, next build or next lint regardless of export style.
Does next build catch non-serializable props passed to Client Components?
Only incidentally. If a route is prerendered at build time and the offending code path actually executes, React will throw during prerendering and the build fails. A dynamically rendered route, or a branch that only runs for certain users, builds cleanly and fails in production instead. Do not treat a green build as evidence that your Client Component props are valid.
Why do I get 'Only plain objects can be passed to Client Components' when my object looks plain?
It has a prototype. The usual culprits are a Prisma Decimal, a Mongoose document or a MongoDB ObjectId, all of which log like ordinary values but are class instances. TypeScript's structural typing means they satisfy a plain-object type, so nothing catches it statically. Map the fields you need explicitly in the Server Component before passing them across the boundary.
How do I check Client Component prop serializability in CI?
Define a Serializable union type covering strings, numbers, booleans, null, undefined, bigint, Date, arrays and plain objects, then declare a generic alias such as AssertSerializable whose type parameter is constrained to extend Serializable, and wrap your props type in it. Any function or class-typed field then fails npx tsc --noEmit, which does run in CI, and it works regardless of whether the component uses a named or a default export.
#Next.js#TypeScript#React Server Components#React#Prisma
Keep reading

Related articles