Los formularios y las cargas útiles entrantes por la red representan el vector de ataque principal para las aplicaciones web: desde inyecciones de código malicioso (XSS, inyección SQL) hasta la corrupción de la integridad de la base de datos y la denegación de servicio del servidor provocada por formatos de datos inesperados.
Históricamente, los equipos de ingeniería duplicaban la lógica de validación: escribían comprobaciones del lado del cliente para una respuesta inmediata de la interfaz y reglas del lado del servidor para proteger la infraestructura backend. Este enfoque conducía inevitablemente a la desincronización de reglas, duplicación de costes de prueba y errores sutiles en tiempo de ejecución al modificarse los requisitos de negocio.
En el ecosistema moderno de Next.js y React 19, la biblioteca Zod se ha consolidado como el estándar de la industria. Permite definir una única vez un esquema declarativo estricto en TypeScript que se utiliza simultáneamente:
FormData dentro de Server Actions y Route Handlers.z.infer.La validación de datos en Next.js se fundamenta en el principio de Server-First Validation:
FormData. Gracias al principio de Progressive Enhancement, el formulario continúa operativo sin depender de la carga de JavaScript.schema.safeParseAsync(), normalizando los tipos y devolviendo una unión discriminada en lugar de abortar abruptamente el hilo de ejecución.error.flatten() estructura los problemas en un mapa asociativo plano indexado por los nombres de los campos del formulario.useActionState de React 19, actualizando la interfaz de usuario de forma reactiva a través del RSC Flight Payload.El usuario completa los campos del formulario. El navegador realiza comprobaciones de restricciones nativas mediante atributos HTML5.
El formulario conserva la capacidad de enviarse de forma nativa incluso con JavaScript deshabilitado (Progressive Enhancement).
El navegador despacha una petición HTTP POST con la carga útil hacia la Server Action correspondiente.
El servidor extrae los campos de FormData, recuperando arrays mediante getAll y normalizando tipos con Zod coerce/preprocess.
El esquema Zod verifica los tipos, comprueba restricciones de negocio y ejecuta validaciones asíncronas en la base de datos.
Si se detectan errores, la ejecución continúa de forma controlada generando un mapa tipado de errores por campo.
Los errores de validación retornan al cliente y actualizan reactivamente el estado del formulario.
El hook useActionState preserva el estado entre renderizados, transmitiendo los errores a los componentes de entrada.
Los esquemas de Zod declaran tipos de datos, restricciones de longitud, expresiones regulares y coerción automática de tipos:
import { z } from 'zod';
export const registrationSchema = z.object({
username: z
.string()
.trim()
.min(3, 'El nombre de usuario debe tener al menos 3 caracteres')
.max(30, 'El nombre de usuario no debe exceder los 30 caracteres')
.regex(/^[a-zA-Z0-9_]+$/, 'Solo se permiten letras, números y guiones bajos'),
email: z
.string()
.trim()
.email('Indica una dirección de correo electrónico válida'),
age: z.coerce
.number()
.int('La edad debe ser un número entero')
.min(18, 'El registro solo está permitido para mayores de 18 años')
.max(120, 'Introduce una edad válida'),
password: z
.string()
.min(8, 'La contraseña debe contener al menos 8 caracteres')
.regex(/[A-Z]/, 'La contraseña debe contener al menos una letra mayúscula')
.regex(/[0-9]/, 'La contraseña debe contener al menos un dígito numérico'),
});
// Inferencia automática del tipo estático de TypeScript (Schema Type Inference)
export type RegistrationInput = z.infer<typeof registrationSchema>;Dentro de una Server Action está estrictamente desaconsejado utilizar z.parse(), ya que lanza una excepción fatal ZodError. En su lugar, se emplea el método safeParse, que produce un resultado seguro respecto a tipos.
'use server';
import { registrationSchema } from '@/lib/schemas/auth';
import { db } from '@/lib/db';
import { users } from '@/lib/schema';
export interface RegistrationFormState {
success: boolean;
message?: string;
errors?: Record<string, string[] | undefined>;
fields?: Record<string, string>; // Preservación de valores para evitar que el usuario pierda su entrada tras un error
}
export async function registerUserAction(
_prevState: RegistrationFormState,
formData: FormData
): Promise<RegistrationFormState> {
// 1. Extracción de entradas planas desde FormData
const rawData = Object.fromEntries(formData.entries());
// 2. Análisis seguro mediante safeParse
const result = registrationSchema.safeParse(rawData);
// 3. Gestión de errores de validación (preservando los campos introducidos)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
message: 'Por favor, corrige los errores del formulario',
fields: {
username: String(rawData.username || ''),
email: String(rawData.email || ''),
age: String(rawData.age || ''),
},
};
}
// 4. Datos garantizados, validados y estrictamente tipados
const { username, email, age, password } = result.data;
try {
await db.insert(users).values({
username,
email,
age,
passwordHash: await hashPassword(password),
});
return {
success: true,
message: '¡Cuenta registrada con éxito!',
};
} catch (err: unknown) {
const errorDetails = err instanceof Error ? err.message : 'Database error';
console.error('Registration failed:', errorDetails);
return {
success: false,
message: 'No se pudo guardar el usuario en la base de datos',
fields: {
username: String(rawData.username || ''),
email: String(rawData.email || ''),
age: String(rawData.age || ''),
},
};
}
}El hook useActionState de React 19 acepta la Server Action y el estado inicial, retornando el estado reactivo con errores, valores preservados y el indicador de pending.
'use client';
import { useActionState } from 'react';
import { registerUserAction, type RegistrationFormState } from '@/app/actions/register';
const initialState: RegistrationFormState = {
success: false,
message: '',
errors: undefined,
fields: undefined,
};
export function RegisterForm() {
const [state, formAction, isPending] = useActionState(registerUserAction, initialState);
return (
<form action={formAction} className="space-y-4 max-w-md bg-bg-surface p-6 rounded-xl border border-border">
<div>
<label htmlFor="username" className="block text-sm font-medium text-text-primary">
Nombre de usuario
</label>
<input
id="username"
name="username"
type="text"
defaultValue={state.fields?.username ?? ''}
className="mt-1 w-full rounded-md border border-border bg-bg-elevated px-3 py-2 text-text-primary focus:border-accent-cyan"
/>
{state.errors?.username && (
<p className="mt-1 text-xs text-danger">{state.errors.username[0]}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-primary">
Correo electrónico
</label>
<input
id="email"
name="email"
type="email"
defaultValue={state.fields?.email ?? ''}
className="mt-1 w-full rounded-md border border-border bg-bg-elevated px-3 py-2 text-text-primary focus:border-accent-cyan"
/>
{state.errors?.email && (
<p className="mt-1 text-xs text-danger">{state.errors.email[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded-md bg-accent-cyan px-4 py-2 font-semibold text-black transition-opacity disabled:opacity-50 cursor-pointer"
>
{isPending ? 'Validando y guardando...' : 'Registrarse'}
</button>
{state.message && (
<div
role="status"
className={`p-3 rounded text-sm ${state.success ? 'bg-success/10 text-success' : 'bg-danger/10 text-danger'}`}
>
{state.message}
</div>
)}
</form>
);
}Un usuario envía un formulario con una casilla de verificación (checkbox) de aceptación de términos. Si la casilla NO está marcada, el navegador envía agree: 'false' o la omite por completo. ¿Qué devolverá z.coerce.boolean().parse('false') y por qué?
En el protocolo FormData, los checkboxes desmarcados no se transmiten, mientras que los campos con nombres compartidos (selecciones múltiples) requieren llamar a formData.getAll().
'use server';
import { z } from 'zod';
export const userSettingsSchema = z.object({
// Transformación segura de checkbox sin la trampa de Boolean("false")
notificationsEnabled: z.preprocess(
(val) => val === 'on' || val === 'true' || val === true,
z.boolean()
),
// Arrays de valores (selectores múltiples y grupos de checkboxes)
assignedRoles: z
.array(z.string())
.min(1, 'Debes seleccionar al menos un rol'),
});
export interface SettingsFormState {
success?: boolean;
errors?: Record<string, string[] | undefined>;
message?: string;
}
export async function updateSettingsAction(
_prevState: SettingsFormState,
formData: FormData
): Promise<SettingsFormState> {
const payload = {
notificationsEnabled: formData.get('notificationsEnabled'),
assignedRoles: formData.getAll('assignedRoles'), // Recuperación de todos los valores del array
};
const result = userSettingsSchema.safeParse(payload);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
return { success: true, message: 'Configuración actualizada' };
}[!IMPORTANT] Validación híbrida: Al integrarse con React Hook Form no se utiliza el atributo
action={}del formulario. En su lugar, el manejadoronSubmitejecuta la validación sincrónica en el cliente mediantezodResolver, invocando a continuación la Server Action dentro de un bloquestartTransition.
'use client';
import { useTransition } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registrationSchema, type RegistrationInput } from '@/lib/schemas/auth';
import { registerUserAction } from '@/app/actions/register';
export function ClientValidatedForm() {
const [isPending, startTransition] = useTransition();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegistrationInput>({
resolver: zodResolver(registrationSchema),
});
const onSubmit = (data: RegistrationInput) => {
startTransition(async () => {
const formData = new FormData();
Object.entries(data).forEach(([key, val]) => formData.append(key, String(val)));
await registerUserAction({ success: false }, formData);
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
<input {...register('username')} placeholder="Nombre de usuario" className="border p-2" />
{errors.username && <p className="text-red-500 text-xs">{errors.username.message}</p>}
<button type="submit" disabled={isPending}>Enviar</button>
</form>
);
}Un esquema define el campo age: z.coerce.number().min(18). ¿Por qué pasar z.infer<typeof schema> a React Hook Form (useForm<T>) provoca un error de compilación en TypeScript?
En JavaScript, la invocación Boolean("false") devuelve true, ya que cualquier cadena no vacía constituye un valor truthy.
Si el cliente envía un campo con la cadena "false", z.coerce.boolean() lo transformará en true. Para procesar checkboxes HTML con fiabilidad, utiliza z.preprocess() verificando explícitamente el valor 'on'.
// ❌ ERROR: Un checkbox con valor "false" se convierte en true
const schema = z.object({
agree: z.coerce.boolean(),
});
schema.parse({ agree: "false" }); // => { agree: true } !
// ✅ CORRECTO: Utilizar z.preprocess o comprobar la bandera de texto "on"
const checkboxSchema = z.preprocess(
(val) => val === 'on' || val === 'true' || val === true,
z.boolean()
);El método estándar Object.fromEntries(formData.entries()) retiene únicamente el primer o último valor cuando existen múltiples campos con el mismo nombre. Si el formulario contiene un grupo de checkboxes con name="permissions" o un selector múltiple, el objeto solo almacenará un único elemento en lugar de la colección completa.
Solución: Para campos que representen listas de valores, recurre siempre de forma explícita a formData.getAll('field_name').
Nunca utilices z.parse() dentro de Server Actions. Si los datos son inválidos, lanza una excepción ZodError, provocando la interrupción de la función, un error 500 no controlado y la imposibilidad de devolver una respuesta legible al usuario.
Solución: Dentro de Server Actions utiliza exclusivamente schema.safeParse() o schema.safeParseAsync().
Al utilizar métodos de transformación (z.coerce, z.preprocess, z.transform), la estructura de datos en la entrada del esquema (Input) y en la salida del esquema (Output) difiere:
z.input<typeof schema>: describe los tipos crudos antes del parseo (valores string de los inputs HTML).z.infer<typeof schema> (o z.output): describe el resultado normalizado y tipado (números, instancias Date).Si asignas z.infer<typeof schema> al tipo del formulario en React Hook Form (useForm<T>), TypeScript notificará incompatibilidades porque los inputs nativos operan con cadenas, mientras que la salida del esquema espera number o Date.
Solución: En formularios del lado del cliente, separa siempre el tipo de entrada (z.input) del DTO saneado de persistencia (z.infer).
import { z } from 'zod';
export const userProfileSchema = z.object({
username: z.string().min(2, 'Mínimo 2 caracteres'),
// En el navegador el input siempre envía un string, y el esquema lo transforma en number
age: z.coerce.number().int().min(18, 'Solo mayores de 18 años'),
birthDate: z.coerce.date(),
});
// ❌ ERROR PARA useForm: contiene tipos convertidos (number, Date), incompatibles con inputs string
export type UserProfileDTO = z.infer<typeof userProfileSchema>;
// ✅ CORRECTO PARA useForm<UserProfileInput>: contiene los tipos crudos de entrada (string | number)
export type UserProfileInput = z.input<typeof userProfileSchema>;