This is a specific case of a general rule, Server Component to Client Component props need to be serializable, but it's worth calling out on its own because the actual error message you get almost never points clearly at "you passed a Mongoose document," and figuring that out the first time genuinely takes real debugging.
The Setup That Looks Completely Normal
// app/dashboard/page.tsx
import { connectDB } from '@/lib/db';
import User from '@/models/User';
import { UserCard } from '@/components/UserCard';
export default async function DashboardPage() {
await connectDB();
const user = await User.findById('some-id'); // note: no .lean() here
return <UserCard user={user} />;
}
// components/UserCard.tsx
'use client';
export function UserCard({ user }: { user: any }) {
return <div>{user.name}</div>;
}
UserCard is a Client Component, and user here is a full Mongoose document, not a plain object, since .lean() was never called on the query. This can throw an error during rendering, or in some Next.js versions and configurations, silently serialize into something that mostly works but loses real functionality, depending on exactly what's being accessed and how.
Why This Happens
The boundary between a Server Component and a Client Component is a real serialization boundary, props passed across it get serialized, sent to the client, and reconstructed there. This works cleanly for plain objects, arrays, strings, numbers, booleans. A Mongoose document is not a plain object, it's a class instance with methods, getters, internal Mongoose-specific state, a prototype chain that doesn't survive serialization, since serialization can only meaningfully carry plain data, not behavior or class identity.
Next.js and React are specifically designed to reject or warn about non-serializable values crossing this boundary, exactly to prevent silently broken behavior. Depending on the specific version and exact data shape, you might get a clear-ish error about an object being non-serializable, or, in some cases, get an object that appears to work for simple property access but breaks the moment code tries to call an actual Mongoose method on it, since those methods don't exist anymore on whatever made it across the boundary.
Why the Error Message Rarely Helps
If you do get an error, it typically references React's serialization rules in fairly generic terms, not "this is a Mongoose document specifically." For a developer who hasn't hit this exact issue before, the natural instinct is to suspect the specific prop shape, a nested object, a date field, something concrete and visible, rather than the actual cause, that the entire object is fundamentally the wrong kind of thing to be crossing this boundary at all, regardless of what its individual fields look like.
The Actual Fix: Convert to a Plain Object Before It Crosses the Boundary
// app/dashboard/page.tsx
export default async function DashboardPage() {
await connectDB();
const user = await User.findById('some-id').lean(); // now a plain object
return <UserCard user={user} />;
}
.lean() returns a genuinely plain JavaScript object from the start, no Mongoose document wrapper, no methods, nothing but the actual data, which is exactly what's safe to pass across the Server-to-Client boundary. This is, incidentally, the same optimization covered in an earlier post for performance reasons, faster queries, less overhead, and it turns out to solve this serialization problem too, for the same underlying reason, a lean query never had the non-serializable document behavior to begin with.
When You Can't Just Add .lean()
Sometimes a full Mongoose document is genuinely needed server-side, for its own methods or virtuals, before eventually needing to pass some of that data to a Client Component. In that case, convert explicitly before crossing the boundary, rather than relying on .lean() at the query level:
export default async function DashboardPage() {
await connectDB();
const userDoc = await User.findById('some-id'); // full document, methods available here
const someComputedValue = userDoc.someMethodThatNeedsTheRealDocument();
const user = userDoc.toObject(); // explicit conversion to a plain object, right before it's needed
return <UserCard user={user} computedValue={someComputedValue} />;
}
.toObject() (or .toJSON(), which also applies any schema-level transform you've configured, worth being deliberate about which one you actually want) converts a real Mongoose document into a genuinely plain object at the exact point you choose, letting you use the document's real methods first, then hand off something safe to serialize once you're done with it.
The Broader Rule
Anything crossing from a Server Component into a Client Component needs to be a plain, genuinely serializable value, not a class instance, not a Mongoose document, not a function, not a Map or Set without explicit conversion. This applies beyond just Mongoose, any ORM or library returning rich class instances rather than plain data carries the same risk. .lean(), .toObject(), or a manual plain-object mapping are all valid ways to cross that line safely, the specific method matters less than making sure something plain, not something with hidden behavior attached, is what actually gets passed.
If you've hit a confusing serialization error passing data into a Client Component, genuinely curious whether it turned out to be this specific cause, or something else. Drop what actually happened in the comments, this one's error messages are vague enough that I'd guess a lot of people have solved it by trial and error without ever fully understanding why the fix worked.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)