Lesson 9

Schema Validation with Zod

Writing individual validator functions works, but it gets repetitive fast. Every field gets its own function, every rule gets spelled out by hand, and if you're also validating on the server or in an API handler, you're duplicating that logic in two places. Zod lets you define your validation schema once, declaratively, and use it everywhere — form validation, API payloads, type inference. TanStack Form integrates with it cleanly through an adapter.

Setup

Back in lesson 2 you installed zod and @tanstack/zod-form-adapter. If you skipped that step, add them now:

npm install zod @tanstack/zod-form-adapter

Then import both at the top of your component file:

import { z } from 'zod'
import { zodValidator } from '@tanstack/zod-form-adapter'

That's all the setup you need.

Field-Level Zod Validation

Instead of passing a function to validators.onChange, you pass a Zod schema. The adapter handles the conversion — it runs the schema, catches any errors, and returns them in the format TanStack Form expects.

<form.Field
  name="age"
  validators={{
    onChange: z.number().min(18, 'Must be at least 18'),
  }}
  validatorAdapter={zodValidator()}
  children={(field) => (
    <input
      type="number"
      value={field.state.value}
      onChange={(e) => field.handleChange(Number(e.target.value))}
    />
  )}
/>

Notice validatorAdapter={zodValidator()} on the field. That's what tells TanStack Form how to interpret the schema you passed. Without it, it would try to call the Zod schema as a function and fail. With it, the whole thing just works.

Form-Level Zod Validation

You can also define a single schema for the entire form and attach it to useForm. This is especially useful for registration or checkout forms where all the fields need to be validated together.

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

const form = useForm({
  defaultValues: { firstName: '', email: '', age: 0 },
  validators: {
    onChange: formSchema,
  },
  validatorAdapter: zodValidator(),
  onSubmit: async ({ value }) => {
    console.log(value)
  },
})

The validatorAdapter here applies to the whole form. Each field's error still surfaces in field.state.meta.errors — TanStack Form maps the Zod error paths back to the right fields automatically.

Type Inference from Zod

One underrated benefit: z.infer<typeof formSchema> gives you the TypeScript type for your form values for free.

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

Your form types and validation rules are defined in exactly one place. If you add a field to the schema, the type updates automatically. No separate interface to keep in sync.

Complete Example

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

import { useForm } from '@tanstack/react-form'
import { z } from 'zod'
import { zodValidator } from '@tanstack/zod-form-adapter'

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

function RegistrationForm() {
  const form = useForm({
    defaultValues: { firstName: '', email: '', age: 0 },
    validators: {
      onChange: formSchema,
    },
    validatorAdapter: zodValidator(),
    onSubmit: async ({ value }) => {
      console.log('Registered:', value)
    },
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        form.handleSubmit()
      }}
    >
      <form.Field
        name="firstName"
        children={(field) => (
          <div>
            <label htmlFor="firstName">First Name</label>
            <input
              id="firstName"
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
              onBlur={field.handleBlur}
            />
            {field.state.meta.errors.length > 0 && (
              <p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
            )}
          </div>
        )}
      />

      <form.Field
        name="email"
        children={(field) => (
          <div>
            <label htmlFor="email">Email</label>
            <input
              id="email"
              type="email"
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
              onBlur={field.handleBlur}
            />
            {field.state.meta.errors.length > 0 && (
              <p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
            )}
          </div>
        )}
      />

      <form.Field
        name="age"
        children={(field) => (
          <div>
            <label htmlFor="age">Age</label>
            <input
              id="age"
              type="number"
              value={field.state.value}
              onChange={(e) => field.handleChange(Number(e.target.value))}
              onBlur={field.handleBlur}
            />
            {field.state.meta.errors.length > 0 && (
              <p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
            )}
          </div>
        )}
      />

      <button type="submit">Register</button>
    </form>
  )
}

One schema, three fields, no hand-written validator functions. The schema lives at the top where it's easy to find, it doubles as your TypeScript type, and you can import it into your API handler to validate the same data on the server.