In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I was building an admin inventory area in Next.js when I hit a noisy warning about Prisma types leaking into Client Components. After chasing it across pages, I realized the fix wasn't to keep serializing props everywhere, but to centralize it in the repository layer. This guide shows how I solved the Decimal/Date serialization problem once, so all inventory pages stop screaming at me.
The exact warning we saw was:
code
Only plain objects can be passed to Client Components from Server Components. Decimal objects are not
supported.
{id: ..., reservoirId: ..., productId: ..., name: ..., type: ..., currentQuantity: Decimal,
minQuantity: ..., unit: ..., createdAt: ..., updatedAt: ..., createdById: ..., updatedById: ...,
vehicleId: ..., reservoir: ..., product: ...}
^^^^^^^
src/app/(admin)/admin/zaloge/[id]/uredi/page.tsx (31:7) @ EditStockPage
That message is accurate: Prisma's Decimal (and Date) are not plain JSON values, so Next.js won't allow
them across the server→client boundary.
Step 1: Add a JSON-safe serializer helper
The first step is to create a serializer that converts Prisma Decimal, Date, and other non-plain
objects into JSON-safe values. I placed this in a dedicated helper so I could reuse it anywhere.
typescript
// File: src/lib/serializers/to-client.tstypeJsonPrimitive = string | number | boolean | null;
exporttypeJsonValue =
| JsonPrimitive
| JsonValue[]
| { [key: string]: JsonValue };
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (!value || typeof value !== "object") {
returnfalse;
}
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
};
exportconst toNumberOrNull = (value: unknown): number | null => {
if (value === null || value === undefined) {
returnnull;
}
if (typeof value === "number") {
returnNumber.isFinite(value) ? value : null;
}
if (typeof value === "string") {
const parsed = Number(value);
returnNumber.isFinite(parsed) ? parsed : null;
}
if (typeof (value as { toNumber?: () =>number }).toNumber === "function") {
const parsed = (value as { toNumber: () =>number }).toNumber();
returnNumber.isFinite(parsed) ? parsed : null;
}
returnnull;
};
const toClientSafeInternal = (value: unknown): JsonValue => {
if (value === null || value === undefined) {
return value;
}
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (typeof value === "bigint") {
return value.toString();
}
if (value instanceofDate) {
return value.toISOString();
}
if (typeof (value as { toNumber?: () =>number }).toNumber === "function") {
const parsed = (value as { toNumber: () =>number }).toNumber();
returnNumber.isFinite(parsed) ? parsed : null;
}
if (typeof (value as { toJSON?: () =>unknown }).toJSON === "function") {
returntoClientSafeInternal((value as { toJSON: () =>unknown }).toJSON());
}
if (Array.isArray(value)) {
return value.map(toClientSafeInternal);
}
if (isPlainObject(value)) {
constresult: Record<string, JsonValue> = {};
for (const [key, nested] ofObject.entries(value)) {
result[key] = toClientSafeInternal(nested);
}
return result;
}
returnString(value);
};
exportconst toClientSafe = <T>(value: T): JsonValue =>toClientSafeInternal(value);
This helper intentionally converts non-plain values to safe primitives. That means Decimal becomes a
number, and Date becomes an ISO string. It does exactly what Next.js expects.
Step 2: Centralize serialization in the repository base class
Instead of serializing data in every page, I added a single toClient helper in the base repository.
That gives me one place to control how all repository results are normalized.
This doesn't change any write logic. It only gives us a way to return JSON-safe values from read
methods.
Step 3: Return serialized values from inventory read methods
With the helper in place, I updated inventory read methods to return this.toClient(...). That's the key
move: every list/get in the inventory repository now returns plain values, so the pages don't need
extra mapping.
I applied the same change to all inventory read methods (listLocationsWithReservoirs, listReservoirs,
getReservoir, getLocation, listProducts, getProduct, searchProducts, getReservoirTransactions,
listFuelToppingActivities, getStockTransactions). Now anything coming out of that repository is already
safe to pass into a Client Component.
Step 4: Accept serialized dates in formatting helpers
Because the serializer turns Date into strings, I made sure our date formatting helpers handle strings
as well.
This keeps UI formatting stable whether the source is a Date or an ISO string.
Conclusion
The problem was a Next.js serialization warning caused by Prisma Decimal and Date values crossing into
Client Components. Instead of serializing props page by page, I centralized the solution in the
repository layer and added a reusable serializer. Now all inventory data is JSON-safe by default, and
the warning disappears without adding SWR or rewriting every page.
If you follow these steps, you'll stop seeing that "Only plain objects can be passed to Client
Components…" warning and gain a consistent, maintainable data boundary.
Let me know in the comments if you have questions, and subscribe for more practical development guides.