Every serious form validates twice: on the client, for immediate feedback, and on the server, because the client is hostile territory and you never trust it. The problem is when those two validations are two different implementations. In a dashboard I built for a client, the frontend accepted a phone number with spaces and the backend rejected it: the user saw the field turn green and the submit failed with a cryptic error. It wasn't a code bug, it was a duplication bug. Since then I follow one rule: the validation schema is written ONCE, in Zod, in a shared package, and both the form and the API import it. This article is that pattern.
The real problem: two sources of truth that diverge
Duplicated validation doesn't fail the day you write it: it fails six months later, when someone changes a rule in the backend (a character minimum, a new postal code format) and nobody touches the frontend. From then on you have two behaviors: what the form says is valid and what the API actually accepts. The user suffers the difference.
The solution isn't discipline ("remember to change both places"), because discipline doesn't scale. The solution is structural: make it so only one place exists.
The schema as a shared contract
In a monorepo, the schema lives in its own package, with no React or Node dependencies:
// packages/schemas/src/registro.ts
import { z } from 'zod';
export const registroSchema = z.object({
email: z.string().email('Invalid email'),
telefono: z
.string()
.transform((v) => v.replace(/\s+/g, ''))
.pipe(z.string().regex(/^\+?\d{9,15}$/, 'Invalid phone number')),
password: z.string().min(12, 'Minimum 12 characters'),
});
export type RegistroInput = z.input<typeof registroSchema>;
export type RegistroOutput = z.output<typeof registroSchema>;
Two details matter here. First, the transform + pipe: normalization (stripping spaces from the phone) is part of the schema, so the "phone with spaces" is valid in both worlds and gets normalized the same way in both worlds. Second, exporting z.input and z.output as separate types: what goes in (with spaces) and what comes out (normalized) are different types, and TypeScript forces you not to confuse them.
On the client: the same schema drives the form
With react-hook-form and its Zod resolver, the form validates against the shared schema without writing a single extra rule:
const form = useForm<RegistroInput>({
resolver: zodResolver(registroSchema),
});
The error messages you defined in the schema appear under each field. If tomorrow the password minimum goes up to 14, you change one number in one file and the form and the API find out at the same time.
On the server: parse, don't trust
In the API, the same schema is the boundary between the outside world and your typed code:
export async function POST(req: Request) {
const parsed = registroSchema.safeParse(await req.json());
if (!parsed.success) {
return Response.json(
{ errors: parsed.error.flatten().fieldErrors },
{ status: 422 },
);
}
// parsed.data is RegistroOutput: normalized and typed
await crearUsuario(parsed.data);
}
I use safeParse and return fieldErrors with the same structure the form consumes. That way, even if a malicious client skips browser validation, the error the server returns fits into the same per-field error UI. Client validation is UX; server validation is security. Same logic, different roles.
💡 "Parse, don't validate": the schema doesn't just check that the data is correct, it transforms it into a type that can no longer be incorrect. Past the boundary, no function ever asks again whether the email is valid: the type guarantees it.
The limits of the pattern
Not everything is shareable, and pretending it is produces monstrous schemas. Validations that depend on server state (does this email already exist? is this coupon still active?) don't belong in the shared schema: they're backend business rules and get checked there, returning errors in the same fieldErrors format so the UI renders them identically.
I also decided not to share API response schemas with the same enthusiasm. Input schemas change slowly and you control them; typing every response with Zod at runtime adds a parsing cost that buys nothing on most endpoints. There, the TypeScript type contract generated from the schema itself is enough for me.
The pattern's overall trade-off is coupling: frontend and backend share a package, which demands a monorepo or publishing a versioned package. I chose a monorepo with pnpm workspaces because the cost of coordinating versions across separate repos was exactly the kind of friction I was trying to eliminate. In exchange, a schema change forces a coordinated deploy of both sides. I accept it: I prefer an explicit coordinated deploy over a silent divergence discovered by a user.