Validación de Formularios con Zod y React Hook Form: guía Completa
Aprende a validar formularios en React con Zod y React Hook Form. Setup completo, validaciones custom, mensajes en español, Server Actions y mejores prácticas de seguridad.
Validación de Formularios con Zod y React Hook Form
La validación de formularios con Zod y React Hook Form es el estandar actual para aplicaciones React con TypeScript. Un solo schema define las reglas de validación, los tipos de TypeScript y los mensajes de error. Sin duplicar lógica, sin estados manuales por campo, sin dolores de cabeza.
Si tus formularios todavia dependen de validaciones manuales con if/else por cada campo o de required en los inputs del HTML, esta guía te muestra como hacerlo correctamente.
por qué importa validar formularios bien
Un formulario sin validación (o con validación solo en el frontend) es una invitación a problemas:
// Esto es lo que pasa sin validación real
function RegistroBasico() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
// Sin validación: confiando ciegamente en el usuario
const datos = {
nombre: formData.get('nombre'),
email: formData.get('email'),
password: formData.get('password'),
}
// qué pasa si nombre esta vacio?
// qué pasa si email no es un email?
// qué pasa si password tiene 2 caracteres?
await fetch('/api/registro', {
method: 'POST',
body: JSON.stringify(datos),
})
}
return (
<form onSubmit={handleSubmit}>
<input name="nombre" required />
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Registrarse</button>
</form>
)
}Problemas con este enfoque:
requiredytype="email"solo funcionan en el navegador, cualquiera puede saltarselos- No hay mensajes de error útiles para el usuario
- No hay validación en el servidor
- No hay tipos de TypeScript -- todo es
FormDataEntryValue | null - No hay forma de reutilizar las reglas de validación
required no es validación
Los atributos HTML como required y type="email" son ayudas de UX del navegador, no validación real. Cualquiera puede abrir DevTools, quitar el atributo required, y enviar el formulario vacio. Nunca confies en la validación del cliente como única barrera.
Setup: instalar las dependencias
Necesitas tres paquetes:
npm install zod react-hook-form @hookform/resolvers| Paquete | Para que sirve |
|---|---|
zod | Definir schemas de validación con tipos automaticos |
react-hook-form | Manejar el estado del formulario sin re-renders innecesarios |
@hookform/resolvers | Conectar Zod (u otras librerías) con React Hook Form |
Compatibilidad
Esta guía usa React 18+, TypeScript 5+, y las versiones más recientes de Zod (3.x) y React Hook Form (7.x). Si usas NextJS 14+, todo funciona sin configuración adicional.
Crear un schema de validación con Zod
Si ya conoces Zod para validar datos, este paso te va a resultar familiar. Si no, aca va un resumen rápido.
Un schema de Zod define la forma y las reglas de tus datos:
import { z } from 'zod'
// Schema para un formulario de registro
const registroSchema = z.object({
nombre: z
.string()
.min(2, 'El nombre debe tener al menos 2 caracteres')
.max(50, 'El nombre no puede tener más de 50 caracteres')
.trim(),
email: z
.string()
.email('Ingresa un email válido')
.toLowerCase(),
password: z
.string()
.min(8, 'La contraseña debe tener al menos 8 caracteres')
.regex(/[A-Z]/, 'Debe contener al menos una mayuscula')
.regex(/[a-z]/, 'Debe contener al menos una minuscula')
.regex(/[0-9]/, 'Debe contener al menos un número'),
confirmarPassword: z
.string()
.min(1, 'Confirma tu contraseña'),
}).refine(
(data) => data.password === data.confirmarPassword,
{
message: 'Las contrasenas no coinciden',
path: ['confirmarPassword'],
}
)
// El tipo se genera automáticamente del schema
type RegistroForm = z.infer<typeof registroSchema>
// {
// nombre: string
// email: string
// password: string
// confirmarPassword: string
// }Lo qué hace cada parte:
.min(),.max(): límites de longitud con mensajes custom.trim(): elimina espacios al inicio y final.toLowerCase(): normaliza el email a minusculas.regex(): validaciones con expresiones regulares.refine(): validación custom a nivel de objeto (password match)z.infer<typeof schema>: extrae el tipo TypeScript del schema
Un schema, dos usos
El mismo schema que usas para validar en el cliente lo puedes reutilizar identico en el servidor. Esto elimina la duplicación de reglas y asegura que las validaciones son consistentes en ambos lados.
Conectar Zod con React Hook Form via zodResolver
Ahora conectamos el schema con React Hook Form:
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const registroSchema = z.object({
nombre: z
.string()
.min(2, 'El nombre debe tener al menos 2 caracteres')
.max(50, 'El nombre no puede tener más de 50 caracteres')
.trim(),
email: z
.string()
.email('Ingresa un email válido')
.toLowerCase(),
password: z
.string()
.min(8, 'La contraseña debe tener al menos 8 caracteres')
.regex(/[A-Z]/, 'Debe contener al menos una mayuscula')
.regex(/[a-z]/, 'Debe contener al menos una minuscula')
.regex(/[0-9]/, 'Debe contener al menos un número'),
confirmarPassword: z
.string()
.min(1, 'Confirma tu contraseña'),
}).refine(
(data) => data.password === data.confirmarPassword,
{
message: 'Las contrasenas no coinciden',
path: ['confirmarPassword'],
}
)
type RegistroForm = z.infer<typeof registroSchema>
function FormularioRegistro() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistroForm>({
resolver: zodResolver(registroSchema),
defaultValues: {
nombre: '',
email: '',
password: '',
confirmarPassword: '',
},
})
const onSubmit = async (datos: RegistroForm) => {
// datos ya esta validado y tipado correctamente
console.log(datos)
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{/* Los campos van aquí */}
</form>
)
}Puntos clave:
zodResolver(registroSchema)conecta el schema con React Hook FormuseForm<RegistroForm>tipa el formulario con el tipo inferido de Zodregisterconecta cada input con React Hook Formerrorscontiene los errores de validación de ZodhandleSubmitsolo ejecutaonSubmitsi la validación pasanoValidatedesactiva la validación nativa del navegador (Zod se encarga)
Formulario de registro completo
Este es el formulario completo con todos los campos, estilos y manejo de errores:
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// ---- Schema ----
const registroSchema = z.object({
nombre: z
.string()
.min(2, 'El nombre debe tener al menos 2 caracteres')
.max(50, 'El nombre no puede tener más de 50 caracteres')
.trim(),
email: z
.string()
.email('Ingresa un email válido')
.toLowerCase(),
password: z
.string()
.min(8, 'La contraseña debe tener al menos 8 caracteres')
.regex(/[A-Z]/, 'Debe contener al menos una mayuscula')
.regex(/[a-z]/, 'Debe contener al menos una minuscula')
.regex(/[0-9]/, 'Debe contener al menos un número')
.regex(/[^A-Za-z0-9]/, 'Debe contener al menos un caracter especial'),
confirmarPassword: z
.string()
.min(1, 'Confirma tu contraseña'),
aceptaTerminos: z
.boolean()
.refine(val => val === true, {
message: 'Debes aceptar los términos y condiciones',
}),
}).refine(
(data) => data.password === data.confirmarPassword,
{
message: 'Las contrasenas no coinciden',
path: ['confirmarPassword'],
}
)
type RegistroForm = z.infer<typeof registroSchema>
// ---- Componente de campo reutilizable ----
interface CampoProps {
label: string
error?: string
children: React.ReactNode
}
function Campo({ label, error, children }: CampoProps) {
return (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-200 mb-1">
{label}
</label>
{children}
{error && (
<p className="mt-1 text-sm text-red-400">{error}</p>
)}
</div>
)
}
// ---- Formulario ----
export function FormularioRegistro() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<RegistroForm>({
resolver: zodResolver(registroSchema),
defaultValues: {
nombre: '',
email: '',
password: '',
confirmarPassword: '',
aceptaTerminos: false,
},
})
const onSubmit = async (datos: RegistroForm) => {
try {
const response = await fetch('/api/registro', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
})
if (!response.ok) {
throw new Error('Error en el registro')
}
reset() // Limpiar el formulario
// Redirigir o mostrar mensaje de éxito
} catch (error) {
console.error('Error:', error)
}
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="max-w-md mx-auto space-y-4"
>
<Campo label="Nombre" error={errors.nombre?.message}>
<input
{...register('nombre')}
type="text"
placeholder="Tu nombre"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-md
text-white placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</Campo>
<Campo label="Email" error={errors.email?.message}>
<input
{...register('email')}
type="email"
placeholder="tu@email.com"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-md
text-white placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</Campo>
<Campo label="contraseña" error={errors.password?.message}>
<input
{...register('password')}
type="password"
placeholder="mínimo 8 caracteres"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-md
text-white placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</Campo>
<Campo label="Confirmar contraseña" error={errors.confirmarPassword?.message}>
<input
{...register('confirmarPassword')}
type="password"
placeholder="Repite tu contraseña"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-md
text-white placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</Campo>
<div className="flex items-start gap-2">
<input
{...register('aceptaTerminos')}
type="checkbox"
id="términos"
className="mt-1"
/>
<label htmlFor="términos" className="text-sm text-gray-300">
Acepto los términos y condiciones
</label>
</div>
{errors.aceptaTerminos && (
<p className="text-sm text-red-400">{errors.aceptaTerminos.message}</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700
disabled:opacity-50 disabled:cursor-not-allowed
text-white font-medium rounded-md transition-colors"
>
{isSubmitting ? 'Registrando...' : 'Crear cuenta'}
</button>
</form>
)
}Este formulario:
- válida todos los campos con Zod via zodResolver
- Muestra errores inline debajo de cada campo
- Verifica que las contrasenas coincidan
- Desactiva el boton mientras se envia
- Limpia el formulario después de un registro exitoso
- Usa TypeScript completo en todo el flujo
Validaciones custom: password match, email único (async)
Validación de coincidencia de contrasenas
Ya la vimos con .refine(), pero hay una alternativa más flexible con .superRefine():
const registroSchema = z.object({
password: z.string().min(8, 'mínimo 8 caracteres'),
confirmarPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmarPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Las contrasenas no coinciden',
path: ['confirmarPassword'],
})
}
// Puedes agregar más validaciones aquí
if (data.password.includes(data.confirmarPassword.slice(0, 3))) {
// Validación adicional si la necesitas
}
})La diferencia entre .refine() y .superRefine():
.refine(): retornatrue/false, un solo error.superRefine(): usactx.addIssue(), permite múltiples errores con contexto completo
Validación asíncrona: verificar email único
const registroSchema = z.object({
nombre: z.string().min(2, 'mínimo 2 caracteres'),
email: z
.string()
.email('Email invalido')
.refine(async (email) => {
// Verificar si el email ya esta registrado
const response = await fetch(
`/api/verificar-email?email=${encodeURIComponent(email)}`
)
const data = await response.json()
return data.disponible // true si el email no existe
}, 'Este email ya esta registrado'),
password: z.string().min(8, 'mínimo 8 caracteres'),
})Para que la validación async funcione bien con React Hook Form, configura el mode:
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValidating },
} = useForm<RegistroForm>({
resolver: zodResolver(registroSchema),
mode: 'onBlur', // válida cuando el usuario sale del campo
})| Mode | Cuando válida | Recomendado para |
|---|---|---|
onSubmit | Solo al enviar el formulario | Formularios simples |
onBlur | Cuando el usuario sale del campo | Validaciones async |
onChange | En cada cambio | Feedback instantaneo (cuidado con el rendimiento) |
onTouched | La primera vez al salir, después en cada cambio | Balance UX/rendimiento |
all | En todos los eventos | máximo feedback |
Validación async con cuidado
Las validaciones asíncronas hacen una petición HTTP cada vez que se ejecutan. Usa mode: 'onBlur' en lugar de onChange para evitar cientos de peticiones mientras el usuario escribe. también considera agregar debounce.
Validación async con debounce
import { useCallback, useRef } from 'react'
// Hook custom para debounce de validación
function useDebounceValidation(delayMs: number = 500) {
const timeoutRef = useRef<NodeJS.Timeout>()
const validar = useCallback(
(fn: () => Promise<boolean>): Promise<boolean> => {
return new Promise((resolve) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(async () => {
const resultado = await fn()
resolve(resultado)
}, delayMs)
})
},
[delayMs]
)
return validar
}múltiples validaciones en un campo
const passwordSchema = z
.string()
.min(8, 'mínimo 8 caracteres')
.max(100, 'máximo 100 caracteres')
.superRefine((password, ctx) => {
const requisitos = [
{ regex: /[A-Z]/, mensaje: 'Al menos una mayuscula' },
{ regex: /[a-z]/, mensaje: 'Al menos una minuscula' },
{ regex: /[0-9]/, mensaje: 'Al menos un número' },
{ regex: /[^A-Za-z0-9]/, mensaje: 'Al menos un caracter especial' },
]
const faltantes = requisitos.filter(r => !r.regex.test(password))
for (const req of faltantes) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: req.mensaje,
})
}
})
// Uso en el formulario: mostrar todos los errores
{errors.password && (
<div className="mt-1 space-y-1">
{/* Si hay múltiples errores, mostrarlos todos */}
<p className="text-sm text-red-400">
{errors.password.message}
</p>
</div>
)}Indicador de fuerza de contraseña
Un patron común en formularios de registro:
function IndicadorFuerza({ password }: { password: string }) {
const reglas = [
{ label: 'mínimo 8 caracteres', cumple: password.length >= 8 },
{ label: 'Una mayuscula', cumple: /[A-Z]/.test(password) },
{ label: 'Una minuscula', cumple: /[a-z]/.test(password) },
{ label: 'Un número', cumple: /[0-9]/.test(password) },
{ label: 'Un caracter especial', cumple: /[^A-Za-z0-9]/.test(password) },
]
const cumplidas = reglas.filter(r => r.cumple).length
const porcentaje = (cumplidas / reglas.length) * 100
const color =
porcentaje <= 40
? 'bg-red-500'
: porcentaje <= 80
? 'bg-yellow-500'
: 'bg-green-500'
return (
<div className="mt-2">
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full ${color} transition-all duration-300`}
style={{ width: `${porcentaje}%` }}
/>
</div>
<ul className="mt-2 space-y-1">
{reglas.map((regla) => (
<li
key={regla.label}
className={`text-xs ${
regla.cumple ? 'text-green-400' : 'text-gray-500'
}`}
>
{regla.cumple ? '[OK]' : '[ ]'} {regla.label}
</li>
))}
</ul>
</div>
)
}
// Uso en el formulario
function FormularioRegistro() {
const { register, handleSubmit, watch, formState: { errors } } = useForm<RegistroForm>({
resolver: zodResolver(registroSchema),
})
const passwordActual = watch('password', '')
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{/* ... otros campos ... */}
<Campo label="contraseña" error={errors.password?.message}>
<input {...register('password')} type="password" />
<IndicadorFuerza password={passwordActual} />
</Campo>
{/* ... más campos ... */}
</form>
)
}Errores personalizados y mensajes en español
Mensajes inline por campo
La forma más directa es pasar el mensaje como segundo argumento:
const contactoSchema = z.object({
nombre: z
.string({ required_error: 'El nombre es obligatorio' })
.min(2, 'El nombre debe tener al menos 2 caracteres')
.max(100, 'El nombre es demasiado largo'),
email: z
.string({ required_error: 'El email es obligatorio' })
.email('Ingresa un email válido'),
telefono: z
.string()
.regex(
/^\+?[1-9]\d{7,14}$/,
'Ingresa un telefono válido (ejemplo: +521234567890)'
)
.optional(),
mensaje: z
.string({ required_error: 'El mensaje es obligatorio' })
.min(10, 'El mensaje debe tener al menos 10 caracteres')
.max(1000, 'El mensaje no puede exceder 1000 caracteres'),
})Error map global en español
Si quieres mensajes en español en toda tu aplicación sin repetirlos en cada campo:
import { z } from 'zod'
const mensajesEspanol: z.ZodErrorMap = (issue, ctx) => {
switch (issue.code) {
case z.ZodIssueCode.invalid_type:
if (issue.expected === 'string') {
return { message: 'Este campo debe ser texto' }
}
if (issue.expected === 'number') {
return { message: 'Este campo debe ser un número' }
}
return { message: `Se esperaba ${issue.expected}, se recibio ${issue.received}` }
case z.ZodIssueCode.too_small:
if (issue.type === 'string') {
return { message: `Debe tener al menos ${issue.minimum} caracteres` }
}
if (issue.type === 'number') {
return { message: `Debe ser mayor o igual a ${issue.minimum}` }
}
if (issue.type === 'array') {
return { message: `Debe tener al menos ${issue.minimum} elementos` }
}
return { message: `Valor demasiado pequeño` }
case z.ZodIssueCode.too_big:
if (issue.type === 'string') {
return { message: `No puede tener más de ${issue.maximum} caracteres` }
}
if (issue.type === 'number') {
return { message: `Debe ser menor o igual a ${issue.maximum}` }
}
return { message: `Valor demasiado grande` }
case z.ZodIssueCode.invalid_string:
if (issue.validation === 'email') {
return { message: 'Ingresa un email válido' }
}
if (issue.validation === 'url') {
return { message: 'Ingresa una URL válida' }
}
return { message: 'Formato invalido' }
case z.ZodIssueCode.custom:
return { message: issue.message ?? 'Valor invalido' }
default:
return { message: ctx.defaultError }
}
}
// Configurar globalmente al inicio de tu app
z.setErrorMap(mensajesEspanol)Coloca z.setErrorMap(mensajesEspanol) en tu archivo principal (por ejemplo, en el layout raiz o en un provider) para que aplique a todos los schemas.
Componente de error reutilizable
interface ErrorCampoProps {
mensaje?: string
}
function ErrorCampo({ mensaje }: ErrorCampoProps) {
if (!mensaje) return null
return (
<p
className="mt-1 text-sm text-red-400"
role="alert"
aria-live="polite"
>
{mensaje}
</p>
)
}
// Uso
<ErrorCampo mensaje={errors.email?.message} />Los atributos role="alert" y aria-live="polite" aseguran que los lectores de pantalla anuncien los errores, mejorando la accesibilidad.
Formulario de contacto completo
Un segundo ejemplo para consolidar el patron:
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const contactoSchema = z.object({
nombre: z
.string()
.min(2, 'mínimo 2 caracteres')
.max(100, 'máximo 100 caracteres')
.trim(),
email: z
.string()
.email('Email invalido')
.toLowerCase(),
asunto: z.enum(
['consulta', 'soporte', 'cotización', 'otro'],
{ errorMap: () => ({ message: 'Selecciona un asunto' }) }
),
mensaje: z
.string()
.min(10, 'El mensaje debe tener al menos 10 caracteres')
.max(2000, 'El mensaje no puede exceder 2000 caracteres'),
presupuesto: z
.number({ invalid_type_error: 'Ingresa un número válido' })
.min(0, 'El presupuesto no puede ser negativo')
.optional(),
})
type ContactoForm = z.infer<typeof contactoSchema>
export function FormularioContacto() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
reset,
} = useForm<ContactoForm>({
resolver: zodResolver(contactoSchema),
defaultValues: {
nombre: '',
email: '',
asunto: undefined,
mensaje: '',
},
})
const onSubmit = async (datos: ContactoForm) => {
const response = await fetch('/api/contacto', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
})
if (!response.ok) {
throw new Error('Error al enviar el formulario')
}
reset()
}
if (isSubmitSuccessful) {
return (
<div className="text-center py-8">
<h3 className="text-lg font-medium text-green-400">
Mensaje enviado correctamente
</h3>
<p className="text-gray-400 mt-2">
Te responderemos lo antes posible.
</p>
</div>
)
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="max-w-lg mx-auto space-y-4"
>
<div>
<label className="block text-sm font-medium text-gray-200 mb-1">
Nombre
</label>
<input
{...register('nombre')}
type="text"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700
rounded-md text-white focus:ring-2 focus:ring-blue-500"
/>
{errors.nombre && (
<p className="mt-1 text-sm text-red-400">{errors.nombre.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-1">
Email
</label>
<input
{...register('email')}
type="email"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700
rounded-md text-white focus:ring-2 focus:ring-blue-500"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-400">{errors.email.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-1">
Asunto
</label>
<select
{...register('asunto')}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700
rounded-md text-white focus:ring-2 focus:ring-blue-500"
>
<option value="">Selecciona un asunto</option>
<option value="consulta">Consulta general</option>
<option value="soporte">Soporte técnico</option>
<option value="cotización">Cotización</option>
<option value="otro">Otro</option>
</select>
{errors.asunto && (
<p className="mt-1 text-sm text-red-400">{errors.asunto.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-1">
Presupuesto (opcional)
</label>
<input
{...register('presupuesto', { valueAsNumber: true })}
type="number"
placeholder="0.00"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700
rounded-md text-white focus:ring-2 focus:ring-blue-500"
/>
{errors.presupuesto && (
<p className="mt-1 text-sm text-red-400">{errors.presupuesto.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-1">
Mensaje
</label>
<textarea
{...register('mensaje')}
rows={5}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700
rounded-md text-white focus:ring-2 focus:ring-blue-500 resize-none"
/>
{errors.mensaje && (
<p className="mt-1 text-sm text-red-400">{errors.mensaje.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700
disabled:opacity-50 text-white font-medium rounded-md"
>
{isSubmitting ? 'Enviando...' : 'Enviar mensaje'}
</button>
</form>
)
}Si después de validar el formulario necesitas enviar un email de confirmación, el flujo sería: validar con Zod en el frontend, enviar al servidor, validar de nuevo con Zod, y disparar el email con Resend.
Server-side validation con Server Actions
La validación del cliente mejora la UX, pero la validación del servidor es la que protege tus datos. Con Server Actions de NextJS puedes reutilizar exactamente el mismo schema de Zod:
// lib/schemas/registro.ts
// Schema compartido entre cliente y servidor
import { z } from 'zod'
export const registroSchema = z.object({
nombre: z
.string()
.min(2, 'mínimo 2 caracteres')
.max(50, 'máximo 50 caracteres')
.trim(),
email: z
.string()
.email('Email invalido')
.toLowerCase(),
password: z
.string()
.min(8, 'mínimo 8 caracteres')
.regex(/[A-Z]/, 'Al menos una mayuscula')
.regex(/[a-z]/, 'Al menos una minuscula')
.regex(/[0-9]/, 'Al menos un número'),
confirmarPassword: z.string(),
}).refine(
(data) => data.password === data.confirmarPassword,
{
message: 'Las contrasenas no coinciden',
path: ['confirmarPassword'],
}
)
export type RegistroForm = z.infer<typeof registroSchema>// app/actions/registro.ts
'use server'
import { registroSchema } from '@/lib/schemas/registro'
type ActionResult =
| { success: true; message: string }
| { success: false; errors: Record<string, string[]> }
export async function registrarUsuario(
formData: unknown
): Promise<ActionResult> {
// Validar en el servidor con el mismo schema
const resultado = registroSchema.safeParse(formData)
if (!resultado.success) {
// Convertir errores de Zod a un formato simple
const errores: Record<string, string[]> = {}
for (const error of resultado.error.errors) {
const campo = error.path.join('.')
if (!errores[campo]) {
errores[campo] = []
}
errores[campo].push(error.message)
}
return { success: false, errors: errores }
}
const { nombre, email, password } = resultado.data
// Verificar que el email no exista
const emailExiste = await verificarEmailExiste(email)
if (emailExiste) {
return {
success: false,
errors: { email: ['Este email ya esta registrado'] },
}
}
// Hashear password y crear usuario
const hashedPassword = await hashPassword(password)
await crearUsuario({ nombre, email, password: hashedPassword })
return { success: true, message: 'Cuenta creada correctamente' }
}
// Funciones auxiliares (implementa según tu stack)
async function verificarEmailExiste(email: string): Promise<boolean> {
// Consulta a tu base de datos
return false
}
async function hashPassword(password: string): Promise<string> {
// Usa bcrypt o argon2
const bcrypt = await import('bcryptjs')
return bcrypt.hash(password, 12)
}
async function crearUsuario(datos: {
nombre: string
email: string
password: string
}) {
// Inserta en tu base de datos
}// Componente que usa el Server Action
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { registroSchema, type RegistroForm } from '@/lib/schemas/registro'
import { registrarUsuario } from '@/app/actions/registro'
export function FormularioRegistroConServer() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<RegistroForm>({
resolver: zodResolver(registroSchema),
})
const onSubmit = async (datos: RegistroForm) => {
// El schema ya válido en el cliente via zodResolver
// Ahora validamos también en el servidor
const resultado = await registrarUsuario(datos)
if (!resultado.success) {
// Mostrar errores del servidor en los campos correspondientes
for (const [campo, mensajes] of Object.entries(resultado.errors)) {
setError(campo as keyof RegistroForm, {
type: 'server',
message: mensajes[0],
})
}
return
}
// Registro exitoso
window.location.href = '/bienvenido'
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<input {...register('nombre')} type="text" placeholder="Nombre" />
{errors.nombre && <p>{errors.nombre.message}</p>}
</div>
<div>
<input {...register('email')} type="email" placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<input {...register('password')} type="password" placeholder="contraseña" />
{errors.password && <p>{errors.password.message}</p>}
</div>
<div>
<input
{...register('confirmarPassword')}
type="password"
placeholder="Confirmar contraseña"
/>
{errors.confirmarPassword && <p>{errors.confirmarPassword.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creando cuenta...' : 'Registrarse'}
</button>
</form>
)
}Un schema, dos validaciones
El schema registroSchema se importa en el componente cliente (para zodResolver) y en el Server Action (para safeParse). Las reglas se definen una sola vez y se aplican en ambos lados automáticamente.
Formularios con arrays dinámicos
Para formularios donde el usuario puede agregar o quitar campos (como experiencia laboral, habilidades, etc.):
'use client'
import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const perfilSchema = z.object({
nombre: z.string().min(2, 'mínimo 2 caracteres'),
habilidades: z
.array(
z.object({
nombre: z.string().min(1, 'Ingresa el nombre de la habilidad'),
nivel: z.enum(['básico', 'intermedio', 'avanzado'], {
errorMap: () => ({ message: 'Selecciona un nivel' }),
}),
})
)
.min(1, 'Agrega al menos una habilidad')
.max(10, 'máximo 10 habilidades'),
})
type PerfilForm = z.infer<typeof perfilSchema>
export function FormularioPerfil() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<PerfilForm>({
resolver: zodResolver(perfilSchema),
defaultValues: {
nombre: '',
habilidades: [{ nombre: '', nivel: 'básico' }],
},
})
const { fields, append, remove } = useFieldArray({
control,
name: 'habilidades',
})
const onSubmit = (datos: PerfilForm) => {
console.log(datos)
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-200">
Nombre
</label>
<input
{...register('nombre')}
type="text"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-md text-white"
/>
{errors.nombre && (
<p className="text-sm text-red-400">{errors.nombre.message}</p>
)}
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-200 mb-2">
Habilidades
</label>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2 mb-2">
<div className="flex-1">
<input
{...register(`habilidades.${index}.nombre`)}
placeholder="Ejemplo: TypeScript"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700
rounded-md text-white"
/>
{errors.habilidades?.[index]?.nombre && (
<p className="text-xs text-red-400">
{errors.habilidades[index].nombre?.message}
</p>
)}
</div>
<select
{...register(`habilidades.${index}.nivel`)}
className="px-3 py-2 bg-gray-800 border border-gray-700
rounded-md text-white"
>
<option value="básico">básico</option>
<option value="intermedio">Intermedio</option>
<option value="avanzado">Avanzado</option>
</select>
<button
type="button"
onClick={() => remove(index)}
disabled={fields.length === 1}
className="px-3 py-2 bg-red-600 hover:bg-red-700
disabled:opacity-30 text-white rounded-md"
>
Quitar
</button>
</div>
))}
{errors.habilidades?.root && (
<p className="text-sm text-red-400">
{errors.habilidades.root.message}
</p>
)}
<button
type="button"
onClick={() => append({ nombre: '', nivel: 'básico' })}
disabled={fields.length >= 10}
className="mt-2 px-4 py-2 bg-gray-700 hover:bg-gray-600
text-white rounded-md text-sm"
>
+ Agregar habilidad
</button>
</div>
<button
type="submit"
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700
text-white font-medium rounded-md"
>
Guardar perfil
</button>
</form>
)
}Schemas reutilizables y composables
En proyectos reales, tendrás schemas compartidos entre formularios. Organiza tus schemas de forma modular:
// lib/schemas/base.ts
import { z } from 'zod'
// Schemas atomicos reutilizables
export const emailSchema = z
.string()
.email('Email invalido')
.toLowerCase()
export const passwordSchema = z
.string()
.min(8, 'mínimo 8 caracteres')
.regex(/[A-Z]/, 'Al menos una mayuscula')
.regex(/[a-z]/, 'Al menos una minuscula')
.regex(/[0-9]/, 'Al menos un número')
export const nombreSchema = z
.string()
.min(2, 'mínimo 2 caracteres')
.max(100, 'máximo 100 caracteres')
.trim()
export const telefonoSchema = z
.string()
.regex(/^\+?[1-9]\d{7,14}$/, 'Telefono invalido')
.optional()// lib/schemas/registro.ts
import { z } from 'zod'
import { emailSchema, passwordSchema, nombreSchema } from './base'
export const registroSchema = z.object({
nombre: nombreSchema,
email: emailSchema,
password: passwordSchema,
confirmarPassword: z.string(),
}).refine(
(data) => data.password === data.confirmarPassword,
{ message: 'Las contrasenas no coinciden', path: ['confirmarPassword'] }
)
export type RegistroForm = z.infer<typeof registroSchema>// lib/schemas/login.ts
import { z } from 'zod'
import { emailSchema } from './base'
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1, 'Ingresa tu contraseña'),
recordarme: z.boolean().default(false),
})
export type LoginForm = z.infer<typeof loginSchema>// lib/schemas/perfil.ts
import { z } from 'zod'
import { nombreSchema, telefonoSchema } from './base'
export const perfilSchema = z.object({
nombre: nombreSchema,
telefono: telefonoSchema,
bio: z.string().max(500, 'máximo 500 caracteres').optional(),
sitioWeb: z.string().url('URL invalida').optional().or(z.literal('')),
})
export type PerfilForm = z.infer<typeof perfilSchema>Estructura de archivos
Mantener los schemas en lib/schemas/ separados de los componentes permite reutilizarlos tanto en formularios del cliente como en Server Actions, API routes y middlewares del servidor.
Mejores prácticas
1. Nunca confies en el frontend
La validación del cliente es para UX. La validación del servidor es para seguridad. Cualquiera puede desactivar JavaScript, modificar peticiones con herramientas como Postman, o enviar datos directamente a tu API con fetch. válida siempre en ambos lados.
2. Usa el mismo schema en cliente y servidor
// lib/schemas/registro.ts -- usado en AMBOS lados
export const registroSchema = z.object({ /* ... */ })
// Cliente: zodResolver(registroSchema)
// Servidor: registroSchema.safeParse(datos)Esto elimina la posibilidad de que las reglas del cliente y del servidor se desfasen.
3. Prefiere safeParse sobre parse en el servidor
// MAL: lanza una excepción que tienes que atrapar
try {
const datos = schema.parse(input)
} catch (error) {
// Manejar errores de Zod mezclados con otros errores
}
// BIEN: retorna un resultado discriminado
const resultado = schema.safeParse(input)
if (!resultado.success) {
return { errors: resultado.error.flatten().fieldErrors }
}
// resultado.data tiene los datos validados y tipados4. Normaliza datos en el schema
const registroSchema = z.object({
email: z.string().email().toLowerCase().trim(),
nombre: z.string().trim(),
// Zod transforma los datos al validar
// No necesitas hacerlo después
})5. Protege datos sensibles en formularios
Los datos de formularios viajan por la red. Asegúrate de:
- Usar HTTPS siempre
- No loggear contrasenas ni datos sensibles
- Hashear contrasenas en el servidor antes de guardarlas
- Sanitizar inputs para prevenir XSS e inyección SQL
Si tu aplicación maneja datos de usuarios a través de formularios, herramientas como datahogo pueden escanear tu repositorio para detectar si algún dato sensible quedo expuesto en tu código o logs accidentalmente.
6. Configura defaultValues
// BIEN: siempre define valores por defecto
useForm<RegistroForm>({
resolver: zodResolver(registroSchema),
defaultValues: {
nombre: '',
email: '',
password: '',
confirmarPassword: '',
},
})
// MAL: sin defaultValues puede causar warnings de
// "uncontrolled to controlled" en React
useForm<RegistroForm>({
resolver: zodResolver(registroSchema),
})7. Maneja errores del servidor en el formulario
const onSubmit = async (datos: RegistroForm) => {
const resultado = await registrarUsuario(datos)
if (!resultado.success) {
// Muestra errores del servidor en los campos correctos
for (const [campo, mensajes] of Object.entries(resultado.errors)) {
setError(campo as keyof RegistroForm, {
type: 'server',
message: mensajes[0],
})
}
}
}8. Usa mode apropiado
// Formularios simples: validar al enviar
useForm({ mode: 'onSubmit' })
// Formularios con validación async: validar al salir del campo
useForm({ mode: 'onBlur' })
// Formularios que necesitan feedback inmediato
useForm({ mode: 'onChange' }) // Cuidado: puede afectar rendimientoTesting de schemas
No olvides testear tus schemas de Zod. Son lógica de negocio:
// __tests__/schemas/registro.test.ts
import { registroSchema } from '@/lib/schemas/registro'
describe('registroSchema', () => {
it('acepta datos válidos', () => {
const resultado = registroSchema.safeParse({
nombre: 'Ana Garcia',
email: 'ana@mail.com',
password: 'MiPassword1',
confirmarPassword: 'MiPassword1',
})
expect(resultado.success).toBe(true)
})
it('rechaza email invalido', () => {
const resultado = registroSchema.safeParse({
nombre: 'Ana',
email: 'no-es-email',
password: 'MiPassword1',
confirmarPassword: 'MiPassword1',
})
expect(resultado.success).toBe(false)
if (!resultado.success) {
const errores = resultado.error.flatten().fieldErrors
expect(errores.email).toBeDefined()
}
})
it('rechaza contraseña corta', () => {
const resultado = registroSchema.safeParse({
nombre: 'Ana',
email: 'ana@mail.com',
password: '123',
confirmarPassword: '123',
})
expect(resultado.success).toBe(false)
})
it('rechaza contrasenas que no coinciden', () => {
const resultado = registroSchema.safeParse({
nombre: 'Ana',
email: 'ana@mail.com',
password: 'MiPassword1',
confirmarPassword: 'OtraPassword1',
})
expect(resultado.success).toBe(false)
if (!resultado.success) {
const errores = resultado.error.flatten().fieldErrors
expect(errores.confirmarPassword).toBeDefined()
}
})
it('normaliza el email a minusculas', () => {
const resultado = registroSchema.safeParse({
nombre: 'Ana',
email: 'ANA@Mail.COM',
password: 'MiPassword1',
confirmarPassword: 'MiPassword1',
})
expect(resultado.success).toBe(true)
if (resultado.success) {
expect(resultado.data.email).toBe('ana@mail.com')
}
})
it('recorta espacios del nombre', () => {
const resultado = registroSchema.safeParse({
nombre: ' Ana Garcia ',
email: 'ana@mail.com',
password: 'MiPassword1',
confirmarPassword: 'MiPassword1',
})
expect(resultado.success).toBe(true)
if (resultado.success) {
expect(resultado.data.nombre).toBe('Ana Garcia')
}
})
})Referencia rápida
| Concepto | código |
|---|---|
| Schema básico | z.object({ campo: z.string() }) |
| Conectar con form | zodResolver(schema) |
| Registrar input | {...register('campo')} |
| Mostrar error | errors.campo?.message |
| Validación custom | .refine(fn, mensaje) |
| múltiples errores | .superRefine((data, ctx) => {...}) |
| Validación async | .refine(async (val) => {...}) |
| Tipo inferido | z.infer<typeof schema> |
| Validar en servidor | schema.safeParse(datos) |
| Errores del servidor | setError('campo', { message }) |
| Campos dinámicos | useFieldArray({ control, name }) |
| Number input | register('campo', { valueAsNumber: true }) |
Recursos externos
- React Hook Form - Documentación oficial -- la referencia completa de la API, ejemplos y guias.
- Zod - Documentación oficial -- toda la API de Zod con ejemplos detallados.
Preguntas frecuentes
¿Puedo usar Zod con React Hook Form sin TypeScript?
Si, pero pierdes la mitad del beneficio. Sin TypeScript no tienes z.infer para generar tipos automaticos, y no tendrás autocompletado ni verificación en tiempo de compilación. Si tu proyecto es JavaScript puro, Zod igual funciona para validación en runtime, pero la experiencia es mejor con TypeScript.
¿Qué pasa si un campo tiene errores del cliente y del servidor al mismo tiempo?
React Hook Form muestra un error a la vez por campo. Los errores de zodResolver (cliente) se evaluan primero. Si el formulario pasa la validación del cliente y el servidor retorna un error, usas setError() para mostrarlo. En el proximo submit, zodResolver vuelve a evaluar primero.
¿Cómo válido archivos (file uploads) con Zod?
Zod soporta validación de archivos con z.instanceof(File):
const schema = z.object({
avatar: z
.instanceof(File)
.refine(file => file.size <= 5 * 1024 * 1024, 'máximo 5MB')
.refine(
file => ['image/jpeg', 'image/png', 'image/webp'].includes(file.type),
'Solo JPG, PNG o WebP'
),
})¿React Hook Form es necesario o puedo usar Zod solo?
Puedes usar Zod sin React Hook Form, pero tendrás que manejar manualmente el estado del formulario, los re-renders, el focus, el dirty tracking y los errores por campo. React Hook Form resuelve todo eso con rendimiento optimizado. Para formularios con más de 2-3 campos, la combinación vale la pena.
¿Cómo reseteo el formulario después de un submit exitoso?
Usa el método reset() de React Hook Form:
const { reset } = useForm<MiForm>({ resolver: zodResolver(schema) })
const onSubmit = async (datos: MiForm) => {
await enviarDatos(datos)
reset() // Regresa a defaultValues
// O con valores especificos:
reset({ nombre: '', email: '' })
}Preguntas frecuentes
¿Por qué usar Zod con React Hook Form en lugar de validación manual?
Zod con React Hook Form te da un único schema de validación que funciona en el cliente y el servidor, genera tipos TypeScript automáticamente, y elimina la necesidad de escribir validaciones manuales campo por campo. además, React Hook Form minimiza re-renders innecesarios, lo que mejora el rendimiento de formularios complejos.
¿Cómo mostrar mensajes de error en español con Zod?
Puedes pasar mensajes personalizados como segundo argumento a cada validador de Zod, por ejemplo z.string().min(8, 'mínimo 8 caracteres'). también puedes usar z.setErrorMap para definir mensajes globales en español que apliquen a todos los schemas de tu aplicación.
¿Cómo validar que dos campos coincidan con Zod y React Hook Form?
Usa el método .refine() o .superRefine() en el schema de Zod a nivel de objeto. Por ejemplo, para verificar que password y confirmarPassword coincidan, aplica .refine(data => data.password === data.confirmarPassword, { message: 'Las contrasenas no coinciden', path: ['confirmarPassword'] }).
¿Es necesario validar en el servidor si ya válido en el cliente?
Si, siempre. La validación del cliente es solo UX, no seguridad. Cualquier persona puede desactivar JavaScript o enviar peticiones directas a tu API. La validación del servidor con el mismo schema de Zod garantiza que los datos sean correctos antes de procesarlos, sin importar como llegaron.
¿Cómo hacer validación asíncrona con Zod en React Hook Form?
Usa .refine() con una función async, por ejemplo para verificar si un email ya existe en la base de datos. Configura el mode del formulario como 'onBlur' para que la validación asíncrona se ejecute cuando el usuario sale del campo, no en cada tecla presionada.
Articulos relacionados
Zod Avanzado: Discriminated Unions, Transforms y Pipes
Patrones avanzados de Zod: discriminated unions, transforms, pipes, preprocess, y como validar datos complejos en TypeScript con schemas reutilizables.
tRPC + Next.js: APIs Type-Safe sin REST
Implementa tRPC en Next.js para APIs 100% type-safe. Sin schemas de API, sin fetch manual, sin types duplicados. End-to-end type safety con TypeScript.
Webhooks en Next.js: Recibe y Procesa Eventos
Implementa webhooks en Next.js para recibir eventos de Stripe, GitHub, Clerk y otros servicios. Verificación de firmas, tipado y manejo de errores.