Skip to content

Form Validation with Zod

Aman Kumar Singh7 min read
Part 31 of 50From the React & Next.js Complete Guide series
Form Validation with Zod — article by Aman Kumar Singh

In Building Forms in React, I covered how to structure controlled inputs, wire up React Hook Form, and keep form state out of the render path where it doesn't belong. What I deliberately left out was validation logic, because validation deserves its own treatment. Get it wrong and you either annoy users with rules that don't match the backend, or worse, you let bad data through because the client and server disagree about what "valid" means.

This is the next entry in the React & Next.js Complete Guide series. The pattern I use across almost every production form at this point is a single Zod schema, shared between client and server, with React Hook Form's resolver doing the wiring on the frontend. It's one of several ways to validate a form, but it's the one that has caused me the least pain when requirements change six months in.

Why schema validation beats ad hoc checks

The naive approach to form validation is a pile of if statements: check if the email has an @, check if the password is long enough, check if the date is in the future. Each check is easy to write and easy to get subtly wrong. The real problem shows up later, when the same rules need to exist in three places: the client form, the API route that receives the submission, and maybe a background job that reprocesses the same data. Ad hoc checks drift. Someone updates the client-side minimum password length and forgets the server still enforces the old one.

A schema-based validator like Zod fixes this by making the rules themselves the artifact, instead of leaving them scattered across whichever call site happens to implement them first. You define a schema once:

import { z } from "zod";

export const signUpSchema = z
  .object({
    email: z.string().email("Enter a valid email address"),
    password: z
      .string()
      .min(10, "Password must be at least 10 characters")
      .regex(/[A-Z]/, "Include at least one uppercase letter")
      .regex(/[0-9]/, "Include at least one number"),
    confirmPassword: z.string(),
    companyName: z.string().trim().min(2).max(80),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords do not match",
    path: ["confirmPassword"],
  });

export type SignUpInput = z.infer<typeof signUpSchema>;

That last line matters as much as the schema itself. z.infer gives you a TypeScript type derived directly from the validation rules, so the type and the runtime check can never drift apart. I've seen teams maintain a hand-written interface next to a validation function, and inevitably one gets updated without the other. With Zod, the type is a projection of the schema itself, so there's no separate source of truth to keep in sync by hand.

Wiring Zod into React Hook Form

If you're using React Hook Form (and per the previous article, that's the default I reach for once a form has more than two or three fields), the integration is a single resolver:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signUpSchema, type SignUpInput } from "./schemas/sign-up-schema";

function SignUpForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignUpInput>({
    resolver: zodResolver(signUpSchema),
    mode: "onBlur",
  });

  const onSubmit = async (data: SignUpInput) => {
    const response = await fetch("/api/sign-up", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });

    if (!response.ok) {
      const payload = await response.json();
      throw new Error(payload.message ?? "Sign up failed");
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} placeholder="Email" />
      {errors.email && <p role="alert">{errors.email.message}</p>}

      <input type="password" {...register("password")} placeholder="Password" />
      {errors.password && <p role="alert">{errors.password.message}</p>}

      <input
        type="password"
        {...register("confirmPassword")}
        placeholder="Confirm password"
      />
      {errors.confirmPassword && (
        <p role="alert">{errors.confirmPassword.message}</p>
      )}

      <input {...register("companyName")} placeholder="Company name" />
      {errors.companyName && <p role="alert">{errors.companyName.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        Create account
      </button>
    </form>
  );
}

Notice that useForm is generic over SignUpInput, the inferred Zod type. That's what gives you autocomplete on register("email") and a compile error if you typo a field name. The mode: "onBlur" setting is a small but deliberate choice: validating on every keystroke (onChange) tends to feel aggressive to users who are still mid-thought on a field, while validating only on submit means someone can fill out an entire long form before finding out the email field was wrong the whole time. Blur validation is the middle ground I default to, though for password strength indicators, real-time feedback is worth the tradeoff.

Sharing the schema with the backend

The part that actually pays off is reusing the exact same schema on the server. If you're on NestJS, this usually means wrapping the Zod schema in a validation pipe rather than relying solely on class-validator DTOs:

import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common";
import { ZodSchema } from "zod";

@Injectable()
export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}

  transform(value: unknown) {
    const result = this.schema.safeParse(value);
    if (!result.success) {
      throw new BadRequestException(result.error.flatten());
    }
    return result.data;
  }
}
@Post("sign-up")
@UsePipes(new ZodValidationPipe(signUpSchema))
async signUp(@Body() body: SignUpInput) {
  return this.authService.createAccount(body);
}

This is the piece I'd call the actual point of the article: the frontend form and the API endpoint are validating against the literal same schema object, imported from a shared package or a monorepo path. There's no separate "backend rules" file that someone forgets to update. If you tighten the password regex, both sides tighten at once, at compile time if you're in a monorepo with project references, or at deploy time if the schema lives in a published package.

This only works cleanly if your project structure supports sharing code between frontend and backend. In a monorepo, that's a shared schemas or validation package. Without a monorepo, you either duplicate the schema (and accept the drift risk you were trying to avoid) or publish it as a small internal npm package. I wouldn't set up a monorepo purely for this reason, but if you already have one, sharing validation schemas is one of the highest-value uses of that setup.

Tradeoffs and when Zod is overkill

Zod is not free. It adds a dependency, a bit of bundle size on the client, and a learning curve for engineers who've only used class-validator decorators or manual checks. For a two-field contact form, reaching for a schema library and a resolver is more ceremony than the form deserves; three if statements would do the job and be easier for a new contributor to read.

The tradeoff tips toward Zod once any of these are true: the form has more than four or five fields, the validation rules need to be shared with an API or a background job, the data shape needs to survive a refactor without silently breaking, or you're validating something other than form input, like environment variables or webhook payloads, where TypeScript's compile-time types give you no protection at runtime. I use Zod for process.env validation on every Node service now, for exactly the reason it's good at forms: the schema is the single source of truth for what a valid value looks like, and safeParse fails loudly instead of letting undefined slip through into business logic three functions later.

One tradeoff worth calling out explicitly: async validation, like checking whether an email is already registered, doesn't fit neatly into a synchronous schema. Zod supports .refine() with an async function, and React Hook Form's resolver will await it, but this means every keystroke or blur event can trigger a network request if you're not careful. Debounce it. Or move that specific check out of the schema and into a dedicated onBlur handler that only fires for the one field that needs it. Don't let the elegance of "one schema, one source of truth" tempt you into cramming a database lookup into .refine() without rate limiting yourself first.

Common production pitfalls

A few mistakes show up repeatedly once teams start relying on Zod for both sides of a form:

Trusting z.infer types without running safeParse at the actual API boundary. The type only exists at compile time; a malformed request body doesn't know or care what your TypeScript types say. Always parse at the edge, whether that's an API route, a queue consumer, or a webhook handler.

Returning raw Zod error objects to the client. result.error.flatten() is convenient but exposes internal field names and message structure verbatim. For public-facing APIs, map errors into a stable shape your frontend expects, so a Zod version bump doesn't silently break your error rendering.

Over-nesting .refine() calls for business rules that have nothing to do with shape validation. "Is this coupon code still valid" is a database question. It has nothing to do with the shape of the data. Keep Zod for structural and format checks, and keep business rules in your service layer where they belong.

Key takeaways

  • Zod lets you define validation rules once and derive both the runtime check and the TypeScript type from the same schema, which prevents the type and the check from drifting apart.
  • `zodResolver` from `@hookform/resolvers/zod` is a thin, well-maintained bridge between a Zod schema and React Hook Form; it doesn't change how you write the schema.
  • The real payoff comes from sharing the schema between client and server, most easily in a monorepo, so validation rules can't silently diverge between the two.
  • Async checks like uniqueness validation don't belong inside the core schema without debouncing; keep them isolated so they don't fire on every keystroke.
  • Reserve Zod for structural validation. Business logic, like whether a coupon is still active, belongs in your service layer, not in a `.refine()` chain.
  • For small, low-field-count forms, plain conditional checks are often simpler and easier for a new contributor to read than introducing a schema library.

Frequently asked questions

Do I need Zod if I'm already using React Hook Form's built-in validation rules?

React Hook Form's built-in rules (`required`, `minLength`, `pattern`, and so on) work fine for simple, isolated forms. Zod becomes worth the extra dependency once you need to reuse the same rules on the server, derive TypeScript types automatically, or express cross-field rules like password confirmation cleanly.

Can I use Zod with Next.js Server Actions instead of a traditional API route?

Yes. The pattern is the same: call `schema.safeParse(formData)` (or `parseAsync` if you have async refinements) at the top of the server action, before touching the database, and return a typed error object if it fails. Server Actions don't remove the need for server-side validation just because the form lives in the same codebase.

How do I handle validation errors that come back from the server but weren't caught by the client schema?

Keep a generic error slot in your form state for server-side errors that don't map to a specific field, such as "email already registered." React Hook Form's `setError` method lets you attach a server-returned error to a specific field name after the fact, so it renders in the same place a client-side error would.

Is Zod slower than writing plain validation functions?

For typical form-sized payloads, the difference isn't something you'd notice in practice. The parsing overhead scales with schema complexity and payload size, and forms rarely have either at a scale where this matters. If you're validating very large payloads or doing it in a hot path processing thousands of requests per second, it's worth profiling your specific case rather than assuming either direction.

What's the difference between `.parse()` and `.safeParse()`?

`.parse()` throws a `ZodError` on failure, which is fine if you're already inside a try/catch block and want the exception to propagate. `.safeParse()` returns a discriminated union (`{ success: true, data }` or `{ success: false, error }`) so you can handle validation failures without exceptions. I use `safeParse` almost everywhere in request handlers because it keeps error handling explicit rather than relying on exception flow.

Does Zod work well with file uploads in forms?

Zod validates the metadata around a file (size limits, MIME type from the browser's `File` object) reasonably well, but it can't inspect actual file contents to confirm the MIME type wasn't spoofed. Treat client-side file validation as a UX nicety; do the real content-type and size verification server-side, ideally by inspecting file signatures rather than trusting the reported MIME type.

Related articles

Error Handling and Loading States — Aman Kumar Singh
Core Web Vitals for React Apps — Aman Kumar Singh
Image and Asset Optimization — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.