Registro con validación en vivo
10 de septiembre de 2026 · React Hook Form 7 con Zod en Next.js 15 · Stack: Next.js 15 (App Router, Server Actions), React Hook Form 7, Zod, @hookform/resolvers, Tailwind CSS
Sé el primero en decir si sirve
PixelUn formulario de registro que muestra errores por campo mientras el usuario escribe, y reutiliza exactamente el mismo schema Zod para validar en el servidor antes de guardar nada.
Sales con esto
- Un solo schema Zod valida el cliente y el Server Action, sin duplicar reglas de negocio
- mode: "onChange" en useForm da feedback por campo mientras el usuario escribe
- El servidor nunca confía en el cliente: siempre vuelve a correr safeParse antes de guardar
- Extraer un componente Field elimina el boilerplate de label + error repetido
- useTransition permite mostrar un estado de carga sin bloquear la UI durante el Server Action
Capítulos
Necesitas
- Conocer componentes cliente vs servidor en Next.js
- Haber usado hooks de React (useState, useTransition)
- Nociones básicas de Zod (z.object, z.string)
Devs frontend/fullstack que ya usan Next.js y quieren formularios robustos sin escribir validación dos veces
6 pasos, un proyecto que corre
Paso 1 — Definir el schema compartido
Crear un único Zod schema con todas las reglas de negocio, que luego se usará tanto en el cliente como en el servidor.
src/lib/schemas/register-schema.ts
import { z } from "zod";
export const registerSchema = z
.object({
name: z
.string()
.min(2, "Mínimo 2 caracteres"),
email: z
.string()
.email("Email inválido"),
password: z
.string()
.min(8, "Mínimo 8 caracteres")
.regex(/[A-Z]/, "Falta mayúscula")
.regex(/[0-9]/, "Falta un número"),
confirmPassword: z.string(),
})
.refine(
(data) =>
data.password === data.confirmPassword,
{
message: "Las contraseñas no coinciden",
path: ["confirmPassword"],
}
);
export type RegisterInput = z.infer<
typeof registerSchema
>;comando: npm install react-hook-form zod @hookform/resolvers
Paso 2 — Server Action que reusa el schema
Mostrar que el servidor nunca confía en el cliente: vuelve a correr safeParse con el mismo schema antes de simular el guardado.
src/app/actions/register.ts
"use server";
import { registerSchema } from
"@/lib/schemas/register-schema";
export type RegisterState = {
success: boolean;
message?: string;
errors?: Record<string, string[]>;
};
export async function registerUser(
data: unknown
): Promise<RegisterState> {
const parsed = registerSchema.safeParse(
data
);
if (!parsed.success) {
return {
success: false,
errors: parsed.error.flatten()
.fieldErrors as Record<
string,
string[]
>,
};
}
await new Promise((r) =>
setTimeout(r, 800)
);
console.log(
"Usuario creado:",
parsed.data.email
);
return {
success: true,
message: "¡Cuenta creada!",
};
}Paso 3 — Formulario base con useForm
Conectar RHF con zodResolver en modo onChange para que los errores de nombre y email aparezcan mientras se escribe.
src/app/register/page.tsx
"use client";
import { useForm } from
"react-hook-form";
import { zodResolver } from
"@hookform/resolvers/zod";
import {
registerSchema,
RegisterInput,
} from "@/lib/schemas/register-schema";
export default function RegisterPage() {
const {
register,
formState: { errors },
} = useForm<RegisterInput>({
resolver: zodResolver(registerSchema),
mode: "onChange",
});
return (
<main className="max-w-sm mx-auto mt-10">
<h1 className="text-xl font-bold mb-4">
Crear cuenta
</h1>
<form className="space-y-3">
<div>
<label className="block text-sm">
Nombre
</label>
<input
{...register("name")}
className="border w-full p-2"
/>
{errors.name && (
<p className="text-red-500 text-sm">
{errors.name.message}
</p>
)}
</div>
<div>
<label className="block text-sm">
Email
</label>
<input
{...register("email")}
className="border w-full p-2"
/>
{errors.email && (
<p className="text-red-500 text-sm">
{errors.email.message}
</p>
)}
</div>
</form>
</main>
);
}comando: npm run dev
Formulario con Nombre y Email. Al escribir una letra en Email sin @, aparece el error debajo del input en tiempo real.
Paso 4 — Contraseñas con reglas y Field reusable
Agregar los campos de contraseña, extraer un componente Field para no repetir label/error, y ver la validación cruzada del refine.
src/app/register/page.tsx
"use client";
import { useForm } from
"react-hook-form";
import { zodResolver } from
"@hookform/resolvers/zod";
import {
registerSchema,
RegisterInput,
} from "@/lib/schemas/register-schema";
export default function RegisterPage() {
const {
register,
formState: { errors },
} = useForm<RegisterInput>({
resolver: zodResolver(registerSchema),
mode: "onChange",
});
return (
<main className="max-w-sm mx-auto mt-10">
<h1 className="text-xl font-bold mb-4">
Crear cuenta
</h1>
<form className="space-y-3">
<Field
label="Nombre"
error={errors.name?.message}
>
<input
{...register("name")}
className="border w-full p-2"
/>
</Field>
<Field
label="Email"
error={errors.email?.message}
>
<input
{...register("email")}
className="border w-full p-2"
/>
</Field>
<Field
label="Contraseña"
error={errors.password?.message}
>
<input
type="password"
{...register("password")}
className="border w-full p-2"
/>
</Field>
<Field
label="Confirmar contraseña"
error={
errors.confirmPassword?.message
}
>
<input
type="password"
{...register("confirmPassword")}
className="border w-full p-2"
/>
</Field>
</form>
</main>
);
}
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="block text-sm">
{label}
</label>
{children}
{error && (
<p className="text-red-500 text-sm">
{error}
</p>
)}
</div>
);
}Ahora se ven Contraseña y Confirmar contraseña. Escribir 'abc' muestra tres errores a la vez; escribir contraseñas distintas muestra 'Las contraseñas no coinciden' bajo el segundo campo.
Paso 5 — Enviar al Server Action
Conectar el submit con useTransition, deshabilitar el botón mientras el formulario es inválido o está enviando, y mostrar el resultado del servidor.
src/app/register/page.tsx
"use client";
import { useTransition, useState } from
"react";
import { useForm } from
"react-hook-form";
import { zodResolver } from
"@hookform/resolvers/zod";
import {
registerSchema,
RegisterInput,
} from "@/lib/schemas/register-schema";
import { registerUser } from
"@/app/actions/register";
export default function RegisterPage() {
const [isPending, startTransition] =
useTransition();
const [serverError, setServerError] =
useState<string | null>(null);
const [success, setSuccess] =
useState(false);
const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm<RegisterInput>({
resolver: zodResolver(registerSchema),
mode: "onChange",
});
const onSubmit = (data: RegisterInput) => {
setServerError(null);
startTransition(async () => {
const result = await registerUser(
data
);
if (!result.success) {
setServerError(
"Revisa los datos enviados"
);
return;
}
setSuccess(true);
});
};
if (success) {
return (
<main className="max-w-sm mx-auto mt-10">
<p className="text-green-600">
¡Cuenta creada!
</p>
</main>
);
}
return (
<main className="max-w-sm mx-auto mt-10">
<h1 className="text-xl font-bold mb-4">
Crear cuenta
</h1>
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-3"
>
<Field
label="Nombre"
error={errors.name?.message}
>
<input
{...register("name")}
className="border w-full p-2"
/>
</Field>
<Field
label="Email"
error={errors.email?.message}
>
<input
{...register("email")}
className="border w-full p-2"
/>
</Field>
<Field
label="Contraseña"
error={errors.password?.message}
>
<input
type="password"
{...register("password")}
className="border w-full p-2"
/>
</Field>
<Field
label="Confirmar contraseña"
error={
errors.confirmPassword?.message
}
>
<input
type="password"
{...register("confirmPassword")}
className="border w-full p-2"
/>
</Field>
{serverError && (
<p className="text-red-500">
{serverError}
</p>
)}
<button
type="submit"
disabled={!isValid || isPending}
className="bg-black text-white w-full
p-2 disabled:opacity-50"
>
{isPending
? "Creando cuenta..."
: "Crear cuenta"}
</button>
</form>
</main>
);
}
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="block text-sm">
{label}
</label>
{children}
{error && (
<p className="text-red-500 text-sm">
{error}
</p>
)}
</div>
);
}Con todos los campos válidos el botón 'Crear cuenta' se habilita. Al hacer clic dice 'Creando cuenta...' por 800ms y luego reemplaza el formulario por '¡Cuenta creada!'.
Paso 6 — Accesibilidad: aria-live en mensajes
Hacer que los mensajes de error del servidor y de éxito sean anunciados por lectores de pantalla, un detalle que se olvida casi siempre en demos de formularios.
src/app/register/page.tsx
if (success) {
return (
<main className="max-w-sm mx-auto mt-10">
<p
role="status"
aria-live="polite"
className="text-green-600"
>
¡Cuenta creada!
</p>
</main>
);
}
// ... dentro del <form>, reemplazar
// el bloque de serverError por:
{serverError && (
<p
role="alert"
aria-live="assertive"
className="text-red-500"
>
{serverError}
</p>
)}comando: npm run dev
Flujo completo: escribir datos inválidos muestra errores al instante, corregirlos habilita el botón, enviar muestra el estado de carga y termina en '¡Cuenta creada!' anunciado por accesibilidad.
Lo que se rompe
¿Qué problema resuelve Registro con validación en vivo?
Los formularios suelen validar solo en el cliente (fácil de saltarse) o duplican las reglas en cliente y servidor, y esas dos copias terminan desincronizadas. Un formulario de registro que muestra errores por campo mientras el usuario escribe, y reutiliza exactamente el mismo schema Zod para validar en el servidor antes de guardar nada.
¿Qué necesito saber antes de seguir esta clase?
Necesitas Conocer componentes cliente vs servidor en Next.js, Haber usado hooks de React (useState, useTransition), Nociones básicas de Zod (z.object, z.string). La clase es de nivel intermedio y dura 9 minutos.
¿Qué stack se usa para React Hook Form 7 con Zod en Next.js 15?
El proyecto usa Next.js 15 (App Router, Server Actions), React Hook Form 7, Zod, @hookform/resolvers, Tailwind CSS. Todo el código se escribe en pantalla durante la clase.
¿Por qué falla al olvidar mode: "onChange" y que los errores solo aparezcan después del primer submit?
Olvidar mode: "onChange" y que los errores solo aparezcan después del primer submit
¿Por qué falla al no tipar el formulario con z.infer<typeof schema>, terminando con campos any?
No tipar el formulario con z.infer<typeof schema>, terminando con campos any
¿Por qué falla al enviar FormData crudo al Server Action en vez del objeto ya tipado que entrega RHF?
Enviar FormData crudo al Server Action en vez del objeto ya tipado que entrega RHF
¿Por qué falla al duplicar las reglas de validación en el servidor 'por las dudas' en vez de importar el mismo schema?
Duplicar las reglas de validación en el servidor 'por las dudas' en vez de importar el mismo schema
¿Hay algún truco que no esté en la documentación oficial?
result.error.flatten().fieldErrors de Zod devuelve Record<string, string[] | undefined>, no Record<string, string[]>. Si vas a pasar ese objeto tal cual como estado de un Server Action, castealo explícitamente (o usa .safeParse().error.format() si necesitas anidar errores de objetos); si no, TypeScript se queja al serializarlo entre servidor y cliente y terminas con 'as any' regado por todo el componente.
Transcripción
Transcripción con marcas de tiempo
0:00¡Bienvenidos a AI Talks! Soy Pixel, y hoy construimos un formulario de registro con validación en vivo usando React Hook Form 7, Zod y Next.js 15. La idea es simple pero poderosa: un solo schema Zod que valida cada campo mientras escribís, y que el Server Action vuelve a correr antes de guardar nada. Al final vas a tener un formulario con feedback instantáneo, botón deshabilitado hasta que todo esté correcto, y mensajes accesibles con aria-live.
0:34Seamos honestos: la mayoría de los formularios validan solo en el cliente, algo tan fácil de saltarse como abrir las devtools y mandar un fetch directo. Otros duplican las reglas en cliente y servidor, y en algún momento alguien cambia una regla en un solo lugar. Resultado: contraseñas débiles colándose a producción. Hoy resolvemos esto de raíz.
1:00React Hook Form maneja el estado del formulario sin re-renderizar todo en cada tecla. Zod define las reglas como código tipado. Y @hookform/resolvers conecta ambos mundos. Con Next.js 15 y Server Actions, ese mismo schema viaja al servidor sin cambiar una línea: cliente y servidor hablan el mismo idioma de validación.
1:23Arrancamos como siempre: create-next-app con TypeScript, Tailwind, ESLint y App Router. Nada exótico todavía, solo la base sobre la que vamos a montar todo el flujo de validación.
1:35Ahora instalamos las tres piezas clave: react-hook-form para el estado del formulario, zod para las reglas, y @hookform/resolvers, el pegamento entre ambos. Con esto ya tenemos todo lo necesario para el schema compartido.
1:50Empecemos por el corazón de todo: el schema. En register-schema.ts definimos con z.object el nombre, que pide mínimo dos caracteres, y el email, validado con el método .email de Zod. Este archivo no renderiza nada, pero es la fuente de verdad que van a usar tanto el formulario como el servidor.
2:13Acá viene lo interesante: la contraseña encadena tres reglas con regex, mínimo ocho caracteres, una mayúscula y un número, cada una con su propio mensaje. Con .refine agregamos validación cruzada: si password y confirmPassword no coinciden, el error se asigna al campo confirmPassword gracias al path. Y exportamos RegisterInput con z.infer, así nunca tipamos este formulario a mano.
2:39En src/app/actions/register.ts creamos el Server Action. Fíjense: importamos exactamente el mismo registerSchema, nada de reglas nuevas. La función registerUser recibe data como unknown, porque nunca confiamos en lo que llega del cliente, y corre safeParse. Si falla, devolvemos los errores por campo con flatten().fieldErrors.
2:59Si la validación pasa, simulamos guardado con un setTimeout de ochocientos milisegundos, logueamos el email y devolvemos success true con un mensaje. Este bloque es la garantía real: aunque alguien saltee el formulario y mande un fetch a mano, esta validación no se la salta nadie.
3:20Volvamos al cliente. En register/page.tsx conectamos useForm con zodResolver pasándole registerSchema, y algo clave: mode onChange, para que la validación corra en cada tecla y no solo al enviar. Ya se ve el título Crear cuenta apareciendo en el preview de la derecha.
3:39Agregamos el campo Nombre con register('name'), y justo debajo el mensaje de error que solo aparece si errors.name existe. Miren el preview: ya tenemos el input Nombre, y si escribimos una sola letra aparece 'Mínimo 2 caracteres' al instante, sin esperar ningún submit.
3:58Cerramos con el campo Email, mismo patrón: register('email') y su error correspondiente. Con esto tenemos un mini formulario funcional, con dos campos que validan mientras escribís gracias al mode onChange que configuramos antes.
4:13Repetir label, input y párrafo de error en cada campo es un boilerplate horrible, así que extraemos un componente Field. Miren cómo Nombre y Email quedan envueltos en Field con su label y su error como props: el JSX se vuelve muchísimo más legible.
4:33Ahora sumamos Contraseña y Confirmar contraseña, también envueltas en Field. En el preview aparecen los dos inputs nuevos, y si escribís 'abc' vas a ver tres errores a la vez: falta mayúscula, falta un número y mínimo ocho caracteres, todos calculados por el mismo schema.
4:53Y acá está la definición del componente Field: recibe label, error y children, y arma la estructura de siempre. Son apenas veinte líneas, pero eliminan la repetición en los cuatro campos que acabamos de escribir. Este es el tipo de refactor que hace que un formulario grande siga siendo mantenible.
5:16Momento de conectar todo con el servidor. Sumamos useTransition y useState para manejar isPending, serverError y success, e importamos registerUser desde nuestro Server Action. La función onSubmit limpia errores previos, llama a startTransition, ejecuta registerUser con los datos ya tipados por RHF, y si falla muestra un mensaje; si funciona, marca success en true.
5:40Con success en true, reemplazamos todo el formulario por un mensaje de '¡Cuenta creada!'. Y en el botón de submit está la magia de la UX: disabled con isValid negado o isPending, así nadie puede hacer doble clic mientras se está guardando. El texto cambia entre 'Crear cuenta' y 'Creando cuenta...' según el estado.
6:04Último detalle, el que casi todos se olvidan: accesibilidad. Agregamos role status y aria-live polite al mensaje de éxito, y role alert con aria-live assertive al error del servidor. Así un lector de pantalla anuncia automáticamente cuando la cuenta se creó, o cuando algo salió mal.
6:25Vamos a la demo real. Escribo un nombre de una sola letra y ahí aparece 'Mínimo 2 caracteres' al instante. Paso al email sin arroba y aparece 'Email inválido'. En la contraseña escribo 'abc' y salen los tres errores juntos: longitud, mayúscula y número. Todo esto sin tocar submit, gracias al mode onChange.
6:49Ahora corrijo todo: nombre completo, email válido, y una contraseña que cumple las tres reglas, repetida igual en la confirmación. El botón se habilita solo cuando isValid es true. Hago clic, el texto cambia a 'Creando cuenta...' por ochocientos milisegundos, y termina en '¡Cuenta creada!', anunciado por aria-live para quien use lector de pantalla.
7:13Antes de cerrar, los errores que más veo en código real: olvidar mode onChange y que la validación solo aparezca después del primer submit; tipar el formulario como any en vez de usar z.infer; mandar FormData crudo al Server Action en vez del objeto que ya te entrega RHF; duplicar reglas en el servidor 'por las dudas'; y no deshabilitar el botón mientras isPending, lo que permite doble envío.
7:44Mi pro tip que no vas a encontrar en la doc oficial: flatten().fieldErrors de Zod devuelve un Record de string a string arreglo o undefined, no un Record limpio. Si lo devolvés tal cual desde un Server Action, TypeScript se va a quejar al serializarlo entre servidor y cliente. Casteálo explícitamente, o mejor, usá .error.format() si necesitás anidar errores de objetos complejos. Y en el ecosistema de AI Talks tenemos una colección de snippets de Zod y RHF listos para copiar, échenle un ojo.
8:22Hoy armamos un formulario de registro donde un solo schema Zod valida en vivo en el cliente y otra vez en el servidor antes de guardar nada, con feedback accesible y sin doble envío. Si te gustó, seguí a AI Talks, todo el contenido está en aitalks.cl. La próxima vez subimos la apuesta con autenticación real sobre este mismo formulario. ¡Nos vemos ahí!