Skip to content
Intermedio
5 min
Frontend

Un solo schema, cero inconsistencias: formulario de registro con Zod 4 + React Hook Form

September 5, 2026 · Zod 4 para validar formularios en React · Stack: Next.js 14 (App Router), TypeScript, Zod 4, React Hook Form, @hookform/resolvers, Vitest, Tailwind CSS

Be the first to say if it helps

Pixel

Construimos un formulario de registro con validación en tiempo real (nombre, correo, contraseña con reglas de complejidad y confirmación) usando un único schema de Zod 4 que también se reutiliza para validar en el servidor.

You leave with

  • Un solo schema Zod puede validar tanto en el cliente (via zodResolver) como en el servidor (via safeParse), eliminando la duplicación de reglas de negocio.
  • superRefine permite validaciones cruzadas entre campos (como confirmar contraseña) que z.object nativo no soporta por sí solo.
  • Los errores devueltos por la API se pueden inyectar de vuelta al formulario con setError, logrando una UX consistente entre validación client-side y server-side.
  • Testear el schema de forma aislada con Vitest evita regresiones cuando cambian las reglas de negocio, sin necesidad de montar componentes React.

Chapters

  1. 0:00secure-signup-form-zod4
  2. 0:23El problema real
  3. 0:38Un schema, dos consumidores
  4. 1:10src/lib/schemas/signup-schema.ts
  5. 1:30src/app/signup/page.tsx
  6. 2:17secure-signup-form-zod4
  7. 2:32src/lib/schemas/signup-schema.test.ts
  8. 2:57src/app/api/signup/route.ts
  9. 3:14src/app/signup/page.tsx
  10. 4:06secure-signup-form-zod4
  11. 4:22Errores comunes
  12. 4:43Pro tip: fieldErrors compartido
  13. 5:03Recap: un schema, cero inconsistencias

You need

  • Conocimientos básicos de React y hooks
  • Haber usado formularios controlados o react-hook-form antes
  • Node.js 18+ instalado
  • Conceptos básicos de API routes en Next.js

Desarrolladores frontend/fullstack con experiencia básica en React que ya usan formularios controlados y quieren una forma robusta y tipada de validar datos compartiendo lógica entre cliente y servidor.

5 steps, one project that runs

Step 1Definir el schema de Zod con validación cruzada

Aprender a declarar reglas de validación reutilizables y usar superRefine para validar campos dependientes entre sí (password vs confirmPassword).

src/lib/schemas/signup-schema.ts

import { z } from "zod";

export const signupSchema = z
  .object({
    name: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
    email: z.string().email("Ingresa un correo válido"),
    password: z
      .string()
      .min(8, "La contraseña debe tener al menos 8 caracteres")
      .regex(/[A-Z]/, "Debe contener al menos una mayúscula")
      .regex(/[0-9]/, "Debe contener al menos un número"),
    confirmPassword: z.string(),
  })
  .superRefine((data, ctx) => {
    if (data.password !== data.confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Las contraseñas no coinciden",
        path: ["confirmPassword"],
      });
    }
  });

export type SignupFormValues = z.infer<typeof signupSchema>;

Step 2Construir el formulario con react-hook-form + zodResolver

Conectar el schema de Zod al formulario y ver la validación en tiempo real sin escribir lógica manual de errores.

src/app/signup/page.tsx

"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, SignupFormValues } from "@/lib/schemas/signup-schema";

export default function SignupPage() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupFormValues>({
    resolver: zodResolver(signupSchema),
    mode: "onBlur",
  });

  const onSubmit = (data: SignupFormValues) => {
    console.log("Datos válidos:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="max-w-sm mx-auto space-y-4 p-6">
      <div>
        <label className="block text-sm font-medium">Nombre</label>
        <input {...register("name")} className="border rounded w-full p-2" />
        {errors.name && <p className="text-red-500 text-sm">{errors.name.message}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium">Correo</label>
        <input {...register("email")} className="border rounded w-full p-2" />
        {errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium">Contraseña</label>
        <input type="password" {...register("password")} className="border rounded w-full p-2" />
        {errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium">Confirmar contraseña</label>
        <input type="password" {...register("confirmPassword")} className="border rounded w-full p-2" />
        {errors.confirmPassword && <p className="text-red-500 text-sm">{errors.confirmPassword.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting} className="bg-black text-white rounded px-4 py-2 w-full">
        {isSubmitting ? "Creando cuenta..." : "Crear cuenta"}
      </button>
    </form>
  );
}

command: npm run dev

En http://localhost:3000/signup se ve el formulario. Al escribir una contraseña sin mayúscula y salir del campo (blur), aparece de inmediato 'Debe contener al menos una mayúscula' sin necesidad de enviar el formulario.

Step 3Testear el schema de forma aislada con Vitest

Validar las reglas de negocio del schema sin depender de la UI, incluyendo el caso de contraseñas que no coinciden.

src/lib/schemas/signup-schema.test.ts

import { describe, it, expect } from "vitest";
import { signupSchema } from "./signup-schema";

describe("signupSchema", () => {
  it("rechaza cuando las contraseñas no coinciden", () => {
    const result = signupSchema.safeParse({
      name: "Ana",
      email: "ana@demo.com",
      password: "Password1",
      confirmPassword: "Password2",
    });

    expect(result.success).toBe(false);
    if (!result.success) {
      const confirmError = result.error.issues.find((i) => i.path[0] === "confirmPassword");
      expect(confirmError?.message).toBe("Las contraseñas no coinciden");
    }
  });

  it("acepta datos válidos", () => {
    const result = signupSchema.safeParse({
      name: "Ana",
      email: "ana@demo.com",
      password: "Password1",
      confirmPassword: "Password1",
    });

    expect(result.success).toBe(true);
  });
});

command: npx vitest run

Step 4Reutilizar el schema en el servidor

Aprender a validar el mismo payload en la API route con safeParse, garantizando que las reglas del cliente y del servidor nunca se desincronicen.

src/app/api/signup/route.ts

import { NextRequest, NextResponse } from "next/server";
import { signupSchema } from "@/lib/schemas/signup-schema";

const existingEmails = ["admin@demo.com"];

export async function POST(req: NextRequest) {
  const body = await req.json();
  const parsed = signupSchema.safeParse(body);

  if (!parsed.success) {
    const fieldErrors: Record<string, string> = {};
    parsed.error.issues.forEach((issue) => {
      fieldErrors[issue.path[0] as string] = issue.message;
    });
    return NextResponse.json({ fieldErrors }, { status: 400 });
  }

  if (existingEmails.includes(parsed.data.email)) {
    return NextResponse.json(
      { fieldErrors: { email: "Este correo ya está registrado" } },
      { status: 409 }
    );
  }

  return NextResponse.json({ message: "Usuario creado" }, { status: 201 });
}

Step 5Conectar el formulario a la API y propagar errores del servidor

Manejar la respuesta de la API con setError para que un error de negocio (email duplicado) se vea igual que un error de validación de Zod en el cliente.

src/app/signup/page.tsx

"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, SignupFormValues } from "@/lib/schemas/signup-schema";
import { useState } from "react";

export default function SignupPage() {
  const [serverError, setServerError] = useState<string | null>(null);
  const [success, setSuccess] = useState(false);

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<SignupFormValues>({
    resolver: zodResolver(signupSchema),
    mode: "onBlur",
  });

  const onSubmit = async (data: SignupFormValues) => {
    setServerError(null);

    const res = await fetch("/api/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });

    const result = await res.json();

    if (!res.ok) {
      if (result.fieldErrors) {
        Object.entries(result.fieldErrors).forEach(([field, message]) => {
          setError(field as keyof SignupFormValues, {
            type: "server",
            message: message as string,
          });
        });
      } else {
        setServerError(result.message ?? "Ocurrió un error inesperado");
      }
      return;
    }

    setSuccess(true);
  };

  if (success) {
    return <p className="text-green-600 font-medium text-center mt-10">¡Cuenta creada con éxito!</p>;
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="max-w-sm mx-auto space-y-4 p-6">
      <div>
        <label className="block text-sm font-medium">Nombre</label>
        <input {...register("name")} className="border rounded w-full p-2" />
        {errors.name && <p className="text-red-500 text-sm">{errors.name.message}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium">Correo</label>
        <input {...register("email")} className="border rounded w-full p-2" />
        {errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium">Contraseña</label>
        <input type="password" {...register("password")} className="border rounded w-full p-2" />
        {errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium">Confirmar contraseña</label>
        <input type="password" {...register("confirmPassword")} className="border rounded w-full p-2" />
        {errors.confirmPassword && <p className="text-red-500 text-sm">{errors.confirmPassword.message}</p>}
      </div>

      {serverError && <p className="text-red-600 text-sm">{serverError}</p>}

      <button type="submit" disabled={isSubmitting} className="bg-black text-white rounded px-4 py-2 w-full">
        {isSubmitting ? "Creando cuenta..." : "Crear cuenta"}
      </button>
    </form>
  );
}

command: npm run dev

Al enviar el formulario con el correo 'admin@demo.com', el input de correo muestra 'Este correo ya está registrado' (viene del servidor, no de Zod client-side). Al cambiar a un correo nuevo y reenviar, aparece el mensaje '¡Cuenta creada con éxito!'.

What breaks

¿Qué problema resuelve Un solo schema, cero inconsistencias: formulario de registro con Zod 4 + React Hook Form?

En la mayoría de apps React se duplica la lógica de validación entre el frontend (para UX) y el backend (para seguridad), lo que genera bugs cuando alguien actualiza una regla en un lado y se olvida del otro. Además, validar campos dependientes entre sí (como 'confirmar contraseña') suele hacerse a mano con lógica imperativa propensa a errores. Construimos un formulario de registro con validación en tiempo real (nombre, correo, contraseña con reglas de complejidad y confirmación) usando un único schema de Zod 4 que también se reutiliza para validar en el servidor.

¿Qué necesito saber antes de seguir esta clase?

Necesitas Conocimientos básicos de React y hooks, Haber usado formularios controlados o react-hook-form antes, Node.js 18+ instalado, Conceptos básicos de API routes en Next.js. La clase es de nivel intermedio y dura 5 minutos.

¿Qué stack se usa para Zod 4 para validar formularios en React?

El proyecto usa Next.js 14 (App Router), TypeScript, Zod 4, React Hook Form, @hookform/resolvers, Vitest, Tailwind CSS. Todo el código se escribe en pantalla durante la clase.

¿Por qué falla al dejar useForm con el mode por defecto ('onSubmit'), lo que hace que los errores de Zod solo aparezcan después…?

Dejar useForm con el mode por defecto ('onSubmit'), lo que hace que los errores de Zod solo aparezcan después de enviar el formulario, dando la falsa impresión de que la validación en tiempo real 'no funciona'.

¿Por qué falla al poner el path incorrecto en ctx.addIssue dentro de superRefine (por ejemplo path: ['password'] en vez de…?

Poner el path incorrecto en ctx.addIssue dentro de superRefine (por ejemplo path: ['password'] en vez de ['confirmPassword']), lo que asocia el error al campo equivocado en el formulario.

¿Por qué falla al escribir dos schemas distintos (uno para el form y otro 'a mano' en la API route) en vez de importar el mismo…?

Escribir dos schemas distintos (uno para el form y otro 'a mano' en la API route) en vez de importar el mismo signupSchema, reintroduciendo el problema de inconsistencia que Zod resuelve.

¿Por qué falla al confiar solo en la validación del cliente y omitir el safeParse en el servidor, dejando la API abierta a…?

Confiar solo en la validación del cliente y omitir el safeParse en el servidor, dejando la API abierta a requests directos (Postman, curl) con datos inválidos.

¿Hay algún truco que no esté en la documentación oficial?

Cuando el error viene de un refinamiento cruzado como superRefine, usar error.flatten().fieldErrors puede perder el path exacto en casos anidados; en cambio, iterar sobre error.issues y mapear issue.path[0] a cada campo es más robusto para formularios reales. Además, reutilizar el mismo objeto de errores (fieldErrors: Record<string,string>) tanto en el cliente como en la API te permite tener un contrato de errores único entre frontend y backend, algo que casi ningún tutorial de Zod muestra porque solo cubren validación client-side.

Transcript

Transcript with timestamps

0:00¡Bienvenidos a AI Talks! Soy Pixel, y hoy resolvemos un bug clásico: la validación duplicada entre frontend y backend. Vamos a construir un formulario de registro con Next.js, React Hook Form y Zod cuatro, compartiendo un solo schema entre cliente y servidor. Al final vas a dominar validación cruzada y tests aislados.

0:23¿Les pasó? Cambian una regla de password en el backend, se olvidan del frontend, y el form aprueba datos que la API rechaza. Hoy eliminamos ese caos con un solo archivo de verdad.

0:38Zod cuatro es un contrato de datos que corre igual en el navegador y en Node. Con zodResolver lo conectamos a React Hook Form, y con safeParse lo reutilizamos en la API. Un schema, dos consumidores.

0:54Creamos el proyecto con create-next-app: TypeScript, Tailwind, App Router y src directory. Configuración estándar de Next.js catorce.

1:01Instalamos Zod cuatro, react-hook-form y el resolver de hookform. Y sumamos Vitest como dev dependency para testear el schema aislado.

1:10Este es el corazón de todo. Definimos name, email y password con reglas de Zod: mínimos y regex para mayúscula y número. confirmPassword es solo un string; la comparación real vive en superRefine, que si no coinciden agrega un issue apuntando al campo confirmPassword.

1:30En signup page conectamos useForm con zodResolver y nuestro signupSchema: esa línea es toda la validación. El modo onBlur es clave; si lo dejan en el default onSubmit, los errores no aparecen hasta enviar, y van a jurar que Zod no funciona.

1:49El JSX es formulario controlado normal: register conecta cada input, y errors punto campo nos da el mensaje exacto del schema. Nombre, correo y password siguen el mismo patrón.

2:01confirmPassword muestra 'no coinciden' apenas hacés blur, gracias al superRefine. El botón se deshabilita mientras isSubmitting es true, evitando dobles envíos.

2:11Levantamos el dev server. Next.js compila y el formulario ya corre en local.

2:17Escribo una contraseña sin mayúscula, salgo del campo, y aparece 'Debe contener al menos una mayúscula' al instante, sin tocar enviar. Ese es el feedback en tiempo real que hace sentir moderno a un formulario.

2:32Testeamos el schema solo, sin React. Con Vitest llamamos signupSchema punto safeParse directo. El primer test manda contraseñas distintas y espera el error en confirmPassword; el segundo manda datos válidos y espera success true.

2:47Corremos vitest run y los dos tests pasan. Si mañana cambia una regla, un test roto avisa antes que un usuario enojado.

2:57En la API route importamos el mismo signupSchema, nada de reglas nuevas a mano. safeParse valida el body; si falla, recorremos issues para armar fieldErrors por campo. Si el email ya existe, devolvemos cuatrocientos nueve con el mismo formato.

3:14Agregamos dos estados, serverError y success, y sumamos setError al useForm: es la función que nos deja inyectar errores del servidor como si fueran de Zod.

3:26El onSubmit ahora es async: fetch al endpoint signup con el JSON, y si viene fieldErrors, iteramos y llamamos setError con type server. Así 'email ya registrado' se ve igual que un error de Zod. Si no hay error, marcamos success true.

3:45Si success es true, mostramos el mensaje verde de cuenta creada sin renderizar el form. Y agregamos el párrafo de serverError para errores sin campo específico.

3:56Reiniciamos el server: un POST devuelve cuatrocientos nueve, y otro doscientos uno. El mismo endpoint maneja ambos casos con el mismo schema.

4:06Mando el registro con admin arroba demo punto com y el correo se pinta de rojo: 'Este correo ya está registrado', cien por ciento del servidor. Cambio el correo, reenvío, y aparece '¡Cuenta creada con éxito!'.

4:22Cuatro errores frecuentes: dejar useForm en modo onSubmit y creer que el tiempo real no funciona; poner el path equivocado en addIssue; escribir un segundo schema a mano en la API; y confiar solo en el cliente, dejando la ruta abierta a un curl con datos maliciosos.

4:43Mi pro tip: cuando el error viene de superRefine, evitá error.flatten, porque en casos anidados pierde el path exacto. Iterá sobre error.issues y mapeá issue.path a mano. Reutilizá ese mismo objeto fieldErrors en cliente y servidor: un contrato de errores único que casi ningún tutorial muestra.

5:03Construimos un form donde un solo schema de Zod valida cliente, servidor y tests, eliminando la inconsistencia de raíz. Seguí a AI Talks, todo está en aitalks.cl. La próxima vez llevamos este schema a Server Actions de Next.js, sin fetch de por medio. ¡Nos vemos!