50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
reactMay 23, 2026·8 min read

Form Validation in React: Zod vs Valibot vs Native — Pick the Right Tool

Three solid approaches to form validation in React, when to use each, and why we stopped reaching for Zod by default.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Form Validation in React: Zod vs Valibot vs Native — Pick the Right Tool

Form validation is one of those things that seems simple until you're debugging why a user's perfectly valid phone number keeps getting rejected at 11pm on a Friday. We've shipped enough forms across enough projects to have strong opinions here — and to have made most of the obvious mistakes at least once.

There are roughly three paths you can take: native browser validation, a schema-first library like Zod or Valibot, or building your own lightweight validation logic. All three are valid. All three will frustrate you in different ways. Let's actually compare them instead of just saying "it depends" and leaving you hanging.

Native HTML Validation: Underrated, Not Enough

Before reaching for any library, it's worth remembering what browsers give you for free. The `required`, `minLength`, `maxLength`, `pattern`, `type="email"`, `type="url"` attributes are real, built-in, and zero-bundle-cost. For simple forms — a contact form, a newsletter signup — they're actually fine.

export function ContactForm() {
  return (
    <form action="/api/contact" method="POST">
      <input
        name="email"
        type="email"
        required
        placeholder="you@example.com"
      />
      <input
        name="name"
        type="text"
        required
        minLength={2}
        maxLength={100}
      />
      <textarea
        name="message"
        required
        minLength={10}
        maxLength={1000}
      />
      <button type="submit">Send</button>
    </form>
  );
}

The problems show up fast though. Native validation messages are ugly and you can't customize them consistently across browsers. You lose control the moment you want conditional validation (validate this field only if that checkbox is checked). And if you're doing any kind of async validation — checking if a username is taken — you're on your own.

The other big issue: native validation only fires on submit by default. Users who fill in a form incorrectly and hit submit don't get real-time feedback. That's a UX problem you'll hear about from your users.

Use native validation for throwaway forms, internal tools, or when you genuinely cannot justify the bundle cost of a library. For anything customer-facing with more than 3 fields, use a schema library.

Zod: The Safe Default That Has a Weight Problem

Zod became the default validation library for TypeScript projects for good reasons. The API is excellent. The TypeScript inference is genuinely impressive — you define a schema once and get both runtime validation and compile-time types from the same source of truth. If you've never written `z.infer<typeof schema>` and had it just work, you're missing out.

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

const signupSchema = z.object({
  email: z.string().email('Please enter a valid email'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain at least one uppercase letter')
    .regex(/[0-9]/, 'Must contain at least one number'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
});

type SignupFormData = z.infer<typeof signupSchema>;

export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupFormData>({
    resolver: zodResolver(signupSchema),
  });

  const onSubmit = (data: SignupFormData) => {
    console.log('Valid data:', data);
  };

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

      <input {...register('password')} type="password" placeholder="Password" />
      {errors.password && <span>{errors.password.message}</span>}

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

      <button type="submit">Sign up</button>
    </form>
  );
}

The cross-field validation with `.refine()` is where Zod really shines. Confirming passwords, validating that an end date is after a start date, making one field required only if another has a specific value — all of this is clean with Zod's refine and superRefine methods.

The downside nobody likes to talk about: Zod is not small. As of v3, it's around 14kb minified+gzipped. Not catastrophic, but not nothing either. If you're shipping a marketing site where load time matters, or you're building a form that lives in a heavily trafficked component, that cost adds up. The Zod team is working on Zod v4 with better tree-shaking and smaller bundle size, so this might be less of an issue soon — but right now it's real.

Valibot: Smaller, Different Mental Model, Still Growing

Valibot came out of frustration with Zod's bundle size. It's built around a pipe-based composition model rather than method chaining, which means individual validators are tiny and tree-shakeable. In practice, a typical validation schema with Valibot can be 10x smaller in your final bundle than the equivalent Zod schema.

import * as v from 'valibot';
import { useForm } from 'react-hook-form';
import { valibotResolver } from '@hookform/resolvers/valibot';

const signupSchema = v.pipe(
  v.object({
    email: v.pipe(
      v.string(),
      v.email('Please enter a valid email')
    ),
    password: v.pipe(
      v.string(),
      v.minLength(8, 'Password must be at least 8 characters'),
      v.regex(/[A-Z]/, 'Must contain at least one uppercase letter'),
      v.regex(/[0-9]/, 'Must contain at least one number')
    ),
    confirmPassword: v.string(),
  }),
  v.forward(
    v.partialCheck(
      [['password'], ['confirmPassword']],
      (input) => input.password === input.confirmPassword,
      "Passwords don't match"
    ),
    ['confirmPassword']
  )
);

type SignupFormData = v.InferOutput<typeof signupSchema>;

export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupFormData>({
    resolver: valibotResolver(signupSchema),
  });

  // same JSX as before...
}

The API is a bit more verbose for cross-field validation — `v.forward` and `v.partialCheck` aren't as intuitive as Zod's `.refine()`. But you get used to it, and the bundle savings are worth it if you care about that sort of thing.

The ecosystem is smaller. If you're looking for third-party integrations, tutorials, or Stack Overflow answers, Zod wins by a wide margin. Valibot works great but you're more on your own when something weird happens. We've hit a few edge cases with Valibot and the GitHub issues were more helpful than existing documentation.

When to Skip react-hook-form

We've been assuming react-hook-form throughout, which is the right call 90% of the time. It's fast, it handles controlled vs uncontrolled inputs well, and the resolver pattern means you can swap your validation library without rewriting your form logic. But there are cases where you don't need it.

If you're using Next.js Server Actions and progressive enhancement is a priority, you might want to handle validation differently. Server Actions let you skip client-side JS entirely for the happy path, which means client-side validation is an enhancement, not the main path. Here's a pattern we use for Server Action forms where we want both — server validation that works without JS, and client validation for snappier feedback when JS is available:

'use server';

import { z } from 'zod';

const contactSchema = z.object({
  email: z.string().email(),
  message: z.string().min(10).max(1000),
});

export async function submitContact(prevState: unknown, formData: FormData) {
  const rawData = {
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const result = contactSchema.safeParse(rawData);

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    };
  }

  // actually send the email...
  await sendContactEmail(result.data);

  return { success: true, errors: null };
}

The `safeParse` method is your friend for Server Actions. It returns a result object instead of throwing, which is what you want when you're passing state back through `useActionState`. The `.flatten().fieldErrors` call gives you a record of field names to error message arrays, which maps cleanly to your form's error display logic.

The Mistake Everyone Makes: Validating Only on the Client

This needs to be said clearly because we still see it in code reviews: client-side validation is a UX feature, not a security feature. Anyone can open DevTools and submit whatever they want to your API. If you're only validating with Zod in your React form and trusting that data on the server, you have a problem.

  • Always validate on the server — whether that's a Server Action, an API route, or a tRPC procedure
  • Use the same schema for both client and server validation to avoid drift
  • If using API routes, consider sharing schema files between frontend and backend directories
  • Never trust form data to be the shape you expect, even from your own frontend

The nice thing about Zod and Valibot is that their schemas run equally well in Node.js and in the browser. You literally import the same schema file in both places. There's no excuse not to validate on both ends.

Our Decision Framework

After shipping dozens of forms, here's how we actually decide:

  • Internal admin tool or low-traffic form → native validation or a very thin custom hook
  • Customer-facing form, bundle size not critical → Zod + react-hook-form. The ecosystem and DX are worth the weight.
  • Public-facing form on a marketing or e-commerce page → Valibot. Every kb counts for conversion rates.
  • Server Action form with progressive enhancement → validate on server with safeParse, add client validation as a layer on top
  • Multi-step wizard with complex cross-field rules → Zod with refine/superRefine, it's just better at this

We use Zod in the peal.dev templates because most of our users are building SaaS products where the extra 14kb doesn't move any needle, and the ecosystem benefits are real. But we keep the schema definitions in a shared location so swapping to Valibot later is a two-hour job, not a two-day rewrite.

The best validation library is the one your team already knows and won't argue about. Pick one, be consistent, and validate on the server. Everything else is optimization.

One Pattern Worth Stealing: Shared Schema Files

Whatever library you pick, organize your schemas in a way that makes sharing between client and server natural. We like a `src/lib/schemas/` directory with one file per domain area:

// src/lib/schemas/auth.ts
import { z } from 'zod';

export const loginSchema = z.object({
  email: z.string().email('Enter a valid email'),
  password: z.string().min(1, 'Password is required'),
});

export const signupSchema = z.object({
  email: z.string().email('Enter a valid email'),
  password: z
    .string()
    .min(8, 'At least 8 characters')
    .regex(/[^a-zA-Z0-9]/, 'Must contain a special character'),
  name: z.string().min(2, 'Name is too short').max(100),
});

// Types derived from schemas — single source of truth
export type LoginData = z.infer<typeof loginSchema>;
export type SignupData = z.infer<typeof signupSchema>;

// src/app/actions/auth.ts  — imports the same schema
import { signupSchema } from '@/lib/schemas/auth';

export async function signup(prevState: unknown, formData: FormData) {
  const result = signupSchema.safeParse(
    Object.fromEntries(formData)
  );
  // ...
}

// src/components/SignupForm.tsx — also imports the same schema
import { signupSchema, SignupData } from '@/lib/schemas/auth';
import { zodResolver } from '@hookform/resolvers/zod';

This pattern means you change the validation rules in one place and both client and server immediately reflect the change. We've been burned before by having validation logic duplicated — the server let through an email format the client rejected, support tickets followed. Now we have a rule: one schema definition, used everywhere.

If you want to see this pattern in action in a real app, our templates at peal.dev ship with auth forms, settings forms, and billing forms all set up this way — schemas in one place, used in both Server Actions and client-side react-hook-form resolvers. It's the kind of boring infrastructure that just works.

Pick your library, set up your schema directory, validate on the server. Stop debating it and start shipping the actual product.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders