Run this yourself. The schema, the form component and the API route from this guide, as runnable files:
zod-form-validation/in the Coding Dunia code-examples repo.
Before going further, make sure your project has TypeScript strict mode turned on. Schema-first validation pays off the most when the compiler refuses to let inferred types drift back into any.
The old pattern was exhausting. You'd write a TypeScript interface for your form data, then write separate validation logic to check the same constraints at runtime. Two definitions, same rules. The moment requirements changed, say, password minimum length goes from 6 to 10, you'd update one and forget the other.
Zod 3.x fixes this. You define a schema once. TypeScript types come from that schema. Runtime validation runs from that schema. There's one place to update, and nothing can drift.
Quick take: Zod is a TypeScript-first schema library that turns one schema into both compile-time types and runtime validation, no separate interface required. Downloaded over 24 million times weekly on npm, it wires into React Hook Form 7.x via the zodResolver adapter, and the same schema runs unchanged on your Next.js API route to keep client and server in sync.
Update (2026-07-31): Zod 4 shipped in July 2025 with a claimed 14x faster parsing and a 57% smaller core bundle, and is now at 4.4.x. The
z.infer,.refine(), andzodResolverpatterns in this guide are unchanged in v4, they're still the current recommended approach, so the code below works as written on either major version. If you're starting a new project today, install Zod 4 directly rather than 3.x; see the official migration guide if you're upgrading an existing 3.x schema.
Why Does Zod Change Form Validation?
Zod makes the schema the single source of truth for both runtime behavior and static types. According to the Zod Documentation, the library was downloaded over 24 million times per week on npm in 2025, reflecting how broadly the TypeScript community has adopted schema-first validation over hand-written type guards.
The core insight is simple: TypeScript types vanish at runtime. You can annotate a function parameter as string, but if something passes a number at runtime, TypeScript can't help you. Zod validates the actual values, then gives you the type.
Schema-first validation is a pattern where a single declarative schema object is the only source of truth for both runtime checks and static types, rather than maintaining a TypeScript interface and a validation function as two separate artifacts. Type inference refers to TypeScript deriving a concrete type from a value or expression instead of a hand-written annotation, which is exactly what z.infer<typeof schema> does with a Zod schema.
import { z } from 'zod';
const emailSchema = z.string().email('Invalid email address');
// Runtime validation
const result = emailSchema.safeParse('not-an-email');
// result.success === false, result.error has the message
// Compile-time type
type Email = z.infer<typeof emailSchema>;
// Email is `string`
That's it. One definition. The type Email stays in sync with the validation rule automatically. Change .email() to .url() and the type updates without touching anything else.
Duplicating a schema and a matching interface side by side is one of the worst TypeScript code smells, the two will silently drift the next time someone edits one without the other.
Maintaining parallel type definitions and validation functions is one of the worst TypeScript code smells around, and it keeps showing up in real codebases. It's always the validation that falls behind, and it's always caught in production.
How Do You Build a Real Registration Form With Zod?
Production apps keep converging on this exact pattern, and a registration form is the clearest way to see why schema-first validation pays off. Here's the full setup. Forms in dashboards and admin UIs carry additional design decisions beyond validation logic: inline error placement, field grouping, and error state styling all affect whether users can recover from mistakes quickly.
Step 1: Install dependencies.
npm install zod react-hook-form @hookform/resolvers
Step 2: Define the schema.
import { z } from 'zod';
const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type RegisterForm = z.infer<typeof registerSchema>;
RegisterForm is now { name: string; email: string; password: string; confirmPassword: string }. TypeScript inferred that from the schema. No separate interface.
Step 3: Wire up React Hook Form.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
});
const onSubmit = (data: RegisterForm) => {
// data is fully typed, no casting needed
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('name')} placeholder="Full name" />
{errors.name && <p>{errors.name.message}</p>}
</div>
<div>
<input {...register('email')} placeholder="Email" type="email" />
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<input {...register('password')} placeholder="Password" type="password" />
{errors.password && <p>{errors.password.message}</p>}
</div>
<div>
<input {...register('confirmPassword')} placeholder="Confirm password" type="password" />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
</div>
<button type="submit">Create account</button>
</form>
);
}
React Hook Form calls zodResolver on submit. If validation fails, errors land in formState.errors with the messages you defined in the schema. The onSubmit callback only runs on valid data, and data there is typed as RegisterForm without any assertion.
What Else Can Zod Handle Beyond Simple Fields?
Real forms rarely stop at "is this a string." According to the Zod Documentation, the library ships chainable methods for trimming, coercing, and transforming values before the validation rules run, plus async refinements for checks that need a network round trip, like confirming a username is free. Three patterns cover most of what production forms need: preprocessing raw input (trimming whitespace, coercing a URL param string into a number), async validation against a server, and reusing one schema on both the client and the server so the two never drift apart. Each pattern below builds on the same registerSchema style you already saw, just with one extra method chained on.
Transforming Input Before Validation
Zod can preprocess values before applying rules. This is useful for inputs you don't fully control, whitespace in names, string-coerced numbers from URL params.
const userSchema = z.object({
name: z.string().trim().min(2, 'Name too short'),
age: z.coerce.number().min(18, 'Must be 18 or older'),
});
.trim() runs before .min(2), so a name of " a " fails correctly. z.coerce.number() converts the string "25" to the number 25, handy for form inputs, which always return strings.
Async Validation
Zod supports async refinements for things like username availability:
const usernameSchema = z.object({
username: z.string().min(3).refine(
async (val) => {
const taken = await checkUsernameAvailability(val);
return !taken;
},
{ message: 'Username already taken' }
),
});
React Hook Form handles async resolvers, pass mode: 'onBlur' to useForm to trigger async checks when the user leaves the field rather than on every keystroke.
Reusing the Schema on the Server
This is the part most tutorials skip, and it's the biggest productivity win. The same registerSchema works identically in a Next.js API route:
// pages/api/register.ts (or app/api/register/route.ts)
import { registerSchema } from '@/schemas/register';
export async function POST(req: Request) {
const body = await req.json();
const result = registerSchema.safeParse(body);
if (!result.success) {
return Response.json({ errors: result.error.flatten() }, { status: 400 });
}
// result.data is fully typed here too
await createUser(result.data);
return Response.json({ ok: true });
}
Client validation gives fast feedback. Server validation is the real check, it's what actually protects your database. Same schema handles both. You can use the same pattern for API data fetched on the client too.
When Should You Use .refine() vs .superRefine()?
Both methods run cross-field checks after the individual field rules pass, but they solve different problems. Use .refine() when you have exactly one condition to check, password confirmation matching, an end date after a start date, and you only need to attach one error message to one field via the path option. Reach for .superRefine() the moment you need two or more independent conditions in a single object, each with its own message and its own field path, because .refine() gives you a single pass/fail while .superRefine() gives you a ctx object where you call ctx.addIssue() once per problem found. In my testing, teams that start every cross-field check with .superRefine() end up with more boilerplate than they need, three lines of ctx.addIssue() for a check that .refine() would handle in one. The table below is the quick reference worth bookmarking.
| .refine() | .superRefine() | |
|---|---|---|
| Conditions handled | One condition | Multiple conditions, different messages |
| Error attachment | path option on the single check | ctx.addIssue() per condition, own path each |
| Best for | Password confirmation, simple cross-field checks | Date ranges, conditional required fields |
| Verbosity | Low | Higher, but explicit |
What Are Some Common Zod Patterns Worth Knowing?
Four patterns show up in almost every production form schema: defaults for optional settings, enums for select fields, nested objects for addresses, and superRefine for date ranges and conditional-required logic. Here is a quick reference for checking whether your own schema is using the right one:
- Field can be missing and Zod should fill in a value: use
.default(). - Field can be missing and should stay
undefinedin the type: use.optional(). - Field has a fixed set of allowed string values: use
z.enum([...]). - Field is a reusable group of sub-fields, like an address: define it as its own schema and nest it.
- Two or more fields depend on each other with different messages: use
.superRefine().
Optional Fields with Defaults
const settingsSchema = z.object({
theme: z.string().default('light'),
notifications: z.boolean().optional(),
pageSize: z.number().default(20),
});
.default() means the field can be absent from input, Zod fills it in. .optional() means it can be absent and the type reflects that (boolean | undefined).
Enum Types for Select Fields
const roleSchema = z.enum(['admin', 'editor', 'viewer']);
type Role = z.infer<typeof roleSchema>;
// Role is "admin" | "editor" | "viewer"
Much cleaner than manually writing a union type and a separate runtime check. Change the enum values and the type updates automatically.
Nested Object Schemas for Address Forms
const addressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
postCode: z.string().regex(/^\d{5}$/, 'Invalid post code'),
});
const checkoutSchema = z.object({
email: z.string().email(),
shippingAddress: addressSchema,
billingAddress: addressSchema.optional(),
});
Compose schemas like objects. addressSchema can be reused across checkoutSchema, profileSchema, whatever needs it.
superRefine for Complex Cross-Field Rules
.refine() handles one condition. .superRefine() handles multiple conditions with different error messages:
const dateRangeSchema = z.object({
startDate: z.string(),
endDate: z.string(),
}).superRefine((data, ctx) => {
if (data.endDate < data.startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'End date must be after start date',
path: ['endDate'],
});
}
if (data.startDate === data.endDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Start and end date cannot be the same',
path: ['startDate'],
});
}
});
Reach for superRefine whenever a single .refine() isn't enough, conditional required fields, date range checks, anything where the error location matters. It's verbose, but it's clear.
Schema-first validation is an opinion worth holding firmly: one schema in one place, shared between the form component and the API route. When the product team asks for a new required field or a stricter email rule, you update one file. The TypeScript type updates. The client validation updates. The server validation updates. That's the deal Zod offers, and it's a good one.