Formularios en Vue 3 con Zod: un esquema, cliente y servidor
Cómo validar formularios en Vue 3 y Nuxt con un único esquema Zod compartido entre navegador y API, sin librerías de formularios y sin duplicar reglas que acaban divergiendo.
Formularios en Vue 3 con Zod: un esquema, cliente y servidor
Hay un bug que aparece en casi todos los formularios que reviso, y casi nadie lo tiene fichado. El usuario rellena el formulario, todo se ve verde, pulsa enviar — y recibe un error genérico del servidor.
La causa está en dos trozos de código que nadie mira juntos. En el componente:
// ❌ Antipatrón: las reglas del cliente
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
function isValidName() { return nameVal.value.trim().length > 0 }
function isValidEmail() { return emailRe.test(emailVal.value.trim()) }
function isValidMsg() { return msgVal.value.trim().length >= 8 }
Y en la API, meses después, alguien añadió límites:
// server/api/contact.post.ts
name: z.string().min(1).max(100),
email: z.string().email().max(254),
message: z.string().min(1).max(5000),
Lee las dos con cuidado. El cliente no comprueba longitud máxima del nombre; el servidor la corta en 100. El cliente exige 8 caracteres de mensaje; el servidor acepta 1. Y cada uno decide qué es un email válido con reglas distintas.
Un usuario con un nombre largo pasa la validación del cliente y se estrella contra el servidor. No es que falte una comprobación: es que hay dos fuentes de verdad que divergen en cuanto una de las dos se toca.
Un esquema, dos entornos
La solución no es sincronizar las dos listas a mano. Es que solo haya una.
// utils/contactSchema.ts
import { z } from 'zod'
export const contactSchema = z.object({
name: z.string().trim().min(1, 'Falta el nombre').max(100, 'Nombre demasiado largo'),
email: z.string().trim().email('Email no válido').max(254, 'Email demasiado largo'),
message: z.string().trim().min(8, 'Mensaje demasiado corto').max(5000, 'Mensaje demasiado largo'),
url: z.string().default(''),
})
export type ContactInput = z.infer<typeof contactSchema>
Ese fichero lo importan los dos lados. El componente para dar feedback inmediato, la API para no fiarse de nadie. Cuando mañana el máximo del nombre pase a 120, cambia en un sitio y los dos lados quedan de acuerdo por construcción.
El .trim() va dentro del esquema, no en el componente. Si limpias en el cliente y validas en el servidor sin limpiar, un mensaje de puros espacios pasa el min(8) del navegador y llega al servidor como cadena vacía. Poner el trim en el esquema hace que ambos lados limpien igual.
z.infer cierra el círculo: ContactInput es el tipo del payload, derivado del esquema. No hay una interfaz aparte que actualizar.
Errores por campo, no un array
safeParse devuelve todos los problemas en error.issues, pero un formulario necesita saber qué error va debajo de qué input. La conversión es de una línea:
// composables/useContactForm.ts
import { contactSchema, type ContactInput } from '~/utils/contactSchema'
type Errors = Partial<Record<keyof ContactInput, string>>
export function useContactForm() {
const form = reactive<ContactInput>({ name: '', email: '', message: '', url: '' })
const errors = ref<Errors>({})
const touched = ref<Set<string>>(new Set())
function validate(): boolean {
const result = contactSchema.safeParse(form)
errors.value = result.success
? {}
: Object.fromEntries(
result.error.issues.map(issue => [issue.path[0], issue.message]),
)
return result.success
}
return { form, errors, touched, validate }
}
Partial<Record<keyof ContactInput, string>> no es adorno: si mañana renombras message a body en el esquema, TypeScript marca cualquier plantilla que siga leyendo errors.message. El tipo del formulario y el de sus errores se mueven juntos.
Cuándo mostrar el error
Aquí está la parte que ninguna librería resuelve por ti, porque es una decisión de producto: validar al escribir es hostil. El usuario teclea la primera letra de su email y ya le estás diciendo que está mal.
La regla que uso: validar al perder el foco, y a partir de ahí sí en cada tecla.
function validateField(field: keyof ContactInput) {
const result = contactSchema.safeParse(form)
const issue = result.success
? undefined
: result.error.issues.find(i => i.path[0] === field)
errors.value = { ...errors.value, [field]: issue?.message }
}
function onBlur(field: keyof ContactInput) {
touched.value.add(field)
validateField(field)
}
function onInput(field: keyof ContactInput) {
// Solo re-valida lo que el usuario ya ha visitado: corregir un error
// debería quitarlo al momento, pero un campo intacto no debe gritar.
if (touched.value.has(field)) validateField(field)
}
Esa asimetría es todo el truco. Un campo que nunca has tocado permanece en silencio. Uno que ya te dio un error te confirma al instante que lo has arreglado — que es justo cuando el feedback inmediato ayuda en vez de molestar.
En el template, los errores se conectan con el input por accesibilidad:
<label for="email">Email</label>
<input
id="email"
v-model="form.email"
type="email"
:aria-invalid="Boolean(errors.email)"
:aria-describedby="errors.email ? 'email-error' : undefined"
@blur="onBlur('email')"
@input="onInput('email')"
>
<p v-if="errors.email" id="email-error" role="alert">{{ errors.email }}</p>
aria-describedby es lo que hace que un lector de pantalla anuncie el error al llegar al campo. Sin él, el mensaje existe visualmente y no existe para quien navega con teclado y voz.
El servidor no se fía
Toda la validación del cliente es una cortesía. Cualquiera puede hacer un POST a tu endpoint con curl. El mismo esquema, en el servidor, es la que de verdad protege:
// server/api/contact.post.ts
import { contactSchema } from '~/utils/contactSchema'
export default defineEventHandler(async (event) => {
const parsed = contactSchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({
statusCode: 400,
statusMessage: parsed.error.issues[0]?.message ?? 'Entrada no válida',
})
}
const { name, email, message, url } = parsed.data
// …
})
Después de safeParse, parsed.data está tipado como ContactInput. No hay as, no hay any, y no hay campos extra: Zod descarta por defecto lo que no esté en el esquema, así que nadie te cuela un isAdmin: true en el body.
El honeypot: un campo que valida "estar vacío"
El campo url del esquema no es un descuido. Es un honeypot: un input oculto que un humano nunca ve y que los bots rellenan por reflejo.
<!-- Oculto para humanos, visible para bots -->
<input v-model="form.url" name="url" tabindex="-1" autocomplete="off" aria-hidden="true">
if (parsed.data.url) {
// Responde 200: un bot que recibe un error reintenta con otra estrategia
return { ok: true }
}
El detalle que importa está en la respuesta. Devolver 400 le enseña al bot que ha sido detectado y que pruebe otra cosa. Devolver 200 le hace creer que funcionó, y no vuelve.
Ojo con esconderlo: display: none y el atributo hidden los detectan los bots modernos. Sácalo del viewport con posicionamiento absoluto.
¿Y cuándo sí necesitas una librería?
| Escenario | Solución |
|---|---|
| 3-6 campos, un solo paso | Zod + un composable propio |
| Arrays dinámicos de campos | Zod + composable, con cuidado en las claves |
| Formularios multipaso con estado | VeeValidate o FormKit |
| Formularios generados desde un schema | FormKit |
El coste real de una librería de formularios no son sus kilobytes: es que te obliga a expresar la validación en su formato, y entonces vuelves a tener dos fuentes de verdad — el esquema del backend y las reglas del formulario. Si ya usas Zod para validar la API, mantener el mismo esquema en el cliente es menos trabajo que integrarlo con la librería.
Conclusión
- Un esquema, importado por cliente y servidor. Todo lo demás deriva de aquí.
trimdentro del esquema, para que ambos lados normalicen igual.- Errores como objeto tipado por campo, no como array de issues.
- Valida al
blur, re-valida al escribir solo lo ya tocado. aria-invalidyaria-describedbyo el error no existe para media parte de tus usuarios.- El servidor valida siempre, aunque el cliente ya lo haya hecho.
Un formulario de contacto no necesita una librería. Necesita que la regla exista una sola vez.
¿Preguntas o quieres ver algún caso concreto? Escríbeme a hola@miguel-jimenez.dev.