RHF + Zod
Integrate Zod schemas with react-hook-form via @hookform/resolvers/zod for fully typed, schema-driven forms.
Search across all documentation pages
Integrate Zod schemas with react-hook-form via @hookform/resolvers/zod for fully typed, schema-driven forms.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const Schema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "At least 8 characters"),
});
type FormData = z.infer<typeof Schema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(Schema),
defaultValues: { email: "", password: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Log in</button>
</form>
);
}When to reach for this: When you want schema-defined validation rules (Zod) powering react-hook-form's performant field-level error display.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const ProfileSchema = z
.object({
username: z
.string()
.min(3, "At least 3 characters")
.max(20)
.regex(/^[a-z0-9_]+$/, "Lowercase letters, numbers, underscores only"),
displayName: z.string().min(1, "Required"),
bio: z.string().max(500).optional(),
website: z.string().url("Invalid URL").optional().or(z.literal("")),
newPassword: z.string().min(8).optional().or(z.literal("")),
confirmPassword: z.string().optional().or(z.literal("")),
})
.refine(
(data) => {
if (data.newPassword && data.newPassword !== data.confirmPassword) return false;
return true;
},
{ message: "Passwords must match", path: ["confirmPassword"] }
);
type ProfileFormData = z.infer<typeof ProfileSchema>;
export function ProfileEditor() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
reset,
} = useForm<ProfileFormData>({
resolver: zodResolver(ProfileSchema),
defaultValues: {
username: "johndoe",
displayName: "John Doe",
bio: "",
website: "",
newPassword: "",
confirmPassword: "",
},
mode: "onBlur",
});
async function onSubmit(data: ProfileFormData) {
await new Promise((r) => setTimeout(r, 1000)); // simulate API
console.log("Saved:", data);
reset(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-md space-y-4">
<div>
<label className="block text-sm font-medium">Username</label>
<input {...register("username")} className="w-full rounded border p-2" />
{errors.username && <p className="text-sm text-red-600">{errors.username.message}</p>}
</div>
<div>
<label className="block text-sm font-medium">Display Name</label>
<input {...register("displayName")} className="w-full rounded border p-2" />
{errors.displayName && <p className="text-sm text-red-600">{errors.displayName.message}</p>}
</div>
<div>
<label className="block text-sm font-medium">Bio</label>
<textarea {...register("bio")} rows={3} className="w-full rounded border p-2" />
{errors.bio && <p className="text-sm text-red-600">{errors.bio.message}</p>}
</div>
<div>
<label className="block text-sm font-medium">Website</label>
<input {...register("website")} placeholder="https://..." className="w-full rounded border p-2" />
{errors.website && <p className="text-sm text-red-600">{errors.website.message}</p>}
</div>
<fieldset className="rounded border p-3">
<legend className="px-1 text-sm font-medium">Change Password (optional)</legend>
<div className="space-y-2">
<input {...register("newPassword")} type="password" placeholder="New password" className="w-full rounded border p-2" />
{errors.newPassword && <p className="text-sm text-red-600">{errors.newPassword.message}</p>}
<input {...register("confirmPassword")} type="password" placeholder="Confirm" className="w-full rounded border p-2" />
{errors.confirmPassword && <p className="text-sm text-red-600">{errors.confirmPassword.message}</p>}
</div>
</fieldset>
<button
type="submit"
disabled={isSubmitting || !isDirty}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Saving..." : "Save Profile"}
</button>
</form>
);
}What this demonstrates:
zodResolver wiring to useForm.refine() for cross-field validation with path targeting.or(z.literal("")))mode: "onBlur" for validate-on-blur behaviorzodResolver(schema) returns a function matching RHF's Resolver typeschema.safeParse(values) and maps ZodError.issues to RHF's FieldErrors formaterrors.fieldName.refine() errors map to the path you specify, or to root if no path is givenAccessing root-level errors:
const { formState: { errors } } = useForm({ resolver: zodResolver(schema) });
// Root error from a .refine() without a path
errors.root?.message;Resolver with custom error mapping:
useForm({
resolver: zodResolver(Schema, {
// Pass Zod options
errorMap: (issue, ctx) => ({
message: customMessages[issue.code] ?? ctx.defaultError,
}),
}),
});Schema with async validation:
const Schema = z.object({
username: z.string().refine(async (val) => {
const available = await checkUsername(val);
return available;
}, "Username taken"),
});
// zodResolver handles async automatically
useForm({ resolver: zodResolver(Schema) });// The generic type flows from the schema
const Schema = z.object({ name: z.string() });
type T = z.infer<typeof Schema>;
// useForm is typed by the generic, not the resolver
const form = useForm<T>({ resolver: zodResolver(Schema) });
// If types mismatch between z.infer and the generic, TS catches it
const BadSchema = z.object({ email: z.string() });
// useForm<T>({ resolver: zodResolver(BadSchema) }) - no TS error at resolver level
// but register("name") will still be type-safe against T
// Best practice: derive the type from the schema
type FormData = z.infer<typeof Schema>;
const form = useForm<FormData>({ resolver: zodResolver(Schema) });Schema and generic type must stay in sync - If you change the schema but not the useForm generic (or vice versa), validation and types diverge silently. Fix: Always derive the form type with z.infer<typeof Schema>.
Optional fields with empty strings - HTML inputs submit "" for empty fields, but z.string().optional() expects undefined. Fix: Use .optional().or(z.literal("")) or preprocess empty strings to undefined.
Transforms not reflected in form values - zodResolver returns the parsed (transformed) data to onSubmit, but the form fields still show the raw input values. Fix: This is expected; transforms apply only to the data passed to onSubmit.
Cross-field errors need path - Object-level .refine() without path puts the error on errors.root, which is easy to miss in the UI. Fix: Always specify path: ["fieldName"].
| Alternative | Use When | Don't Use When |
|---|---|---|
| Yup resolver | Legacy codebase using Yup schemas | Starting fresh (Zod has better inference) |
| Valibot resolver | You need minimal bundle size | You need Zod's ecosystem |
| Built-in RHF validation | Very simple rules (required, minLength) only | You want centralized schema validation |
| Server-only validation | Forms submit via server actions with no client JS | You need instant field-level feedback |
The @hookform/resolvers package provides zodResolver, which you pass to useForm({ resolver: zodResolver(Schema) }).
const Schema = z.object({ email: z.string().email() });
type FormData = z.infer<typeof Schema>;
const form = useForm<FormData>({ resolver: zodResolver(Schema) });schema.safeParse(values) with all field valuesZodError.issues to RHF's FieldErrors formaterrors.fieldNameUse .optional().or(z.literal("")) on the schema field. Without this, z.string().optional() expects undefined, but HTML inputs send "".
const Schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"],
});It triggers validation when a field loses focus instead of only on submit, giving users feedback as they move between fields.
It lands on errors.root, which is easy to miss in the UI. Always specify path: ["fieldName"] to attach the error to the correct field.
Validation and types diverge silently. The resolver validates against the schema shape, but register() is type-checked against the generic. Always derive the type with z.infer<typeof Schema> to keep them in sync.
No. zodResolver returns the transformed data only to the onSubmit handler. The form fields still display the raw input values. This is expected behavior.
const Schema = z.object({
username: z.string().refine(async (val) => {
return await checkUsername(val);
}, "Username taken"),
});
// zodResolver handles async automatically
useForm({ resolver: zodResolver(Schema) });useForm({
resolver: zodResolver(Schema, {
errorMap: (issue, ctx) => ({
message: customMessages[issue.code] ?? ctx.defaultError,
}),
}),
});z.infer<typeof Schema> is always in sync with the schema definitionNo. TypeScript does not flag a mismatch at the resolver level. The type safety comes from register("fieldName") being checked against the generic. This is why deriving the type from the schema is critical.
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥