splitforms.com
← Back to the journal

React Hook Form + Zod: Schema Validation Step by Step (2026)

Wire Zod into React Hook Form with zodResolver: install, define a schema, infer types, show errors, validate async, and submit the parsed data to a form…

Start free — 500 submissionsSee pricing →No card, no time limit. Pro is $5/mo.
React Hook Form + Zod: Schema Validation Step by Step (2026) article cover
Short answer

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.

Try it on your own form

Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.

Create free account

The 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/resolvers

The 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() and z.uuid() are top-level now. The method forms such as z.string().email() still work but are deprecated.
  • Custom messages go in the error option. 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 is undefined, which fails a z.string() type check with a generic message.
  • Add noValidate to the form so the browser's own type="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 keep role="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: true converts the value in React Hook Form. An empty input becomes NaN, which Zod 4 rejects as an invalid type. The error option on z.number() gives that case a readable message.
  • Optional numbers need setValueAs, because NaN is not undefined and .optional() would still fail.
  • z.coerce.number() runs Number(input), and Number("") is 0. An empty field passes unless you add a .min(). In Zod 4 the input type of every coerce schema is unknown, so do not pin useForm to a single z.infer type 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 default reValidateMode: "onChange" after the first submit, that means one request per keystroke. Use onBlur for both, or debounce inside the refine.
  • abort: true on 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" }) uses parse(), 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 setError with 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 per reValidateMode.
  • 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_key goes 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-data and URL-encoded bodies. On success it returns { success: true }. On failure it returns success: false with a message you can show the user.
  • setError("root.server", ...) records a form-level error that is not tied to any field. It also keeps isSubmitSuccessful false. 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 in useEffect after a successful submit. keepIsSubmitSuccessful keeps 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().

Related articles

More practical guidance from tutorials.

Browse the journal →
Tutorials

FormSubmit File Uploads: How Attachments Work (and Their Limits)

Yes, FormSubmit.co supports file attachments: set enctype="multipart/form-data" and add a fi…

8 min readRead →
Tutorials

React Hook Form reset(): Clear, Reset and Repopulate a Form

How reset() works in React Hook Form: clearing after submit, resetting to new default values…

8 min readRead →
Tutorials

React Hook Form useFieldArray: Dynamic Fields Guide (2026)

Build add/remove field lists with useFieldArray: append, remove, move, keys, validation per…

10 min readRead →

Explore this topic

Start with the overview, then move into focused guides.

OverviewContact Forms for Every FrameworkStart here →GuideContact Form for Next.jsRead →GuideContact Form for ReactRead →GuideContact Form for VueRead →ReferenceContact form guideOpen →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 500 free submissions — no card, no time limit.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe