Multi-Step Forms with Zod and React Hook Form in TypeScript
Multi-step forms are where most React validation setups fall apart. You split a long form across screens, and suddenly the validation logic is scattered across five components, the TypeScript types drift away from the actual rules, and "this field is required only when the user picks business" turns into a pile of useState flags nobody trusts. (You know the pile. You may have written the pile. No judgment — I have the git history to prove I did too.)
This guide fixes that. We build a type-safe multi-step form with Zod and React Hook Form in TypeScript, using a single schema as the source of truth. You get per-step validation, cross-field conditional validation (a field that becomes required based on another field's value), and the same schema reused on the server.
Everything here targets Zod 4 and React Hook Form 7.
What we're building
A three-step account checkout:
- Step 1 — Account: email + password
- Step 2 — Profile: name + account type (
personalorbusiness) - Step 3 — Billing: country, and a VAT ID that is required only for business accounts
Two rules make this realistic instead of a toy:
- Each step must validate on its own before the user moves on.
- Step 3's VAT ID is conditional — it depends on a choice made back in step 2. Forms, like elephants, never forget.
One schema is the source of truth
The mistake is writing validation per screen. Five screens, five schemas, five slightly different opinions about what a valid email looks like — that's not architecture, that's a support ticket generator. Instead, define one master schema and derive everything else from it — TypeScript types, default values, the resolver React Hook Form uses, and the field list for each step.
// lib/checkout-schema.ts
import { z } from "zod";
export const checkoutSchema = z.object({
// step 1
email: z.email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
// step 2
firstName: z.string().min(1, "Required"),
lastName: z.string().min(1, "Required"),
accountType: z.enum(["personal", "business"]),
// step 3
country: z.string().min(1, "Select a country"),
vatId: z.string().optional(),
});
export type CheckoutValues = z.infer<typeof checkoutSchema>;Note the Zod 4 style: z.email() is a top-level validator now, not z.string().email(). z.infer gives you the TypeScript type for free — one change to the schema updates the type everywhere.
Now map each step to the fields it owns. This is the list we'll hand to React Hook Form when validating a single step:
// lib/checkout-schema.ts
export const stepFields = {
0: ["email", "password"],
1: ["firstName", "lastName", "accountType"],
2: ["country", "vatId"],
} satisfies Record<number, (keyof CheckoutValues)[]>;The satisfies keyword is doing real work here: every string in those arrays is checked against keyof CheckoutValues. Rename vatId in the schema and this map fails to compile until you fix it. That's the whole point — the compiler guards the wiring.
Wire up React Hook Form with the Zod resolver
@hookform/resolvers/zod connects the schema to the form. Because we typed useForm<CheckoutValues>(), register, watch, and errors are all fully typed against the schema.
// components/checkout-form.tsx
"use client";
import { useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
checkoutSchema,
stepFields,
type CheckoutValues,
} from "@/lib/checkout-schema";
import { AccountStep } from "./steps/account-step";
import { ProfileStep } from "./steps/profile-step";
import { BillingStep } from "./steps/billing-step";
const steps = [AccountStep, ProfileStep, BillingStep];
export function CheckoutForm() {
const [step, setStep] = useState(0);
const methods = useForm<CheckoutValues>({
resolver: zodResolver(checkoutSchema),
mode: "onTouched",
defaultValues: {
email: "",
password: "",
firstName: "",
lastName: "",
accountType: "personal",
country: "",
vatId: "",
},
});
const StepComponent = steps[step];
const isLastStep = step === steps.length - 1;
async function goNext() {
// validate ONLY the current step's fields
const valid = await methods.trigger(stepFields[step], {
shouldFocus: true,
});
if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
}
function onSubmit(values: CheckoutValues) {
// reaching here means every step passed AND the full
// schema (including conditional rules) re-validated
console.log("submit", values);
}
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<StepComponent />
<div>
{step > 0 && (
<button type="button" onClick={() => setStep((s) => s - 1)}>
Back
</button>
)}
{isLastStep ? (
<button type="submit">Create account</button>
) : (
<button type="button" onClick={goNext}>
Next
</button>
)}
</div>
</form>
</FormProvider>
);
}Give the default values as an explicit object rather than leaving fields undefined. React Hook Form treats an input as controlled from the first render, and Zod's optional() fields still want a starting value so the component doesn't flip from uncontrolled to controlled.
Validate one step at a time
The key to multi-step validation is trigger. Calling methods.trigger(names) runs the Zod resolver but only reports errors for the fields you pass. That's why we split field lists per step:
const valid = await methods.trigger(stepFields[step], { shouldFocus: true });
if (valid) setStep((s) => s + 1);Two details worth internalizing:
triggerruns the whole schema through the resolver, then React Hook Form filters the result down to the names you passed. So even though the resolver validates step 3's fields too, the user isn't blocked by them on step 1.shouldFocus: truemoves the cursor to the first invalid field. Small touch, big difference for accessibility and for anyone filling out a long form.
The FormProvider at the top lets each step component read the form through useFormContext instead of prop-drilling register and errors down every level.
Conditional validation: a field required by another field's value
This is the part people search for at 11pm and the part naive setups get wrong. VAT ID is optional in the base schema, but it must be required when accountType === "business". Attach the rule to the object with .refine() and point the error at the dependent field using path:
// lib/checkout-schema.ts
export const checkoutSchema = z
.object({
email: z.email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
firstName: z.string().min(1, "Required"),
lastName: z.string().min(1, "Required"),
accountType: z.enum(["personal", "business"]),
country: z.string().min(1, "Select a country"),
vatId: z.string().optional(),
})
.refine((data) => data.accountType !== "business" || Boolean(data.vatId), {
message: "VAT ID is required for business accounts",
path: ["vatId"],
});The path: ["vatId"] is what makes this usable. Without it, the error lands on the whole form object and you have nowhere to render it. With it, the error shows up as errors.vatId — exactly where the user needs to see it, right under the input.
There's a subtle interaction with multi-step validation, and it's the one gotcha to remember: because path targets vatId, and vatId is in stepFields[2], the conditional error correctly surfaces when the user clicks Next on step 3. Cross-field rules only fire when the fields they read live in the same schema — one more reason to keep a single master schema instead of one per step.
Multiple conditional rules with superRefine
.refine() is great for one rule. When you have several cross-field checks, .superRefine() lets you add issues imperatively and target a different path for each:
.superRefine((data, ctx) => {
if (data.accountType === "business" && !data.vatId) {
ctx.addIssue({
code: "custom",
path: ["vatId"],
message: "VAT ID is required for business accounts",
});
}
if (data.country === "US" && !/^\d{9}$/.test(data.vatId ?? "")) {
ctx.addIssue({
code: "custom",
path: ["vatId"],
message: "US tax IDs must be exactly 9 digits",
});
}
});In Zod 4 the issue code is the string "custom". Each addIssue call attaches its own message to its own path, so you can drive several conditional messages on one or many fields from a single block.
When the shape itself changes: discriminated unions
Sometimes a field doesn't just become required — a whole set of fields swaps in. "Contact by email" needs an email; "contact by SMS" needs a phone number. That's a discriminated union, and Zod validates the correct branch automatically based on the discriminator:
const contactSchema = z.discriminatedUnion("method", [
z.object({ method: z.literal("email"), email: z.email() }),
z.object({ method: z.literal("sms"), phone: z.string().min(10) }),
]);When method is "email", only email is validated; when it's "sms", only phone is. Reach for a discriminated union when the shape changes and a refine when a field's rules change but the shape stays the same.
Render the conditional field in the step
The schema decides validity; the component decides visibility. Read accountType with watch and only show the VAT input for business accounts:
// components/steps/billing-step.tsx
"use client";
import { useFormContext } from "react-hook-form";
import type { CheckoutValues } from "@/lib/checkout-schema";
export function BillingStep() {
const {
register,
watch,
formState: { errors },
} = useFormContext<CheckoutValues>();
const accountType = watch("accountType");
return (
<fieldset>
<label>
Country
<input {...register("country")} />
</label>
{errors.country && <p role="alert">{errors.country.message}</p>}
{accountType === "business" && (
<label>
VAT ID
<input {...register("vatId")} />
{errors.vatId && <p role="alert">{errors.vatId.message}</p>}
</label>
)}
</fieldset>
);
}useFormContext<CheckoutValues>() gives this deeply-nested component the same typed form API as the root, no props required. Keep the visibility check (watch) and the validity check (the schema's refine) in agreement — the schema is what actually protects your data; the watch is just UX.
Reuse the exact same schema on the server
The biggest payoff of a single schema: your server validates with the identical rules. Never trust the client — the client is a stranger's computer running code it may have edited in DevTools for fun. But "don't trust" doesn't mean "rewrite the rules twice." Import the schema into a route handler or server action:
// app/api/checkout/route.ts
import { z } from "zod";
import { checkoutSchema } from "@/lib/checkout-schema";
export async function POST(request: Request) {
const body = await request.json();
const result = checkoutSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: z.flattenError(result.error) },
{ status: 400 },
);
}
// result.data is fully typed as CheckoutValues,
// conditional rules already enforced
return Response.json({ ok: true });
}z.flattenError() (the Zod 4 replacement for error.flatten()) returns { formErrors, fieldErrors } — a shape that's trivial to map back onto form fields if you want to surface server errors in the UI. Client and server can never disagree, because they're reading the same file.
Gotchas worth remembering
- Conditional rules need shared fields. A
refinecan only compare fields that live in the same schema. This is the real reason to avoid one-schema-per-step. - Always set
path. Object-levelrefine/superRefineerrors go to the form root unless you point them at a field withpath. No path means no place to render it. triggervalidates the full schema, reports a subset. That's a feature — later steps don't block earlier ones — but it means a broken schema elsewhere can still fail a submit that looked fine step by step.- Give every field a default value. Prevents the controlled/uncontrolled warning and keeps
optional()fields happy. - Use
mode: "onTouched"or"onBlur"for long forms. ValidatingonChangeon every keystroke across many fields is noisy and slow.
Wrapping up
One Zod schema drove the whole thing: TypeScript types, per-step validation via trigger, conditional rules with refine/superRefine and path, shape-swapping with discriminated unions, and identical server-side checks. That's the payoff of treating the schema as the single source of truth — the form's rules live in exactly one place, and everything else is derived from it.
And the useState flag pile from the intro? Deleted. Nobody misses it. Not even the flags.
FAQ
How do I validate only the current step in a multi-step form?
Call React Hook Form's trigger() with just that step's field names, for example await trigger(["email", "password"]). It runs the Zod resolver but only surfaces errors for the fields you pass, so the user can advance without the later steps blocking them.
How do I make a Zod field required only when another field has a specific value?
Use .refine() or .superRefine() on the object schema and target the dependent field with the path option: .refine((d) => d.accountType !== "business" || Boolean(d.vatId), { path: ["vatId"], message: "VAT ID is required" }). The error attaches to vatId so it renders on the right input.
Should I use one Zod schema or one per step?
Define one master schema and derive per-step field lists (or use .pick()) from it. A single source of truth keeps your TypeScript types, default values, resolver, and server validation in sync — and cross-field rules only work when the fields live in the same schema.
Can I reuse the same Zod schema on the server?
Yes. Import the schema into a route handler or server action and call schema.safeParse(body). You get the same validation and the same inferred TypeScript type, so the client and server can never drift apart.
Still here? Read more
no ads, no popups, just the void offering you more content.
Technical SEO in Next.js: What I Actually Wired Into This Site
A code-first tour of technical SEO in Next.js — the Metadata API, typed sitemaps and robots, a linked JSON-LD schema graph, XSS-safe structured data, and per-page OG images served through the file convention. Every line shown is running on the page you're reading.
Multi-Language Sites in Next.js: Locale Routing, Typed Dictionaries, and the RTL U-Turn
A code-first guide to internationalization in the Next.js App Router — a [lang] route segment, locale detection in proxy.ts, type-safe dictionaries with one source of truth, RTL support, and hreflang metadata. No i18n library required.