EmpireUI
Get Pro
← Blog9 min read#react hook form#zod#validation

react-hook-form + Zod: Full Type-Safe Form Validation in 2026

Stop writing duplicate validation logic. Here's how to wire react-hook-form v7 with Zod schemas for end-to-end type safety with zero runtime surprises.

TypeScript code on a dark monitor screen with form validation logic

Why This Combo Still Wins in 2026

Forms are where type safety goes to die. You've got a TypeScript schema on the backend, a Zod schema somewhere in a lib folder, and then a completely separate set of string-based field names in your form that nobody's keeping in sync. It's a mess. react-hook-form v7 paired with Zod via @hookform/resolvers finally kills that problem dead.

Honestly, the reason this pairing dominates over alternatives like Formik + Yup is raw performance and zero re-renders. react-hook-form uses uncontrolled inputs under the hood, which means your component isn't re-rendering on every keystroke. Zod handles the type inference so you get autocomplete on field names and TypeScript screams at you at compile time — not at 2am in production.

In practice, the boilerplate you write once covers both client-side validation feedback AND gives you a typed data object in onSubmit that you can hand straight to a tRPC mutation or a fetch call. No manual casting. No as UserInput. Just types that flow.

Installing and Wiring Up the Resolver

Start with the right packages. As of 2026, you want react-hook-form v7.51+, zod v3.22+, and the resolver package:

npm install react-hook-form zod @hookform/resolvers

That's it. Three packages. Worth noting: @hookform/resolvers v3 ships a zodResolver that accepts your schema directly — no adapters, no wrappers. Drop it straight into useForm.

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

const schema = z.object({
  email: z.string().email('Enter a valid email'),
  password: z.string().min(8, 'At least 8 characters'),
  age: z.coerce.number().min(18, 'Must be 18 or older'),
})

type FormValues = z.infer<typeof schema>

export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
  })

  const onSubmit = async (data: FormValues) => {
    // data is fully typed here — no casting
    await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(data),
    })
  }

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

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

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Log in'}
      </button>
    </form>
  )
}

Notice z.coerce.number() on the age field. That's doing something subtle — HTML inputs always return strings, so without coercion you'd get a type error at runtime even though TypeScript thinks it's a number. Always coerce numeric fields.

Building Schemas That Actually Reflect Your Domain

The real power shows up when you write Zod schemas that encode actual business rules, not just "field is required". Can you do that with a 5-line schema? Absolutely. But you'd be leaving a lot on the table.

const registerSchema = z
  .object({
    username: z
      .string()
      .min(3)
      .max(20)
      .regex(/^[a-z0-9_]+$/, 'Lowercase letters, numbers, underscores only'),
    email: z.string().email(),
    password: z.string().min(12),
    confirmPassword: z.string(),
    role: z.enum(['user', 'admin', 'editor']),
    newsletterOptIn: z.boolean().default(false),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords don't match",
    path: ['confirmPassword'], // attach error to the right field
  })

type RegisterValues = z.infer<typeof registerSchema>

That .refine() at the end is key. Cross-field validation — like confirming passwords match — lives in a single place. The path option tells react-hook-form which field to attach the error to, so errors.confirmPassword.message just works.

One more thing — that z.enum(['user', 'admin', 'editor']) feeds directly into a <select> without you maintaining a separate array of options. Pull it out:

const roles = registerSchema.shape.role.options
// ['user', 'admin', 'editor'] — derived from your schema

<select {...register('role')}>
  {roles.map((r) => (
    <option key={r} value={r}>{r}</option>
  ))}
</select>

That means if you add a new role to the schema, the dropdown updates automatically. No drift.

Controlled Components and UI Libraries

The register spread works great for native inputs, but falls apart with component libraries that expect value and onChange — things like a custom date picker, a rich select, or any of the interactive inputs you'd find in a design system. That's where Controller comes in.

import { Controller } from 'react-hook-form'

// Example: wiring a custom NumberInput that doesn't accept register
<Controller
  name="price"
  control={control}
  render={({ field, fieldState }) => (
    <NumberInput
      value={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
      error={fieldState.error?.message}
    />
  )}
/>

Quick aside: field.ref also exists in that render prop, and passing it to your component enables the library to focus the erroring field on submit. Most devs skip this and then wonder why the form doesn't scroll to the error on mobile. Don't skip it.

If you're building these inputs from scratch, you might want to check out Empire UI's form components — the library ships inputs already wired for accessibility with 16px font sizes on mobile so iOS doesn't zoom, which is the kind of detail that bites you at launch.

// Pattern for building a reusable FormField wrapper
function FormField({
  name,
  control,
  label,
  children,
}: {
  name: string
  control: Control<any>
  label: string
  children: (field: ControllerRenderProps) => React.ReactNode
}) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <div className="field-wrapper">
          <label htmlFor={name}>{label}</label>
          {children(field)}
          {fieldState.error && (
            <span role="alert" className="error-msg">
              {fieldState.error.message}
            </span>
          )}
        </div>
      )}
    />
  )
}

Async Validation and Server Errors

What happens when a username is already taken — but you only know that after hitting the API? This is where a lot of tutorials drop the ball. They show you perfect happy-path schemas and leave you to figure out server errors yourself.

There are two approaches. First: async refinements in Zod itself:

const usernameSchema = z.object({
  username: z.string().min(3).refine(
    async (val) => {
      const res = await fetch(`/api/check-username?u=${val}`)
      const { available } = await res.json()
      return available
    },
    { message: 'Username already taken' }
  ),
})

// Use zodResolver with async mode explicitly
const form = useForm({
  resolver: zodResolver(usernameSchema, undefined, { mode: 'async' }),
})

That works, but it fires on every validation pass. In practice, you'd pair this with a debounced watch and only validate the username field after the user stops typing for 400ms. That said, the simpler approach for most forms is to keep your Zod schema synchronous and use setError to plumb server errors back in manually:

const onSubmit = async (data: FormValues) => {
  try {
    await createUser(data)
  } catch (err) {
    if (err.code === 'USERNAME_TAKEN') {
      setError('username', {
        type: 'server',
        message: 'That username is taken',
      })
    } else {
      setError('root', { message: 'Something went wrong, try again' })
    }
  }
}

// In JSX:
{errors.root && <div role="alert">{errors.root.message}</div>}

Look, setError('root', ...) for top-level server errors is massively underused. It's the cleanest way to show non-field-specific errors without hacking in an external state variable.

Sharing Schemas Between Client and Server

Here's where the whole setup clicks into place. The same Zod schema you use in your form can validate data on the server. One schema file, imported in two places — the API route and the form component.

// lib/schemas/user.ts
export const createUserSchema = z.object({
  email: z.string().email(),
  username: z.string().min(3).max(20),
  password: z.string().min(12),
})

export type CreateUserInput = z.infer<typeof createUserSchema>
// app/api/users/route.ts (Next.js App Router)
import { createUserSchema } from '@/lib/schemas/user'

export async function POST(req: Request) {
  const body = await req.json()
  const parsed = createUserSchema.safeParse(body)

  if (!parsed.success) {
    return Response.json(
      { errors: parsed.error.flatten().fieldErrors },
      { status: 400 }
    )
  }

  // parsed.data is fully typed
  await db.user.create({ data: parsed.data })
  return Response.json({ ok: true })
}

That .safeParse() on the server means even if someone bypasses your frontend entirely and hits the API directly with garbage data, the schema catches it. No trust issues. If you're using tRPC, this gets even tighter — pass the schema directly as your input validator and tRPC generates the types automatically, both client and server.

This pattern eliminates an entire class of bugs. Did you ever ship a form where the client said the field was optional but the server required it? Yeah. This stops that.

For building out the UI side of these forms, Empire UI's component library includes pre-styled form primitives. And if you want to style inputs to match a glassmorphism or neumorphism theme, the glassmorphism generator spits out the exact CSS backdrop-filter values you need — handy when you're building auth screens that need to actually look good.

Performance, DevEx, and a Few Gotchas

react-hook-form's default validation mode is onSubmit, which is the least annoying for users. You can flip it to onChange or onBlur with the mode option in useForm. In practice, onBlur is the sweet spot — it validates after the user leaves a field instead of while they're still typing, which stops that jarring "email is invalid" flash before they've even finished typing their address.

const form = useForm<FormValues>({
  resolver: zodResolver(schema),
  mode: 'onBlur',          // validate on blur
  reValidateMode: 'onChange', // re-validate on change after first submit
  defaultValues: {
    email: '',
    password: '',
    role: 'user',
  },
})

Always provide defaultValues. If you don't, react-hook-form initializes fields as undefined, which causes React's controlled/uncontrolled input warning when you later set a value. This is one of the top three issues people hit with this library.

Worth noting: Zod's error messages are decent defaults, but you can override them globally using z.setErrorMap() if you need i18n or custom phrasing across your entire app. Or just pass the message inline at the schema level, which keeps things colocated and obvious.

One more thing — if you're debugging why a field isn't validating, install the React DevTools browser extension and look at the form state. react-hook-form v7.51 has solid DevTools integration. Watching formState.errors update in real time is way faster than adding console logs everywhere. Also consider the box shadow generator if you're styling error states — a subtle 0 0 0 3px red shadow at 30% opacity reads as "error" without being aggressive.

FAQ

Do I need to define TypeScript types separately from my Zod schema?

No. Use z.infer<typeof schema> to derive the TypeScript type directly from your Zod schema. Any time you update the schema, the type updates automatically. Keeping them in sync manually is just asking for bugs.

Why is my number field coming through as a string even though I typed it as `number` in Zod?

HTML inputs always return strings. Use z.coerce.number() instead of z.number() and Zod will cast the string to a number before validation. Skip coercion and you'll get runtime type mismatches even though TypeScript showed no errors.

Can I use the same Zod schema on both client and server?

Yes, that's one of the main selling points. Define your schema in a shared lib folder, import it in your form component for client-side validation, and import it in your API route for server-side validation. One source of truth.

How do I handle validation errors returned from the server after the form submits?

Call setError from react-hook-form's return value inside your submit handler catch block. Use setError('fieldName', { message: '...' }) for field-specific errors, or setError('root', { message: '...' }) for top-level errors unrelated to a specific field.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Hook Form + Zod: The Form Stack That Finally Makes Sensereact-hook-form Guide 2026: Controller, Register, Watch PatternsZod vs Yup vs Valibot in 2026: Schema Validation ShowdownZod Guide 2026: Schemas, Transforms, Refinements and Error Formatting