Frontend 7 jul 2026 · 7 min de lectura

Un esquema Zod para gobernarlos a todos: validación compartida cliente/servidor

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

Todo formulario serio valida dos veces: en el cliente, para dar feedback inmediato, y en el servidor, porque el cliente es territorio hostil y nunca se confía en él. El problema es cuando esas dos validaciones son dos implementaciones distintas. En un panel que construí para un cliente, el frontend aceptaba un teléfono con espacios y el backend lo rechazaba: el usuario veía el campo en verde y el submit fallaba con un error críptico. No era un bug de código, era un bug de duplicación. Desde entonces sigo una regla: el esquema de validación se escribe UNA vez, en Zod, en un paquete compartido, y tanto el formulario como la API lo importan. Este artículo es ese patrón.

El problema real: dos fuentes de verdad que divergen

La validación duplicada no falla el día que la escribes: falla seis meses después, cuando alguien cambia una regla en el backend (mínimo de caracteres, un formato nuevo de código postal) y nadie toca el frontend. A partir de ahí tienes dos comportamientos: lo que el formulario dice que es válido y lo que la API acepta de verdad. El usuario sufre la diferencia.

La solución no es disciplina ("acordaos de cambiar los dos sitios"), porque la disciplina no escala. La solución es estructural: que solo exista un sitio.

El esquema como contrato compartido

En un monorepo, el esquema vive en un paquete propio, sin dependencias de React ni de Node:

// packages/schemas/src/registro.ts
import { z } from 'zod';

export const registroSchema = z.object({
  email: z.string().email('Email inválido'),
  telefono: z
    .string()
    .transform((v) => v.replace(/\s+/g, ''))
    .pipe(z.string().regex(/^\+?\d{9,15}$/, 'Teléfono inválido')),
  password: z.string().min(12, 'Mínimo 12 caracteres'),
});

export type RegistroInput = z.input<typeof registroSchema>;
export type RegistroOutput = z.output<typeof registroSchema>;

Dos detalles importan aquí. El primero, el transform + pipe: la normalización (quitar espacios del teléfono) es parte del esquema, así el "teléfono con espacios" es válido en los dos mundos y se normaliza igual en los dos mundos. El segundo, exportar z.input y z.output como tipos separados: lo que entra (con espacios) y lo que sale (normalizado) son tipos distintos, y TypeScript te obliga a no confundirlos.

En el cliente: el mismo esquema alimenta el formulario

Con react-hook-form y su resolver de Zod, el formulario valida contra el esquema compartido sin escribir ni una regla más:

const form = useForm<RegistroInput>({
  resolver: zodResolver(registroSchema),
});

Los mensajes de error que definiste en el esquema aparecen bajo cada campo. Si mañana el mínimo de la contraseña sube a 14, cambias un número en un archivo y el formulario y la API se enteran a la vez.

En el servidor: parsear, no confiar

En la API, el mismo esquema es la frontera entre el mundo exterior y tu código tipado:

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 es RegistroOutput: normalizado y tipado
  await crearUsuario(parsed.data);
}

Uso safeParse y devuelvo fieldErrors con la misma estructura que consume el formulario. Así, incluso si un cliente malicioso salta la validación del navegador, el error que devuelve el servidor encaja en la misma UI de errores por campo. La validación del cliente es UX; la del servidor es seguridad. Misma lógica, roles distintos.

💡 "Parse, don't validate": el esquema no solo comprueba que los datos son correctos, los transforma en un tipo que ya no puede ser incorrecto. Después de la frontera, ninguna función vuelve a preguntarse si el email es válido: el tipo lo garantiza.

Los límites del patrón

No todo es compartible, y fingir que lo es genera esquemas monstruosos. Las validaciones que dependen de estado del servidor (¿este email ya existe?, ¿este cupón sigue activo?) no pertenecen al esquema compartido: son reglas de negocio del backend y se comprueban ahí, devolviendo errores con el mismo formato de fieldErrors para que la UI las pinte igual.

También decidí no compartir esquemas de respuesta de la API con el mismo entusiasmo. Los esquemas de entrada cambian despacio y los controlas tú; tipar cada respuesta con Zod en runtime añade un coste de parseo que en la mayoría de endpoints no compra nada. Ahí me basta el contrato de tipos de TypeScript generado del propio esquema.

El trade-off global del patrón es el acoplamiento: frontend y backend comparten un paquete, lo que exige monorepo o publicar el paquete versionado. Elegí monorepo con pnpm workspaces porque el coste de coordinar versiones entre repos separados era exactamente el tipo de fricción que intentaba eliminar. A cambio, un cambio de esquema obliga a desplegar ambos lados coordinados. Lo acepto: prefiero un despliegue coordinado explícito a una divergencia silenciosa que descubre un usuario.

Yohangel Ramos

Escrito por Yohangel Ramos

Senior Fullstack Developer y Tech Lead. Construyo con React, Next.js, Nest.js y AWS — y escribo sobre lo que aprendo en el camino.

Hablemos →

Sigue leyendo

IA

Embeddings más baratos: recortar dimensiones y cuantizar sin perder recall

IA

Qué va a pasar con el desarrollo de software: predicciones con fechas (julio 2026)