Forms and inbound network payloads represent the primary attack vector for web applications: ranging from malicious script injections (XSS, SQL injection) to data integrity corruption and server denial of service triggered by unexpected payloads.
Historically, engineering teams duplicated validation logic: authoring client-side checks for instant UI feedback and server-side rules to protect backend infrastructure. This approach inevitably resulted in rule desynchronization, double testing overhead, and subtle runtime bugs when business requirements changed.
In the modern Next.js and React 19 ecosystem, Zod has become the industry standard. It enables defining a single declarative schema in TypeScript that powers:
FormData within Server Actions and Route Handlers.z.infer.Validation in Next.js is anchored in the Server-First Validation paradigm:
FormData. Under Progressive Enhancement, the form remains functional even before client-side hydration completes.schema.safeParseAsync(), normalizing values and returning a discriminated union rather than crashing the thread.error.flatten() maps nested schema issues into a flat associative map keyed by form field names.useActionState hook, seamlessly updating the UI via the RSC Flight Payload.The user fills out form inputs. The browser performs baseline constraint validation using HTML5 attributes.
The form retains the ability to submit natively even with JavaScript disabled (Progressive Enhancement).
The browser issues an HTTP POST request carrying the payload to the Server Action.
The server extracts fields, pulling arrays via getAll and coercing values via Zod.
The Zod schema verifies types, evaluates business constraints, and runs async database checks.
On failure, execution continues safely, producing a typed map of field errors.
Validation errors return to the client and reactively update the form interface.
The useActionState hook preserves state across renders, passing errors down to field components.
Zod schemas declare data types, length constraints, regular expressions, and automatic coercion rules:
import { z } from 'zod';
export const registrationSchema = z.object({
username: z
.string()
.trim()
.min(3, 'Username must be at least 3 characters')
.max(30, 'Username must not exceed 30 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores are allowed'),
email: z
.string()
.trim()
.email('Please provide a valid email address'),
age: z.coerce
.number()
.int('Age must be an integer')
.min(18, 'Registration requires a minimum age of 18')
.max(120, 'Please enter a valid age'),
password: z
.string()
.min(8, 'Password must be at least 8 characters long')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one numeric digit'),
});
// Automatic TypeScript DTO inference
export type RegistrationInput = z.infer<typeof registrationSchema>;Never invoke z.parse() inside Server Actions; it throws a fatal ZodError exception. Always utilize safeParse to inspect results safely:
'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>; // Preserves input to prevent resetting form fields on error
}
export async function registerUserAction(
_prevState: RegistrationFormState,
formData: FormData
): Promise<RegistrationFormState> {
// 1. Extract string dictionary from FormData
const rawData = Object.fromEntries(formData.entries());
// 2. Safe parsing via safeParse
const result = registrationSchema.safeParse(rawData);
// 3. Handle validation errors while retaining user inputs
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
message: 'Please resolve the highlighted validation errors',
fields: {
username: String(rawData.username || ''),
email: String(rawData.email || ''),
age: String(rawData.age || ''),
},
};
}
// 4. Guaranteed valid, fully typed data
const { username, email, age, password } = result.data;
try {
await db.insert(users).values({
username,
email,
age,
passwordHash: await hashPassword(password),
});
return {
success: true,
message: 'Account successfully registered!',
};
} catch (err: unknown) {
const errorDetails = err instanceof Error ? err.message : 'Database error';
console.error('Registration failed:', errorDetails);
return {
success: false,
message: 'Failed to record user account in the database',
fields: {
username: String(rawData.username || ''),
email: String(rawData.email || ''),
age: String(rawData.age || ''),
},
};
}
}useActionState#The React 19 useActionState hook pairs with the Server Action to supply reactive error state and submission tracking:
'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">
Username
</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">
Email Address
</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 ? 'Verifying and Saving...' : 'Register'}
</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>
);
}A user submits a form containing a terms acceptance checkbox. When unchecked, browsers send agree: 'false' or omit the key. What will z.coerce.boolean().parse('false') return and why?
In FormData, unchecked inputs are omitted entirely, while inputs sharing identical names (multi-selects, checkbox groups) require invoking formData.getAll():
'use server';
import { z } from 'zod';
export const userSettingsSchema = z.object({
// Safe boolean coercion avoiding the Boolean("false") pitfall
notificationsEnabled: z.preprocess(
(val) => val === 'on' || val === 'true' || val === true,
z.boolean()
),
// Array values from multi-selects and checkbox groups
assignedRoles: z
.array(z.string())
.min(1, 'Please select at least one role'),
});
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'), // Extracts all array values
};
const result = userSettingsSchema.safeParse(payload);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
return { success: true, message: 'Settings successfully updated' };
}[!IMPORTANT] Hybrid Validation Architecture: When using React Hook Form, omit the
<form action={...}>prop. Instead, invoke synchronous client validation viazodResolverwithinonSubmit, and dispatch the Server Action programmatically insidestartTransition.
'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="Username" className="border p-2" />
{errors.username && <p className="text-red-500 text-xs">{errors.username.message}</p>}
<button type="submit" disabled={isPending}>Submit</button>
</form>
);
}A schema includes age: z.coerce.number().min(18). Why does passing z.infer<typeof schema> into React Hook Form (useForm<T>) produce a TypeScript compilation error?
In JavaScript, Boolean("false") evaluates to true because any non-empty string is truthy.
If an unchecked checkbox transmits "false" or is omitted, z.coerce.boolean() evaluates it as true. Use z.preprocess() checking explicitly for "on" or boolean true.
// ❌ ERROR: Checkbox with value "false" becomes true!
const schema = z.object({
agree: z.coerce.boolean(),
});
schema.parse({ agree: "false" }); // => { agree: true } !
// ✅ CORRECT: Use z.preprocess checking for "on" or boolean true
const checkboxSchema = z.preprocess(
(val) => val === 'on' || val === 'true' || val === true,
z.boolean()
);Object.fromEntries(formData.entries()) retains only the first or last occurrence of duplicate keys. If a form includes checkbox groups with identical name="roles" or multi-select inputs, array values will be flattened to a single entry.
Solution: Always extract array fields explicitly using formData.getAll('field_name').
Never invoke z.parse() within Server Actions. On validation failure, it throws a ZodError exception, triggering an HTTP 500 error and preventing the user from receiving actionable validation feedback.
Solution: Always use schema.safeParse() or schema.safeParseAsync().
When utilizing z.coerce, z.preprocess, or z.transform, the schema's Input shape diverges from its Output shape:
z.input<typeof schema>: Describes raw input types before parsing (strings from HTML inputs).z.infer<typeof schema>: Describes cleaned, coerced DTO types (numbers, Date objects).Supplying z.infer to useForm<T> leads to typing incompatibilities because browser inputs produce strings.
Solution: Type client-side form state using z.input, and type backend database payloads using z.infer.
import { z } from 'zod';
export const userProfileSchema = z.object({
username: z.string().min(2, 'Minimum 2 characters'),
// Browser inputs submit strings; the schema coerces them to numbers
age: z.coerce.number().int().min(18, 'Must be at least 18'),
birthDate: z.coerce.date(),
});
// ❌ USE FOR DB/DTO: Contains coerced types (number, Date), incompatible with string inputs
export type UserProfileDTO = z.infer<typeof userProfileSchema>;
// ✅ USE FOR useForm<UserProfileInput>: Matches raw input types (string | number)
export type UserProfileInput = z.input<typeof userProfileSchema>;