Install react-hook-form, zod and @hookform/resolvers, define a z.object() schema, and pass resolver: zodResolver(schema) to useForm. Type the form with z.infer<typeof schema>, render errors.field.message, and your onSubmit receives data that Zod has already parsed. Zod 4 needs @hookform/resolvers 5.1.0 or newer.
Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.
Create free accountThe short version: zodResolver in one component
React Hook Form manages the form state. Zod describes what valid data looks like. zodResolver from @hookform/resolvers connects the two, so every validation pass runs your schema and turns Zod issues into formState.errors. Here is a complete, typed contact form:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const contactSchema = z.object({
name: z.string().trim().min(1, "Name is required"),
email: z.email("Enter a valid email address"),
message: z.string().trim().min(10, "Message must be at least 10 characters"),
});
type ContactValues = z.infer<typeof contactSchema>;
export default function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactValues>({
resolver: zodResolver(contactSchema),
defaultValues: { name: "", email: "", message: "" },
});
async function onSubmit(data: ContactValues) {
// data is already parsed by Zod: trimmed, typed, valid
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("name")} placeholder="Name" />
{errors.name && <p role="alert">{errors.name.message}</p>}
<input {...register("email")} type="email" placeholder="Email" />
{errors.email && <p role="alert">{errors.email.message}</p>}
<textarea {...register("message")} placeholder="Message" />
{errors.message && <p role="alert">{errors.message.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send"}
</button>
</form>
);
}The rest of this guide explains each piece, then covers the parts that usually go wrong: password confirmation, number inputs, async checks, validation modes, and sending the result to a form endpoint.
Install the three packages
npm install react-hook-form zod @hookform/resolversThe code in this post was type-checked against react-hook-form 7.89, @hookform/resolvers 5.9 and Zod 4 (4.3 and 4.6) under strict TypeScript. The version that matters most is the resolver. @hookform/resolvers added Zod 4 support in 5.1.0, and it still accepts Zod 3 schemas. If you upgraded Zod to v4 and now get type errors on zodResolver(schema), upgrade @hookform/resolvers first.
Still on Zod 3? Everything here works with two swaps: z.string().email() instead of z.email(), and message instead of error in option objects.
Define the schema and infer the type
A Zod schema is the single source of truth for your fields. Write it once and z.infer gives you the TypeScript type, so the form values, the submit handler and your API payload can never drift apart.
import { z } from "zod";
export const signupSchema = z.object({
// Zod 4: top-level string formats
email: z.email({ error: "Enter a valid email address" }),
website: z.url().optional(),
// Strings: trim before you check length
name: z.string().trim().min(2, "At least 2 characters").max(80),
// A fixed set of options from a <select>
plan: z.enum(["free", "pro", "team"]),
// A checkbox that must be ticked
terms: z.literal(true, { error: "You must accept the terms" }),
});
export type SignupValues = z.infer<typeof signupSchema>;
// {
// email: string;
// website?: string | undefined;
// name: string;
// plan: "free" | "pro" | "team";
// terms: true;
// }A few Zod 4 details that matter in forms:
z.email(),z.url()andz.uuid()are top-level now. The method forms such asz.string().email()still work but are deprecated.- Custom messages go in the
erroroption. A plain string as the last argument (.min(1, "Required")) is shorthand for the same thing. .trim()changes the parsed value, so the data your submit handler receives is trimmed too.z.object()strips keys that are not in the schema. Anything you want in the submitted data has to be declared.
Wire it into useForm and render errors
Pass the schema through zodResolver and register each input by the same key it has in the schema. When validation fails, the issue's message lands in formState.errors.<field>.message, and nested paths become nested objects (errors.address?.city).
- Always set
defaultValues. React Hook Form recommends it, and Zod needs it: an untouched field with no default isundefined, which fails az.string()type check with a generic message. - Add
noValidateto the form so the browser's owntype="email"bubbles do not compete with your Zod messages. - Do not mix in register rules. Once a resolver is set,
register("email", { required: true })is ignored. Every rule belongs in the schema. - For accessibility, add
aria-invalid={errors.email ? "true" : "false"}to the input and keeprole="alert"on the message.
On submit, handleSubmit runs the schema. If it passes, your onSubmit receives Zod's parsed output. If it fails, React Hook Form focuses the first invalid field (shouldFocusError defaults to true) and skips your handler.
Cross-field validation: confirm password
Rules that compare two fields go on the object with .refine(). The path option decides which field the error attaches to. Without it, the resolver stores the error under an empty key (errors[""]) that no field reads, so it looks as if the refine never ran.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const passwordSchema = z
.object({
email: z.email("Enter a valid email address"),
password: z.string().min(8, "Use at least 8 characters"),
confirmPassword: z.string().min(1, "Confirm your password"),
})
.refine((data) => data.password === data.confirmPassword, {
error: "Passwords do not match",
path: ["confirmPassword"], // attach the error to this field
});
type PasswordValues = z.infer<typeof passwordSchema>;
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<PasswordValues>({
resolver: zodResolver(passwordSchema),
defaultValues: { email: "", password: "", confirmPassword: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
<input {...register("email")} type="email" />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input {...register("password")} type="password" />
{errors.password && <p role="alert">{errors.password.message}</p>}
<input {...register("confirmPassword")} type="password" />
{errors.confirmPassword && (
<p role="alert">{errors.confirmPassword.message}</p>
)}
<button type="submit">Create account</button>
</form>
);
}When does the refinement run? We tested this on Zod 4.3 and 4.6. A bad email or a short password does not block it, so the user sees the mismatch message alongside the other errors. A field with the wrong type does block it. If email is undefined because it has no default value, the object-level refine is skipped and the mismatch never appears. That is the most common reason people say "refine is not working", and defaultValues fixes it.
Need several cross-field issues at once? Use .superRefine((data, ctx) => ...) and call ctx.addIssue({ code: "custom", message, path: ["field"] }) once for each problem. If you only want the comparison to run after both password fields are valid, Zod 4's refine also accepts a when option. For fields that should only be validated when they are visible, combine this with watch() and setValue() for conditional fields.
Numbers, optional fields and z.coerce
HTML inputs always hold strings, even type="number". So z.number() fails with "expected number, received string" unless you convert the value first. You have three options, each with a gotcha:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const quoteSchema = z.object({
// Required number: register with valueAsNumber, so an empty box is NaN
seats: z
.number({ error: "Enter the number of seats" })
.int()
.min(1, "At least 1 seat"),
// Optional number: map "" to undefined before Zod sees it
budget: z.number().positive("Budget must be positive").optional(),
// Coerced number: input type is unknown, and "" becomes 0
employees: z.coerce.number().int().min(1, "At least 1 employee"),
});
export function QuoteForm() {
// No generic: input and output types are inferred from the resolver
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(quoteSchema),
defaultValues: { seats: 1, employees: "" },
});
return (
<form
onSubmit={handleSubmit((data) => {
// data.employees is a number here, even though the input held a string
console.log(data.seats, data.budget, data.employees);
})}
noValidate
>
<input type="number" {...register("seats", { valueAsNumber: true })} />
{errors.seats && <p role="alert">{errors.seats.message}</p>}
<input
type="number"
{...register("budget", {
setValueAs: (v) => (v === "" ? undefined : Number(v)),
})}
/>
{errors.budget && <p role="alert">{errors.budget.message}</p>}
<input type="number" {...register("employees")} />
{errors.employees && <p role="alert">{errors.employees.message}</p>}
<button type="submit">Get quote</button>
</form>
);
}valueAsNumber: trueconverts the value in React Hook Form. An empty input becomesNaN, which Zod 4 rejects as an invalid type. Theerroroption onz.number()gives that case a readable message.- Optional numbers need
setValueAs, becauseNaNis notundefinedand.optional()would still fail. z.coerce.number()runsNumber(input), andNumber("")is0. An empty field passes unless you add a.min(). In Zod 4 the input type of every coerce schema isunknown, so do not pinuseFormto a singlez.infertype here.
The same rule applies to .default() and .transform(): input and output types differ. Either omit the generic, as above, or pass all three: useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>. The resolver's own README gives the same advice.
Async validation, and its caveat
Checking a username or a coupon code against an API is a normal async refine. zodResolver calls Zod's parseAsync by default, so this works with no extra setup:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const usernameSchema = z.object({
username: z
.string()
.min(3, { error: "At least 3 characters", abort: true }) // skip the API call if too short
.refine(async (value) => {
const res = await fetch(`/api/username-available?u=${encodeURIComponent(value)}`);
const { available } = (await res.json()) as { available: boolean };
return available;
}, "That username is taken"),
});
export function useUsernameForm() {
return useForm({
resolver: zodResolver(usernameSchema), // async by default: uses parseAsync
mode: "onBlur", // validate when the field loses focus...
reValidateMode: "onBlur", // ...and keep it that way after the first submit
defaultValues: { username: "" },
});
}The caveats:
- The whole schema runs each time. The resolver parses every field on each validation pass, not only the field that changed. With
mode: "onChange", or the defaultreValidateMode: "onChange"after the first submit, that means one request per keystroke. UseonBlurfor both, or debounce inside the refine. abort: trueon the cheaper check stops Zod before the network call when the value is already invalid.- Do not switch to sync mode.
zodResolver(schema, undefined, { mode: "sync" })usesparse(), and Zod throws on async refinements during a synchronous parse. - For checks that only the server can answer, it is often simpler to validate on submit and call
setErrorwith the server's answer. The next section shows that pattern.
Choosing a validation mode
mode controls when validation first runs. reValidateMode controls when it runs again after a submit. The resolver does not change these; it only decides what "valid" means.
onSubmit(default): validate on submit, then re-validate perreValidateMode.onBlur: validate when a field loses focus.onChange: validate on every change. Most re-renders, and it can show errors before the user finishes typing.onTouched: validate on the first blur, then on every change.all: validate on both blur and change.
reValidateMode defaults to onChange, so once a user has submitted, errors clear as they fix them. For most contact and signup forms, mode: "onTouched" is a good default: nobody sees an error before leaving a field, and the error clears as soon as the input is fixed. If you only need one message per field, keep the default criteriaMode: "firstError".
Submit the validated data to a form endpoint
Zod has already checked and typed the data by the time onSubmit runs, so the handler only needs to send it. You do not need your own API route for this. The component below posts JSON to the splitforms endpoint, which stores the submission, emails it to you and can forward it to webhooks.
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const ACCESS_KEY = "YOUR_ACCESS_KEY"; // public form ID from your splitforms dashboard
const contactSchema = z.object({
name: z.string().trim().min(1, "Name is required"),
email: z.email("Enter a valid email address"),
message: z.string().trim().min(10, "Message must be at least 10 characters"),
});
type ContactValues = z.infer<typeof contactSchema>;
export default function ContactForm() {
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<ContactValues>({
resolver: zodResolver(contactSchema),
defaultValues: { name: "", email: "", message: "" },
mode: "onTouched",
});
// Clear the fields after a successful send, but keep the success message
useEffect(() => {
if (isSubmitSuccessful) reset(undefined, { keepIsSubmitSuccessful: true });
}, [isSubmitSuccessful, reset]);
async function onSubmit(data: ContactValues) {
try {
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ access_key: ACCESS_KEY, ...data }),
});
const json = (await res.json()) as { success: boolean; message?: string };
if (!res.ok || !json.success) {
setError("root.server", {
message: json.message ?? "Something went wrong. Please try again.",
});
}
} catch {
setError("root.server", { message: "Network error. Check your connection." });
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("name")} placeholder="Name" />
{errors.name && <p role="alert">{errors.name.message}</p>}
<input {...register("email")} type="email" placeholder="Email" />
{errors.email && <p role="alert">{errors.email.message}</p>}
<textarea {...register("message")} placeholder="Message" />
{errors.message && <p role="alert">{errors.message.message}</p>}
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
{isSubmitSuccessful && <p>Thanks, your message was sent.</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send"}
</button>
</form>
);
}What each part does:
access_keygoes in the request body, not the schema. Zod strips keys it does not know, and the access key is not user input, so it is added when the request is built. The key is a public form identifier and is safe in client code.- The endpoint accepts JSON, as well as
multipart/form-dataand URL-encoded bodies. On success it returns{ success: true }. On failure it returnssuccess: falsewith amessageyou can show the user. setError("root.server", ...)records a form-level error that is not tied to any field. It also keepsisSubmitSuccessfulfalse. React Hook Form does not keep root errors between submits, so the message clears on the next attempt.reset()runs in an effect. The React Hook Form docs recommend resetting inuseEffectafter a successful submit.keepIsSubmitSuccessfulkeeps the thank-you message on screen. The React Hook Form reset() guide covers the other reset options.
If you send JSON from localhost and get a CORS error, check the form's Allowed Domains list first; the CORS fix guide walks through it. The free plan includes 500 submissions in total, and you can get an access key in under a minute.
Client-side Zod is not a security boundary
Zod in the browser is for the user's benefit: fast feedback and clean, typed data. It does not stop anyone from skipping your form and posting straight to the endpoint. The splitforms endpoint does its own server-side checks on every request (a valid access key, payload size limits, rate limiting, spam filtering and the honeypot), but it does not know your Zod schema. If the same data also reaches your own API or database, export the schema from a shared file and run contactSchema.safeParse(body) on the server too. Defining the rules once and checking them on both sides is the main reason to use Zod with React Hook Form.
FAQ
Why is zodResolver not working?
Check five things. (1) Import it from '@hookform/resolvers/zod', not from the package root. (2) If you use Zod 4, you need @hookform/resolvers 5.1.0 or newer; older versions only understand Zod 3 and fail with type errors. (3) A resolver replaces the built-in rules, so register('email', { required: true }) is ignored once a resolver is set. Put every rule in the schema. (4) Give every field a default value. A field that is undefined fails its type check, and Zod then skips object-level .refine() checks such as password confirmation. (5) Inputs return strings, so z.number() needs valueAsNumber or z.coerce.number().
Does React Hook Form work with Zod v4?
Yes. @hookform/resolvers added Zod 4 support in version 5.1.0 (June 2025), including Zod Mini, while keeping Zod 3 working. The import stays the same: zodResolver from '@hookform/resolvers/zod' and z from 'zod'. Zod 4 changes some schema APIs: use z.email() instead of z.string().email() (deprecated), and the error option instead of message (message still works but is deprecated).
How do I show only the first error?
Per field, React Hook Form already does this: criteriaMode defaults to 'firstError', so errors.email.message holds one message even if several rules failed. Set criteriaMode: 'all' to collect every message in errors.email.types. To show a single message for the whole form, read the first entry: const first = Object.values(errors)[0]; then render first?.message.
Should I type useForm with z.infer or z.input?
If the schema has no transforms, defaults or coercion, input and output are the same and useForm<z.infer<typeof schema>> is fine. If it uses .default(), z.coerce or .transform(), the input and output types differ. Either leave the generic off and let zodResolver infer both, or pass all three: useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>.
How do I validate a field against an API with Zod?
Use an async .refine() on that field. zodResolver runs Zod's parseAsync by default, so async refinements work. The resolver parses the whole schema on every validation pass, so set mode and reValidateMode to 'onBlur' or the request fires on every keystroke, and add abort: true to cheaper checks so the API call is skipped when the value is already invalid.
Do I still need server-side validation if I use Zod on the client?
Yes. Client-side Zod improves the user experience, but anyone can send a POST request without going through your form. If you post to the splitforms endpoint, it runs its own server-side checks (a valid access key, payload size limits, rate limiting, spam filtering and the honeypot), but it does not know your Zod schema. If the data also reaches your own API, run the same schema there with schema.safeParse().
