惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Y
Y Combinator Blog
宝玉的分享
宝玉的分享
月光博客
月光博客
小众软件
小众软件
Jina AI
Jina AI
WordPress大学
WordPress大学
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
T
Tailwind CSS Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 【当耐特】
博客园 - 三生石上(FineUI控件)
博客园 - 司徒正美
大猫的无限游戏
大猫的无限游戏
The Cloudflare Blog
G
Google Developers Blog
M
MIT News - Artificial intelligence
N
Netflix TechBlog - Medium
云风的 BLOG
云风的 BLOG
MyScale Blog
MyScale Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
爱范儿
爱范儿
U
Unit 42
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Blog — PlanetScale
Blog — PlanetScale

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Zod on the server and the client: the schema you define o...
Juan Torchia · 2026-06-13 · via DEV Community

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:


This article was originally published on juanchi.dev