- Fix Next.js Prisma Serialization: Centralize Decimal/Date
Fix Next.js Prisma Serialization: Centralize Decimal/Date
Convert Prisma Decimal & Date to JSON-safe values by centralizing serialization in your repository for Next.js

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Related Posts:
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:
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.
// File: src/lib/serializers/to-client.ts
type JsonPrimitive = string | number | boolean | null;
export type JsonValue =
| JsonPrimitive
| JsonValue[]
| { [key: string]: JsonValue };
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (!value || typeof value !== "object") {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
};
export const toNumberOrNull = (value: unknown): number | null => {
if (value === null || value === undefined) {
return null;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
if (typeof (value as { toNumber?: () => number }).toNumber === "function") {
const parsed = (value as { toNumber: () => number }).toNumber();
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
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 instanceof Date) {
return value.toISOString();
}
if (typeof (value as { toNumber?: () => number }).toNumber === "function") {
const parsed = (value as { toNumber: () => number }).toNumber();
return Number.isFinite(parsed) ? parsed : null;
}
if (typeof (value as { toJSON?: () => unknown }).toJSON === "function") {
return toClientSafeInternal((value as { toJSON: () => unknown }).toJSON());
}
if (Array.isArray(value)) {
return value.map(toClientSafeInternal);
}
if (isPlainObject(value)) {
const result: Record<string, JsonValue> = {};
for (const [key, nested] of Object.entries(value)) {
result[key] = toClientSafeInternal(nested);
}
return result;
}
return String(value);
};
export const 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.
// File: src/lib/repositories/base/base-repository.ts
import { prisma } from "@/lib/prisma";
import { toClientSafe } from "@/lib/serializers/to-client";
export abstract class BaseRepository {
protected readonly prisma = prisma;
protected readonly context: AuthContext;
constructor(context: AuthContext) {
// existing validation
this.context = context;
}
protected toClient<T>(value: T): T {
return toClientSafe(value) as T;
}
}
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.
// File: src/lib/repositories/inventory-repository.ts
async listStockItems(...) {
const items = await this.prisma.stockItem.findMany({
where,
include: { reservoir: { include: { location: true } }, product: true },
orderBy: [...]
});
if (filters?.lowStock) {
const filtered = items.filter(item =>
item.reservoir.minQuantity && item.currentQuantity.lessThanOrEqualTo(item.reservoir.minQuantity)
);
return this.toClient(filtered);
}
return this.toClient(items);
}
async getStockItem(id: string) {
const item = await this.prisma.stockItem.findFirst({
where: { id, reservoir: { location: { organisationId: this.organisationId } } },
include: { reservoir: { include: { location: true } }, product: true }
});
return item ? this.toClient(item) : null;
}
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.
// File: src/lib/time-zone.ts
export function formatLocalDate(
utcDate: Date | string | number,
formatStr: string = "yyyy-MM-dd",
): string {
return formatInTimeZone(new Date(utcDate), APP_TIMEZONE, formatStr);
}
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.
Thanks, Matija


