tutoriales·16 min de lectura

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:

tsx
// 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:

  • required y type="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:

bash
npm install zod react-hook-form @hookform/resolvers
PaquetePara que sirve
zodDefinir schemas de validacion con tipos automaticos
react-hook-formManejar el estado del formulario sin re-renders innecesarios
@hookform/resolversConectar 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:

typescript
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:

tsx
'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 Form
  • useForm<RegistroForm> tipa el formulario con el tipo inferido de Zod
  • register conecta cada input con React Hook Form
  • errors contiene los errores de validacion de Zod
  • handleSubmit solo ejecuta onSubmit si la validacion pasa
  • noValidate desactiva 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:

tsx
'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():

typescript
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(): retorna true/false, un solo error
  • .superRefine(): usa ctx.addIssue(), permite multiples errores con contexto completo

Validacion asincrona: verificar email unico

typescript
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:

tsx
const {
  register,
  handleSubmit,
  formState: { errors, isSubmitting, isValidating },
} = useForm<RegistroForm>({
  resolver: zodResolver(registroSchema),
  mode: 'onBlur', // Valida cuando el usuario sale del campo
})
ModeCuando validaRecomendado para
onSubmitSolo al enviar el formularioFormularios simples
onBlurCuando el usuario sale del campoValidaciones async
onChangeEn cada cambioFeedback instantaneo (cuidado con el rendimiento)
onTouchedLa primera vez al salir, despues en cada cambioBalance UX/rendimiento
allEn todos los eventosMaximo 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

tsx
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

typescript
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:

tsx
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:

typescript
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:

typescript
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

tsx
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:

tsx
'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:

typescript
// 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>
typescript
// 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
}
tsx
// 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.):

tsx
'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:

typescript
// 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()
typescript
// 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>
typescript
// 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>
typescript
// 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

typescript
// 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

typescript
// 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 tipados

4. Normaliza datos en el schema

typescript
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

typescript
// 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

typescript
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

typescript
// 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 rendimiento

Testing de schemas

No olvides testear tus schemas de Zod. Son logica de negocio:

typescript
// __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

ConceptoCodigo
Schema basicoz.object({ campo: z.string() })
Conectar con formzodResolver(schema)
Registrar input{...register('campo')}
Mostrar errorerrors.campo?.message
Validacion custom.refine(fn, mensaje)
Multiples errores.superRefine((data, ctx) => {...})
Validacion async.refine(async (val) => {...})
Tipo inferidoz.infer<typeof schema>
Validar en servidorschema.safeParse(datos)
Errores del servidorsetError('campo', { message })
Campos dinamicosuseFieldArray({ control, name })
Number inputregister('campo', { valueAsNumber: true })

Recursos externos

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):

typescript
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:

typescript
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: '' })
}
#react#zod#react-hook-form#validacion#formularios

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.