Zod on the server and the client: the schema you define once and the three ways it breaks in runtime
80% of Next.js projects using Zod have the same schema imported from three different contexts. And only one of those three contexts behaves exactly the way Zod promises.
Yeah, you read that right. The mental model of "define the schema once and validate everywhere" is true inside the library — but in a real stack with Next.js 16 — Server Actions, edge middleware, and API routes — you're dealing with three execution environments with different constraints, and Zod doesn't always arrive complete to all three.
My thesis: Zod is one of the best tools in the TypeScript ecosystem, but sharing the same schema across client, Node.js server, and edge runtime without thinking about context differences produces three specific failure classes that aren't obvious until they blow up. This post documents those three failures and the pattern that prevents them.
The problem nobody draws in the diagram
When you start with Zod, the flow looks clean: a schema in lib/schemas/user.ts, imported from the Server Action, from the client form, and from the middleware. TypeScript happy, a single source of truth.
The problem is that .ts file runs in three different engines depending on context:
| Context | Runtime | Relevant constraints |
|---|---|---|
| Client component / form | Browser (V8) | No Node APIs, bundle must stay small |
| Server Action / API Route | Node.js on the server | Full access, but strict serialization between server/client |
Middleware (middleware.ts) |
Edge Runtime (restricted V8) | No Node.js APIs, limited ESM modules, no eval
|
Zod itself is compatible with all these contexts at its core. The problem isn't Zod — it's what you build on top of Zod: refinements with Node.js logic, transforms that return non-serializable types, and errors that travel back to the client unfiltered.
Failure #1: The .refine() that silently calls Node.js
The first failure mode shows up in the middleware. You have a session or route parameter validation schema, you drop it in middleware.ts, and at some point that schema has a .refine() that does something innocent like this:
// lib/schemas/session.ts
import { z } from "zod"
import { isValidToken } from "@/lib/crypto" // ← uses Node.js crypto
export const sessionSchema = z.object({
token: z.string().refine(
async (val) => isValidToken(val), // ← calls a function with Node API
{ message: "Invalid token" }
)
})
// middleware.ts — Edge Runtime
import { sessionSchema } from "@/lib/schemas/session"
export async function middleware(request: NextRequest) {
const result = await sessionSchema.safeParseAsync({ token: getCookie(request) })
// ← in Edge Runtime, this can fail if isValidToken uses Node's crypto.subtle
}
Next.js's edge runtime runs in a restricted V8 environment — similar to Cloudflare Workers — that doesn't expose all Node.js APIs. If isValidToken internally uses Node's crypto (not the Web Crypto API), the import explodes at runtime, not at build time. TypeScript won't catch it because the signature is valid.
The fix: schemas that go to the middleware have to be edge-safe by design. If you need validation logic that depends on Node.js APIs, that logic doesn't belong in the edge schema — it belongs in the Server Action running on Node.js.
// lib/schemas/edge/session.ts — structural validation only, no Node logic
import { z } from "zod"
export const edgeSessionSchema = z.object({
token: z.string().min(32).max(512), // pure structural validation
// No .refine() calling anything external
})
// lib/schemas/server/session.ts — for Server Actions / API Routes on Node.js
import { z } from "zod"
import { isValidToken } from "@/lib/crypto"
export const serverSessionSchema = z.object({
token: z.string().refine(
async (val) => isValidToken(val),
{ message: "Invalid token" }
)
})
Separating schemas by execution layer isn't duplicating code — it's documenting the real contract for each context. You can read more about how the Web Crypto API differs between browser and Node.js in this stack analysis — the same logic applies to what you can safely put inside an edge .refine().
Failure #2: The .transform() that breaks serialization in Server Actions
The second failure mode is more subtle and only surfaces in Server Actions. When a Server Action returns data, Next.js serializes it to send to the client using a protocol based on React Server Components (similar to JSON but with support for Promises, Dates, and some special types). The official docs call these "serializable return values."
The problem: if your schema uses .transform() to convert data into something non-serializable — a Map, a class instance, a Set, or an object with methods — and that result travels directly to the client from a Server Action, Next.js can't serialize it.
// lib/schemas/user.ts — shared schema without thinking about context
import { z } from "zod"
export const userSchema = z.object({
id: z.string(),
roles: z.array(z.string()).transform(
(roles) => new Set(roles) // ← Set is not serializable by React Server Components
)
})
// app/actions/user.ts — Server Action
"use server"
import { userSchema } from "@/lib/schemas/user"
export async function getUser(formData: FormData) {
const parsed = userSchema.parse({ id: formData.get("id"), roles: ["admin"] })
return parsed // ← Next.js tries to serialize this → runtime error
}
TypeScript accepts this code. The build passes. The error shows up at runtime when Next.js tries to serialize the Set to send to the client component.
The most direct fix: if the transform exists for internal server convenience, don't put it in the shared schema. Put the base schema (without the transform) in the shared location, and apply the transform only inside the Server Action or service that needs it.
// lib/schemas/user.ts — base schema, no transforms that break serialization
import { z } from "zod"
export const userSchema = z.object({
id: z.string(),
roles: z.array(z.string()) // serializable array
})
// Clean inferred type for the client
export type User = z.infer<typeof userSchema>
// app/actions/user.ts
"use server"
import { userSchema } from "@/lib/schemas/user"
export async function getUser(formData: FormData) {
const parsed = userSchema.parse({ id: formData.get("id"), roles: ["admin"] })
// You do the Set transform here, on the server, and don't send it to the client
const rolesSet = new Set(parsed.roles)
return parsed // only the serializable object
}
This connects directly to the caching mental model in App Router: data traveling between server and client has constraints that TypeScript code doesn't reflect. If you want to go deeper on those constraints from the React side, the post on React 19 Server Components and caching covers the mental model that's missing from the documentation.
Failure #3: The Zod error that reaches the client unsanitized
The third failure is a security issue and it's the easiest one to introduce. When zod.parse() fails, it throws a ZodError with an array of issues. Each issue has path, message, and code. If you catch that error in a Server Action and send it directly to the client without processing it, you're shipping the complete internal validation structure — including internal field names, nested paths, and sometimes messages that leak business logic.
// ❌ Insecure pattern — the full ZodError travels to the client
"use server"
import { userSchema } from "@/lib/schemas/user"
export async function createUser(formData: FormData) {
try {
const data = userSchema.parse(Object.fromEntries(formData))
// ...
} catch (error) {
// ← if it's a ZodError, this exposes internal paths to the client
return { error: error instanceof Error ? error.message : "Unknown error" }
}
}
ZodError.message is a serialized JSON with all the issues. On a password field or a field validating against an internal list of prohibited values, that can leak information.
The right pattern is to use safeParseAsync or safeParse and explicitly build the error message you want the client to receive:
// ✅ Secure pattern — sanitized errors
"use server"
import { userSchema } from "@/lib/schemas/user"
export async function createUser(formData: FormData) {
const result = userSchema.safeParse(Object.fromEntries(formData))
if (!result.success) {
// You build exactly what you want to expose
const publicErrors = result.error.issues.map((issue) => ({
field: issue.path.join("."), // do you want to expose the path? your call
message: issue.message, // is this message safe for the client?
}))
return { success: false, errors: publicErrors }
}
// data is correctly typed
const data = result.data
// ...
return { success: true }
}
This pattern also makes it much easier to internationalize error messages, because you have explicit control over what gets sent.
Decision checklist: how to share schemas across contexts
Before importing a schema from a new context, run it through these questions:
Will the schema run in Edge Runtime (middleware)?
→ Does it have .refine() or .transform() that calls external functions?
→ YES: separate into an edge-safe schema with structural validation only
→ NO: you can reuse it carefully
Will the schema be returned from a Server Action to the client?
→ Does it have .transform() that produces Map, Set, complex Date, class instance?
→ YES: apply the transform on the server, return the base serializable type
→ NO: the base schema can be shared
Will validation errors reach the client?
→ Are you using .parse() and catching the error directly?
→ YES: replace with .safeParse() and build the error response manually
→ NO: verify that error messages don't expose internal business logic
The golden rule: the shared schema should only have pure structural validations — types, lengths, formats, required fields. Validations that depend on business logic, database access, or Node.js APIs belong in server-exclusive schemas.
Limits: what you can't conclude without more evidence
This analysis is based on official Zod and Next.js documentation and reproducible patterns. What you can't assume from this:
- That these three failure modes are the only ones. In a stack with tRPC, Remix, or Vercel Edge Functions, the constraints may differ.
- That Next.js 16's edge runtime and Vercel's are identical in every case. The Next.js and Vercel Edge Runtime docs have some nuances of their own.
- That
.transform()always breaks serialization in Server Actions. Transforms that produce primitive types, arrays of primitives, or plain objects work fine. The problem is specific to types that aren't serializable by the React Server Components protocol.
If you want to verify the behavior in your own stack, the reproducible experiment is simple: create a schema with a .transform() that returns a new Set(), use it in a Server Action, and inspect the error in the browser console. Next.js's error message is pretty clear about what it can't serialize.
FAQ on Zod in production with Next.js 16
Can I use the same Zod schema on client and server?
Yes, if the schema only has pure structural validations (types, formats, lengths). The problem appears when you add .refine() with logic that depends on Node.js APIs or .transform() that produces non-serializable types.
Does Zod work in Next.js's Edge Runtime?
Zod's core does. Problems appear when .refine() or .transform() inside the schema call code that uses Node.js-exclusive APIs (like crypto, fs, or buffer). Zod itself doesn't use those APIs in its core.
What's the difference between parse() and safeParse() for Server Actions?
parse() throws a ZodError on failure, which you need to catch with try/catch. safeParse() returns { success: true, data } or { success: false, error } without throwing an exception. For Server Actions, safeParse() gives you explicit control over what errors you send to the client — which is the safe way to handle it.
Can I put database validations inside a Zod .refine()?
You can, but only in server schemas (never in schemas running on edge or client). An async .refine() that queries the database to check email uniqueness is a valid pattern in a Server Action on Node.js. In edge runtime or on the client, that makes no sense and isn't possible.
How do you know if a transform will break serialization in a Server Action?
The practical rule: if the resulting type of the transform is Map, Set, a class instance with methods, or anything that isn't serializable to plain JSON, don't return it directly from the Server Action. You can verify this in the official Next.js documentation on serialization in Server Actions.
Is it worth having separate schemas per context if it complicates the project?
The separation is only necessary where there are real differences: if you don't have middleware with validation logic, you don't need edge schemas. The minimum rule is: a shared base schema with structural validations, and extended schemas with .refine() / .transform() only where the context allows it.
Final stance and next step
Zod isn't broken. The "define once" model works perfectly for pure structural validations that don't depend on the execution environment. The problem is that in Next.js 16 with Server Actions and middleware, that execution environment changes silently and TypeScript doesn't warn you.
What I do buy: Zod as the source of truth for your types and data structure. What I don't buy without thinking: using the same schema with complex transforms and refinements across all three contexts without separating responsibilities.
The pattern that works is simple: a shared base schema with structural validations, server schemas for Node.js logic, and safeParse() whenever errors might travel to the client. It's not overhead — it's explicitly documenting what contract belongs to what layer.
The concrete next step: if you have a project with Zod in Next.js 16, find every place where you import a schema that has .refine() or .transform(), and verify what context it runs in. Three minutes of grep can save you a runtime error that only shows up in production.
Original sources:
- Zod Documentation: https://zod.dev/
- Next.js Server Actions Docs: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations
This article was originally published on juanchi.dev
Top comments (0)