2 AM, my phone buzzes. The product manager texts saying the registration form in production is accepting malformed emails — someone typed abc@ and it passed right through, crashing the server. I pull up the code and find a tangled mess of hand-written if/else validation from six months ago, logic so convoluted that nobody dared touch it.
After that night, I decided to refactor all the form validation in the project. It was also the first time I used the React Hook Form + Zod combo seriously. I’ve never gone back to the old way since.
The Problem with Old-School Form Validation
The most common approach that most React beginners use:
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const handleSubmit = () => {
if (!email) {
setError('Email is required');
return;
}
if (!email.includes('@')) {
setError('Invalid email address');
return;
}
// ... submit
};
A 2-3 field form? That’s fine. But when you have 10+ fields with 3-4 rules each, the code quickly becomes a maintenance nightmare. TypeScript can’t catch type errors through this pile of if/else — validation bugs only surface at runtime, usually at 2 AM in production.
I once refactored a 50K-line codebase and the biggest lesson was that you need solid test coverage before you start. But with validation logic scattered everywhere like this, writing tests is also a nightmare — every component handles things its own way, with no consistency at all.
Zod and React Hook Form — A Perfect Match
Zod: Schema as the Single Source of Truth
Zod doesn’t just validate data — it generates TypeScript types directly from your schema. Declare it once, and Zod handles the rest:
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
age: z.number().min(18, 'Must be at least 18 years old'),
});
// TypeScript automatically infers type from schema — no manual declaration needed
type RegisterForm = z.infer<typeof registerSchema>;
// { email: string; password: string; age: number }
What I love most about this: the same schema works on both client and server. Want to change an email validation rule? Update it in one place — the frontend and API stay in sync automatically, no need to remember to update two locations.
React Hook Form: Performance Without Sacrificing DX
React Hook Form manages state through uncontrolled inputs with refs. User typing character by character? Zero re-renders. Bundle size is just ~9KB gzip — lighter than Formik (~15KB) and noticeably faster on forms with many fields. Integrate it with Zod via the @hookform/resolvers package:
npm install react-hook-form zod @hookform/resolvers
In Practice: Building a Type-Safe Registration Form End to End
Step 1: Define the Zod Schema
// schemas/register.ts
import { z } from 'zod';
export const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters').max(50),
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain at least 1 uppercase letter')
.regex(/[0-9]/, 'Must contain at least 1 number'),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword'],
}
);
export type RegisterFormData = z.infer<typeof registerSchema>;
Notice .refine() — used for cross-field validation (comparing two fields against each other). This is something the old validation approach struggles with, often leading to bugs when edge cases come up.
Step 2: Wire It Up with React Hook Form
// components/RegisterForm.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registerSchema, RegisterFormData } from '@/schemas/register';
export function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterFormData) => {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json();
// Map server-side errors to the correct field
errorData.errors?.forEach((err: { field: string; message: string }) => {
setError(err.field as keyof RegisterFormData, {
message: err.message,
});
});
}
} catch {
setError('root', { message: 'Connection error. Please try again.' });
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Email</label>
<input {...register('email')} type="email" placeholder="Email" />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<div>
<label>Password</label>
<input {...register('password')} type="password" />
{errors.password && <p className="error">{errors.password.message}</p>}
</div>
<div>
<label>Confirm Password</label>
<input {...register('confirmPassword')} type="password" />
{errors.confirmPassword && (
<p className="error">{errors.confirmPassword.message}</p>
)}
</div>
{errors.root && <p className="error-global">{errors.root.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Processing...' : 'Register'}
</button>
</form>
);
}
handleSubmit from RHF only calls onSubmit when Zod validation passes — if the schema fails, errors automatically appear on the correct fields with no manual handling required.
Step 3: Re-Validate on the Server with the Same Schema
Client-side validation is a UX concern, not a security one. The server must always re-validate — and this is where sharing the schema with the client really shines:
// app/api/register/route.ts
import { NextResponse } from 'next/server';
import { registerSchema } from '@/schemas/register';
import { ZodError } from 'zod';
export async function POST(request: Request) {
try {
const body = await request.json();
// Parse + validate with the same schema as the client
const validatedData = registerSchema.parse(body);
// validatedData is fully TypeScript type-checked
await createUser(validatedData);
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json(
{
errors: error.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
}))
},
{ status: 400 }
);
}
return NextResponse.json(
{ message: 'Internal server error' },
{ status: 500 }
);
}
}
Step 4: Using with Next.js Server Actions
Using the Next.js App Router? The flow is even cleaner with Server Actions:
// app/register/actions.ts
'use server';
import { registerSchema } from '@/schemas/register';
export async function registerAction(formData: FormData) {
const rawData = Object.fromEntries(formData);
// safeParse doesn't throw — returns { success, data } or { success, error }
const result = registerSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// result.data is fully type-safe
await createUser(result.data);
return { success: true };
}
Use safeParse() when you don’t want to throw exceptions, and parse() when you want errors to bubble up through try/catch.
Common Patterns
- Optional field with default:
z.string().optional().default('') - Enum:
z.enum(['admin', 'user', 'guest'])— TypeScript automatically infers the union type - Transform:
z.string().transform(val => val.trim().toLowerCase())— sanitize data right in the schema, no manual processing needed - Number from string (common with FormData):
z.coerce.number().min(0) - Conditional validation:
z.discriminatedUnion('type', [...])when the schema changes based on another field’s value
Conclusion
After that 2 AM incident, I migrated all the form validation in the project to React Hook Form + Zod over three days. Not because of a deadline — but because once you use it, you can’t stand going back to the old way.
Schema as a single source of truth. TypeScript catches errors at compile time — not at 2 AM in production. API errors map directly to the right form field, so users know exactly where they went wrong. Those three things alone are enough to never go back.
If you’re still using the old validation approach, try migrating just one small form in your project this way. Chances are, you won’t stop at just one form.
