Validacion de Formularios con Zod y React Hook Form: Guia Completa
Aprende a validar formularios en React con Zod y React Hook Form. Setup completo, validaciones custom, mensajes en espanol, Server Actions y mejores practicas de seguridad.
Validacion de Formularios con Zod y React Hook Form
La validacion de formularios con Zod y React Hook Form es el estandar actual para aplicaciones React con TypeScript. Un solo schema define las reglas de validacion, los tipos de TypeScript y los mensajes de error. Sin duplicar logica, 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 guia te muestra como hacerlo correctamente.
Por que importa validar formularios bien
Un formulario sin validacion (o con validacion solo en el frontend) es una invitacion a problemas:
// Esto es lo que pasa sin validacion real
function RegistroBasico() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
// Sin validacion: confiando ciegamente en el usuario
const datos = {
nombre: formData.get('nombre'),
email: formData.get('email'),
password: formData.get('password'),
}
// Que pasa si nombre esta vacio?
// Que pasa si email no es un email?
// Que 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 utiles para el usuario
- No hay validacion en el servidor
- No hay tipos de TypeScript -- todo es
FormDataEntryValue | null - No hay forma de reutilizar las reglas de validacion
required no es validacion
Los atributos HTML como required y type="email" son ayudas de UX del navegador, no validacion real. Cualquiera puede abrir DevTools, quitar el atributo required, y enviar el formulario vacio. Nunca confies en la validacion del cliente como unica barrera.
Setup: instalar las dependencias
Necesitas tres paquetes:
npm install zod react-hook-form @hookform/resolvers| Paquete | Para que sirve |
|---|---|
zod | Definir schemas de validacion con tipos automaticos |
react-hook-form | Manejar el estado del formulario sin re-renders innecesarios |
@hookform/resolvers | Conectar Zod (u otras librerias) con React Hook Form |
Compatibilidad
Esta guia usa React 18+, TypeScript 5+, y las versiones mas recientes de Zod (3.x) y React Hook Form (7.x). Si usas NextJS 14+, todo funciona sin configuracion adicional.
Crear un schema de validacion con Zod
Si ya conoces Zod para validar datos, este paso te va a resultar familiar. Si no, aca va un resumen rapido.
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 mas de 50 caracteres')
.trim(),
email: z
.string()
.email('Ingresa un email valido')
.toLowerCase(),
password: z
.string()
.min(8, 'La contrasena 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 numero'),
confirmarPassword: z
.string()
.min(1, 'Confirma tu contrasena'),
}).refine(
(data) => data.password === data.confirmarPassword,
{
message: 'Las contrasenas no coinciden',
path: ['confirmarPassword'],
}
)
// El tipo se genera automaticamente del schema
type RegistroForm = z.infer<typeof registroSchema>
// {
// nombre: string
// email: string
// password: string
// confirmarPassword: string
// }Lo que hace cada parte:
.min(),.max(): limites de longitud con mensajes custom.trim(): elimina espacios al inicio y final.toLowerCase(): normaliza el email a minusculas.regex(): validaciones con expresiones regulares.refine(): validacion 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 duplicacion 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 mas de 50 caracteres')
.trim(),
email: z
.string()
.email('Ingresa un email valido')
.toLowerCase(),
password: z
.string()
.min(8, 'La contrasena 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 numero'),
confirmarPassword: z
.string()
.min(1, 'Confirma tu contrasena'),
}).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 aqui */}
</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 validacion de ZodhandleSubmitsolo ejecutaonSubmitsi la validacion pasanoValidatedesactiva la validacion 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 mas de 50 caracteres')
.trim(),
email: z
.string()
.email('Ingresa un email valido')
.toLowerCase(),
password: z
.string()
.min(8, 'La contrasena 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 numero')
.regex(/[^A-Za-z0-9]/, 'Debe contener al menos un caracter especial'),
confirmarPassword: z
.string()
.min(1, 'Confirma tu contrasena'),
aceptaTerminos: z
.boolean()
.refine(val => val === true, {
message: 'Debes aceptar los terminos 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 exito
} 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="Contrasena" error={errors.password?.message}>
<input
{...register('password')}
type="password"
placeholder="Minimo 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 contrasena" error={errors.confirmarPassword?.message}>
<input
{...register('confirmarPassword')}
type="password"
placeholder="Repite tu contrasena"
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="terminos"
className="mt-1"
/>
<label htmlFor="terminos" className="text-sm text-gray-300">
Acepto los terminos 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:
- Valida 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 despues de un registro exitoso
- Usa TypeScript completo en todo el flujo
Validaciones custom: password match, email unico (async)
Validacion de coincidencia de contrasenas
Ya la vimos con .refine(), pero hay una alternativa mas flexible con .superRefine():
const registroSchema = z.object({
password: z.string().min(8, 'Minimo 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 mas validaciones aqui
if (data.password.includes(data.confirmarPassword.slice(0, 3))) {
// Validacion adicional si la necesitas
}
})La diferencia entre .refine() y .superRefine():
.refine(): retornatrue/false, un solo error.superRefine(): usactx.addIssue(), permite multiples errores con contexto completo
Validacion asincrona: verificar email unico
const registroSchema = z.object({
nombre: z.string().min(2, 'Minimo 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, 'Minimo 8 caracteres'),
})Para que la validacion async funcione bien con React Hook Form, configura el mode:
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValidating },
} = useForm<RegistroForm>({
resolver: zodResolver(registroSchema),
mode: 'onBlur', // Valida cuando el usuario sale del campo
})| Mode | Cuando valida | 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, despues en cada cambio | Balance UX/rendimiento |
all | En todos los eventos | Maximo feedback |
Validacion async con cuidado
Las validaciones asincronas hacen una peticion HTTP cada vez que se ejecutan. Usa mode: 'onBlur' en lugar de onChange para evitar cientos de peticiones mientras el usuario escribe. Tambien considera agregar debounce.
Validacion async con debounce
import { useCallback, useRef } from 'react'
// Hook custom para debounce de validacion
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
}Multiples validaciones en un campo
const passwordSchema = z
.string()
.min(8, 'Minimo 8 caracteres')
.max(100, 'Maximo 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 numero' },
{ 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 multiples errores, mostrarlos todos */}
<p className="text-sm text-red-400">
{errors.password.message}
</p>
</div>
)}Indicador de fuerza de contrasena
Un patron comun en formularios de registro:
function IndicadorFuerza({ password }: { password: string }) {
const reglas = [
{ label: 'Minimo 8 caracteres', cumple: password.length >= 8 },
{ label: 'Una mayuscula', cumple: /[A-Z]/.test(password) },
{ label: 'Una minuscula', cumple: /[a-z]/.test(password) },
{ label: 'Un numero', 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="Contrasena" error={errors.password?.message}>
<input {...register('password')} type="password" />
<IndicadorFuerza password={passwordActual} />
</Campo>
{/* ... mas campos ... */}
</form>
)
}Errores personalizados y mensajes en espanol
Mensajes inline por campo
La forma mas 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 valido'),
telefono: z
.string()
.regex(
/^\+?[1-9]\d{7,14}$/,
'Ingresa un telefono valido (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 espanol
Si quieres mensajes en espanol en toda tu aplicacion 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 numero' }
}
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 pequeno` }
case z.ZodIssueCode.too_big:
if (issue.type === 'string') {
return { message: `No puede tener mas 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 valido' }
}
if (issue.validation === 'url') {
return { message: 'Ingresa una URL valida' }
}
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, 'Minimo 2 caracteres')
.max(100, 'Maximo 100 caracteres')
.trim(),
email: z
.string()
.email('Email invalido')
.toLowerCase(),
asunto: z.enum(
['consulta', 'soporte', 'cotizacion', '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 numero valido' })
.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 tecnico</option>
<option value="cotizacion">Cotizacion</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 despues de validar el formulario necesitas enviar un email de confirmacion, el flujo seria: 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 validacion del cliente mejora la UX, pero la validacion 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, 'Minimo 2 caracteres')
.max(50, 'Maximo 50 caracteres')
.trim(),
email: z
.string()
.email('Email invalido')
.toLowerCase(),
password: z
.string()
.min(8, 'Minimo 8 caracteres')
.regex(/[A-Z]/, 'Al menos una mayuscula')
.regex(/[a-z]/, 'Al menos una minuscula')
.regex(/[0-9]/, 'Al menos un numero'),
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 segun 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 valido en el cliente via zodResolver
// Ahora validamos tambien 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="Contrasena" />
{errors.password && <p>{errors.password.message}</p>}
</div>
<div>
<input
{...register('confirmarPassword')}
type="password"
placeholder="Confirmar contrasena"
/>
{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 automaticamente.
Formularios con arrays dinamicos
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, 'Minimo 2 caracteres'),
habilidades: z
.array(
z.object({
nombre: z.string().min(1, 'Ingresa el nombre de la habilidad'),
nivel: z.enum(['basico', 'intermedio', 'avanzado'], {
errorMap: () => ({ message: 'Selecciona un nivel' }),
}),
})
)
.min(1, 'Agrega al menos una habilidad')
.max(10, 'Maximo 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: 'basico' }],
},
})
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="basico">Basico</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: 'basico' })}
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, tendras 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, 'Minimo 8 caracteres')
.regex(/[A-Z]/, 'Al menos una mayuscula')
.regex(/[a-z]/, 'Al menos una minuscula')
.regex(/[0-9]/, 'Al menos un numero')
export const nombreSchema = z
.string()
.min(2, 'Minimo 2 caracteres')
.max(100, 'Maximo 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 contrasena'),
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, 'Maximo 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 practicas
1. Nunca confies en el frontend
La validacion del cliente es para UX. La validacion del servidor es para seguridad. Cualquiera puede desactivar JavaScript, modificar peticiones con herramientas como Postman, o enviar datos directamente a tu API con fetch. Valida 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 excepcion 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 despues
})5. Protege datos sensibles en formularios
Los datos de formularios viajan por la red. Asegurate de:
- Usar HTTPS siempre
- No loggear contrasenas ni datos sensibles
- Hashear contrasenas en el servidor antes de guardarlas
- Sanitizar inputs para prevenir XSS e inyeccion SQL
Si tu aplicacion maneja datos de usuarios a traves de formularios, herramientas como datahogo pueden escanear tu repositorio para detectar si algun dato sensible quedo expuesto en tu codigo 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 validacion 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 logica de negocio:
// __tests__/schemas/registro.test.ts
import { registroSchema } from '@/lib/schemas/registro'
describe('registroSchema', () => {
it('acepta datos validos', () => {
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 contrasena 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 rapida
| Concepto | Codigo |
|---|---|
| Schema basico | z.object({ campo: z.string() }) |
| Conectar con form | zodResolver(schema) |
| Registrar input | {...register('campo')} |
| Mostrar error | errors.campo?.message |
| Validacion custom | .refine(fn, mensaje) |
| Multiples errores | .superRefine((data, ctx) => {...}) |
| Validacion async | .refine(async (val) => {...}) |
| Tipo inferido | z.infer<typeof schema> |
| Validar en servidor | schema.safeParse(datos) |
| Errores del servidor | setError('campo', { message }) |
| Campos dinamicos | useFieldArray({ control, name }) |
| Number input | register('campo', { valueAsNumber: true }) |
Recursos externos
- React Hook Form - Documentacion oficial -- la referencia completa de la API, ejemplos y guias.
- Zod - Documentacion 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 tendras autocompletado ni verificacion en tiempo de compilacion. Si tu proyecto es JavaScript puro, Zod igual funciona para validacion en runtime, pero la experiencia es mejor con TypeScript.
Que 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 validacion del cliente y el servidor retorna un error, usas setError() para mostrarlo. En el proximo submit, zodResolver vuelve a evaluar primero.
Como valido archivos (file uploads) con Zod?
Zod soporta validacion de archivos con z.instanceof(File):
const schema = z.object({
avatar: z
.instanceof(File)
.refine(file => file.size <= 5 * 1024 * 1024, 'Maximo 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 tendras 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 mas de 2-3 campos, la combinacion vale la pena.
Como reseteo el formulario despues de un submit exitoso?
Usa el metodo 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 que usar Zod con React Hook Form en lugar de validacion manual?
Zod con React Hook Form te da un unico schema de validacion que funciona en el cliente y el servidor, genera tipos TypeScript automaticamente, y elimina la necesidad de escribir validaciones manuales campo por campo. Ademas, React Hook Form minimiza re-renders innecesarios, lo que mejora el rendimiento de formularios complejos.
Como mostrar mensajes de error en espanol con Zod?
Puedes pasar mensajes personalizados como segundo argumento a cada validador de Zod, por ejemplo z.string().min(8, 'Minimo 8 caracteres'). Tambien puedes usar z.setErrorMap para definir mensajes globales en espanol que apliquen a todos los schemas de tu aplicacion.
Como validar que dos campos coincidan con Zod y React Hook Form?
Usa el metodo .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 valido en el cliente?
Si, siempre. La validacion del cliente es solo UX, no seguridad. Cualquier persona puede desactivar JavaScript o enviar peticiones directas a tu API. La validacion del servidor con el mismo schema de Zod garantiza que los datos sean correctos antes de procesarlos, sin importar como llegaron.
Como hacer validacion asincrona con Zod en React Hook Form?
Usa .refine() con una funcion 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 validacion asincrona 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. Verificacion de firmas, tipado y manejo de errores.