Lesson 9

Schema Validation with Zod

Writing validation rules inline with register works, but it gets repetitive fast. Every field gets its own set of rules, and if you also need to validate the same data on the server or in an API route, you're writing those rules twice. Zod lets you define your validation schema once, declaratively, and use it everywhere — form validation, API payloads, type inference. React Hook Form integrates with it through the @hookform/resolvers package.

Setup

Install Zod and the resolvers package, then import both at the top of your component file:

pnpm install zod @hookform/resolvers
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

The zodResolver function is an adapter that translates Zod's validation output into the format React Hook Form expects.

Defining a Form Schema

Instead of scattering validation rules across individual register calls, you define them all in one place as a Zod schema:

const formSchema = z.object({
  firstName: z.string().min(1, "Required").min(3, "Too short"),
  email: z.string().email("Invalid email"),
  age: z.coerce.number().min(18, "Must be 18+"),
});

Each field maps to a Zod type with chained validation methods. The .min(1, "Required") check on firstName ensures the field isn't empty — Zod's string() allows empty strings by default, so you need this explicit check. The .coerce.number() on age converts the string from the input into a number before validating, saving you from doing that conversion yourself.

Passing the Resolver to useForm

To connect your schema to the form, pass it through zodResolver in the resolver option:

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm({
  resolver: zodResolver(formSchema),
});

When the user submits, React Hook Form hands the form data to the resolver. It runs the Zod schema, collects any errors, and maps them back to the correct field paths. You never call schema.parse() yourself.

What Happens to register

This is the key thing to understand: when you use a resolver, field-level validation rules in register are bypassed. The schema handles all validation. You still use register to wire up your inputs — it still provides onChange, onBlur, ref, and name — but you skip the second argument entirely.

// Before: validation rules in register
<input {...register("email", { required: "Required", pattern: { value: /.../, message: "Invalid" } })} />

// After: register just wires up the input, schema handles validation
<input {...register("email")} />

This is a clean separation. register connects the input to the form. The Zod schema validates the data. They don't step on each other.

Type Inference

One of the biggest benefits of Zod is automatic TypeScript type inference. Instead of maintaining a separate type or interface for your form data, derive it from the schema and pass it as a generic to useForm:

type FormValues = z.infer<typeof formSchema>;
// { firstName: string; email: string; age: number }

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<FormValues>({
  resolver: zodResolver(formSchema),
});

Now register only accepts valid field names, errors is properly typed, and the data argument in your onSubmit handler has the correct shape. Add a field to the schema and the type updates automatically — no separate interface to keep in sync.

Error Display

Errors surface in formState.errors exactly the same way as inline validation. The resolver maps Zod's error paths to the right fields, so your display code doesn't change — errors.email?.message works just like before. If a field has multiple chained validations (like .min(1).min(3) on firstName), the resolver reports the first failure.

Complete Example

Here's a registration form that uses Zod for all validation:

import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const formSchema = z.object({
  firstName: z.string().min(1, "Required").min(3, "Too short"),
  email: z.string().email("Invalid email"),
  age: z.coerce.number().min(18, "Must be 18+"),
});

type FormValues = z.infer<typeof formSchema>;

function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(formSchema),
  });

  const onSubmit = async (data: FormValues) => {
    await new Promise((resolve) => setTimeout(resolve, 2000));
    console.log("Registered:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="firstName">First Name</label>
        <input id="firstName" {...register("firstName")} />
        {errors.firstName && (
          <p style={{ color: "red" }}>{errors.firstName.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register("email")} />
        {errors.email && (
          <p style={{ color: "red" }}>{errors.email.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="age">Age</label>
        <input id="age" type="number" {...register("age")} />
        {errors.age && (
          <p style={{ color: "red" }}>{errors.age.message}</p>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Submitting..." : "Register"}
      </button>
    </form>
  );
}

export default RegistrationForm;

The schema lives at the top where it's easy to find. Every register call is a clean one-liner — no validation rules cluttering the JSX. The FormValues type is derived from the schema, so there's no separate interface to maintain.

Note that @hookform/resolvers isn't limited to Zod — it also supports Yup, Superstruct, Joi, Vest, and others. We focus on Zod because it's the most popular choice in the React ecosystem today, and its TypeScript-first design makes it a natural fit for React Hook Form. One schema, three fields, zero hand-written validator functions — and you can import that same schema into your API route handler to validate the exact same data on the server.