JSON Schema for Form Validation: React Hook Form and Zod
Last updated:
JSON Schema form validation gives you a single source of truth that works across your React form, your Node.js server, and your OpenAPI documentation. Instead of duplicating rules in three places, you define constraints once — required fields, string formats, numeric ranges — and run them through Ajv or Zod on every layer of your stack. This guide covers four concrete approaches: ajvResolver for react-hook-form, zodResolver with TypeScript inference, auto-generated forms with @rjsf/core, and shared server-side validation with the same schema object.
react-hook-form with Ajv Resolver
The @hookform/resolvers/ajv adapter connects a raw JSON Schema to react-hook-form in about 10 lines. Install the packages first:
npm install react-hook-form @hookform/resolvers ajv ajv-formatsDefine your JSON Schema and pass it to ajvResolver:
import { useForm } from 'react-hook-form'
import { ajvResolver } from '@hookform/resolvers/ajv'
import Ajv from 'ajv'
import addFormats from 'ajv-formats'
// 1. Define the JSON Schema
const schema = {
type: 'object',
properties: {
username: {
type: 'string',
minLength: 3,
maxLength: 20,
pattern: '^[a-zA-Z0-9_]+$',
},
email: {
type: 'string',
format: 'email',
},
age: {
type: 'integer',
minimum: 18,
maximum: 120,
},
password: {
type: 'string',
minLength: 8,
},
},
required: ['username', 'email', 'password'],
additionalProperties: false,
}
// 2. Create a configured Ajv instance
const ajv = new Ajv({ allErrors: true })
addFormats(ajv)
// 3. Wire it into react-hook-form
export function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: ajvResolver(schema, { ajv }),
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('username')} placeholder="Username" />
{errors.username && <p>{errors.username.message}</p>}
<input {...register('email')} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register('password')} placeholder="Password" />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Register</button>
</form>
)
}When the user submits, ajvResolver calls ajv.validate(schema, formValues). Ajv returns error objects with instancePath values like /email and /password. The resolver converts these JSON Pointer paths into react-hook-form field names (email, password) so errors appear under the correct fields in the errors object. Setting allErrors: true in Ajv ensures all field errors are reported at once, not just the first one.
Zod Schema with zodResolver
Zod is the most popular choice for react-hook-form validation in TypeScript projects because the schema and the TypeScript type stay in sync automatically. z.infer<typeof schema> extracts the exact type the schema accepts — no separate interface needed.
npm install zod @hookform/resolversimport { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// 1. Define the Zod schema with inline error messages
const registrationSchema = z.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username cannot exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
email: z.string().email('Enter a valid email address'),
age: z.number().int().min(18, 'Must be 18 or older').optional(),
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 do not match',
path: ['confirmPassword'], // error assigned to confirmPassword field
}
)
// 2. Extract the TypeScript type — no separate interface needed
type RegistrationData = z.infer<typeof registrationSchema>
// 3. Pass the type to useForm for full TypeScript inference
export function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<div>
<label>Username</label>
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
</div>
<div>
<label>Email</label>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<label>Password</label>
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
</div>
<div>
<label>Confirm Password</label>
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
</div>
<button type="submit">Register</button>
</form>
)
}The refine() method adds cross-field validation that cannot be expressed in a plain JSON Schema. When you call zodResolver, it runs schema.safeParse(values) and maps ZodError.issues to react-hook-form's FieldErrors. The path array in refine() controls which field receives the error — here ['confirmPassword'] makes the mismatch error appear under that field.
Auto-Generated Forms with react-jsonschema-form
@rjsf/core reads a JSON Schema and renders a complete form — every field, label, and error message — with no manual JSX. This is the fastest path from a schema to a working form for admin panels, configuration editors, and data-entry tools where design polish is secondary to correctness.
npm install @rjsf/core @rjsf/utils @rjsf/validator-ajv8import Form from '@rjsf/core'
import { RJSFSchema, UiSchema } from '@rjsf/utils'
import validator from '@rjsf/validator-ajv8'
// JSON Schema drives field generation
const schema: RJSFSchema = {
title: 'User Profile',
type: 'object',
required: ['firstName', 'lastName', 'email'],
properties: {
firstName: {
type: 'string',
title: 'First Name',
minLength: 1,
},
lastName: {
type: 'string',
title: 'Last Name',
minLength: 1,
},
email: {
type: 'string',
format: 'email',
title: 'Email Address',
},
age: {
type: 'integer',
title: 'Age',
minimum: 0,
maximum: 150,
},
bio: {
type: 'string',
title: 'Bio',
maxLength: 500,
},
role: {
type: 'string',
title: 'Role',
enum: ['admin', 'editor', 'viewer'],
enumNames: ['Administrator', 'Editor', 'Viewer'],
},
notifications: {
type: 'boolean',
title: 'Receive email notifications',
default: true,
},
tags: {
type: 'array',
title: 'Tags',
items: { type: 'string' },
uniqueItems: true,
},
},
}
// UI Schema controls widget types and layout — optional
const uiSchema: UiSchema = {
bio: { 'ui:widget': 'textarea', 'ui:options': { rows: 4 } },
password: { 'ui:widget': 'password' },
age: { 'ui:widget': 'updown' },
}
export function AutoForm() {
return (
<Form
schema={schema}
uiSchema={uiSchema}
validator={validator}
onSubmit={({ formData }) => console.log(formData)}
onError={(errors) => console.log('Validation errors:', errors)}
/>
)
}A string property renders as <input type="text">. An enum renders as <select>. A boolean renders as a checkbox. An array of strings renders as a dynamic list with Add Item / Remove buttons. The UI Schema layer overrides these defaults — for example, switching bio from a text input to a textarea. Theme packages (@rjsf/mui, @rjsf/bootstrap-4, @rjsf/chakra-ui) replace the default HTML widgets with styled components from each design system.
TypeScript Types from Validation Schema
Keeping TypeScript types in sync with validation schemas eliminates a common class of bugs where the type says a field is required but the schema does not enforce it, or vice versa.
// ── Approach 1: Zod (recommended for TypeScript-first projects) ──────────────
import { z } from 'zod'
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
})
// Type is inferred automatically — stays in sync with schema always
type User = z.infer<typeof userSchema>
// Equivalent to:
// type User = { id: string; email: string; role: 'admin' | 'user' | 'guest'; createdAt: string }
// ── Approach 2: JSON Schema → TypeScript via json-schema-to-typescript ────────
// schema.json:
// { "type": "object", "properties": { "id": { "type": "string" }, ... } }
// Generate types at build time:
// npx json2ts -i ./schemas/user.schema.json -o ./types/user.d.ts
// Generated output (types/user.d.ts):
export interface User {
id: string
email: string
role: 'admin' | 'user' | 'guest'
createdAt: string
}
// ── Approach 3: Ajv with TypeScript generics ──────────────────────────────────
import Ajv from 'ajv'
import type { JSONSchemaType } from 'ajv'
interface User {
id: string
email: string
role: 'admin' | 'user' | 'guest'
}
const schema: JSONSchemaType<User> = {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string', format: 'email' },
role: { type: 'string', enum: ['admin', 'user', 'guest'] },
},
required: ['id', 'email', 'role'],
additionalProperties: false,
}
// compile<User> makes validate a type guard: (data: unknown) => data is User
const ajv = new Ajv()
const validate = ajv.compile<User>(schema)
function processUser(raw: unknown): User {
if (validate(raw)) {
return raw // TypeScript knows raw is User here
}
throw new Error(ajv.errorsText(validate.errors))
}The JSONSchemaType<T> generic from Ajv is a TypeScript-level contract: if your JSON Schema does not match the interface T, TypeScript will report a compile-time error on the schema definition itself. This catches mismatches between the interface and the schema before any data is validated.
Server-Side Validation with the Same Schema
The strongest reason to use JSON Schema or Zod for form validation is running the identical rules on the server. Client-side validation is a UX feature; server-side validation is a security requirement. Sharing the schema means the rules are always identical.
// shared/schemas/registration.ts (imported by both client and server)
import { z } from 'zod'
export const registrationSchema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
password: z.string().min(8),
})
export type RegistrationData = z.infer<typeof registrationSchema>
// ── Client (React component) ───────────────────────────────────────────────────
// app/register/page.tsx
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { registrationSchema, type RegistrationData } from '@/shared/schemas/registration'
const { register, handleSubmit } = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
})
// ── Server (Next.js Server Action) ────────────────────────────────────────────
// app/actions/register.ts
'use server'
import { registrationSchema } from '@/shared/schemas/registration'
export async function registerUser(formData: FormData) {
const raw = {
username: formData.get('username'),
email: formData.get('email'),
password: formData.get('password'),
}
// Runs the exact same Zod schema — no separate server-side validation rules
const result = registrationSchema.safeParse(raw)
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
// result.data is typed as RegistrationData
await createUser(result.data)
return { success: true }
}
// ── Express API route (alternative backend) ────────────────────────────────────
// routes/register.ts
import { registrationSchema } from '../shared/schemas/registration'
app.post('/api/register', (req, res) => {
const result = registrationSchema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() })
}
// ...
})safeParse() never throws — it returns {success: true, data} or {success: false, error}. Use error.flatten().fieldErrors to get a per-field error map that matches what react-hook-form's setError expects, making it straightforward to relay server errors back into the form state.
Custom Error Messages
All three major validators support custom messages, but through different mechanisms.
// ── Zod: messages inline in the schema ────────────────────────────────────────
const schema = z.object({
email: z.string({
required_error: 'Email is required',
invalid_type_error: 'Email must be a string',
}).email('Enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Include at least one capital letter')
.regex(/[0-9]/, 'Include at least one number'),
})
// ── Ajv: errorMessage keyword via ajv-errors ───────────────────────────────────
import Ajv from 'ajv'
import addErrors from 'ajv-errors'
import addFormats from 'ajv-formats'
const ajv = new Ajv({ allErrors: true })
addFormats(ajv)
addErrors(ajv) // enables errorMessage keyword
const schema = {
type: 'object',
properties: {
email: {
type: 'string',
format: 'email',
errorMessage: {
type: 'Email must be a string',
format: 'Enter a valid email address',
},
},
password: {
type: 'string',
minLength: 8,
errorMessage: {
minLength: 'Password must be at least 8 characters',
},
},
age: {
type: 'integer',
minimum: 18,
errorMessage: 'Must be an integer of 18 or older',
// single string replaces ALL errors for this property
},
},
required: ['email', 'password'],
errorMessage: {
required: {
email: 'Email is required',
password: 'Password is required',
},
},
}
// ── Yup: messages in the schema ────────────────────────────────────────────────
import * as yup from 'yup'
const schema = yup.object({
email: yup.string().required('Email is required').email('Enter a valid email'),
password: yup.string().required('Password is required').min(8, 'At least 8 characters'),
})In react-hook-form, field errors are available as errors.fieldName?.message. When using ajvResolver with ajv-errors, the custom errorMessage values appear in that field. With zodResolver, the Zod message string becomes the message property directly.
Yup vs Zod vs Ajv for Form Validation
The three major validation libraries each have strengths suited to different project contexts. The table below compares them across the dimensions that matter most for form validation.
| Feature | Yup | Zod | Ajv (JSON Schema) |
|---|---|---|---|
| Bundle size (min+gzip) | ~19 kB | ~14 kB | ~29 kB (ajv + ajv-formats) |
| TypeScript inference | Good — InferType<> | Excellent — z.infer<> | Good — JSONSchemaType<T> |
| JSON Schema export | Via yup-to-json-schema | Via zod-to-json-schema | Native — it IS JSON Schema |
| react-hook-form resolver | yupResolver | zodResolver | ajvResolver |
| Validation performance | ~50k ops/sec | ~80k ops/sec | ~1M+ ops/sec (compiled) |
| Cross-field validation | .test() method | .refine() method | Custom keywords or programmatic |
| Best use case | Existing Yup projects, Formik | New TypeScript projects | OpenAPI, multi-language APIs, server perf |
Ajv's performance advantage — over 1 million validations per second on compiled schemas — matters most on the server side where thousands of requests arrive per second. On the client for a single form submission, all three are imperceptibly fast. Choose Zod for new TypeScript-first projects; use Ajv when you need true JSON Schema spec compliance for OpenAPI or multi-language API contracts.
Definitions
- resolver
- A function passed to react-hook-form's
useForm({ resolver })option that intercepts the validation step. Instead of react-hook-form's built-in rules (required,min,pattern), the resolver delegates to an external validation library — Zod, Yup, Ajv, Joi, or others — and converts that library's error format into react-hook-form'sFieldErrorsmap. The@hookform/resolverspackage provides pre-built resolvers for all major libraries. - zodResolver
- The resolver adapter for Zod, exported from
@hookform/resolvers/zod. It callsschema.safeParse(values)on the current form values, mapsZodError.issuesto field-level error messages using each issue'spatharray, and returns the result in react-hook-form's expected format. BecausesafeParse()never throws, all errors — including cross-field refinements — are captured and reported back to the form. - react-jsonschema-form
- An open-source React library (package:
@rjsf/core) maintained by the rjsf-team that auto-generates a complete HTML form from a JSON Schema definition. It reads the schema'sproperties,required,type, andenumfields to decide which HTML widget to render for each field. A separate UI Schema document can override default widget choices and configure layout options. The library supports theming for Material UI, Bootstrap, Ant Design, Chakra UI, and Semantic UI. - yup
- A JavaScript schema builder for value parsing and validation, commonly used with Formik and react-hook-form. Yup schemas are defined using a fluent builder API:
yup.object({ email: yup.string().email() }). TypeScript types can be inferred withyup.InferType<typeof schema>. Yup predates Zod and remains widely used in existing codebases. Theyup-to-json-schemapackage converts Yup schemas to JSON Schema Draft 7 format for interop with OpenAPI tooling or Ajv. - JSON Pointer error path
- A string that identifies the location of a validation error within a JSON document, following RFC 6901 syntax. Ajv reports errors with an
instancePathfield containing the JSON Pointer to the failing value:/emailfor a top-level field,/address/streetfor a nested field, and/items/0/namefor the first array item'snamefield. TheajvResolverconverts these paths to react-hook-form field name notation — replacing/items/0/namewithitems.0.name— so errors surface on the correct field in the form.
FAQ
How do I use JSON Schema with react-hook-form?
Install @hookform/resolvers and ajv, then pass ajvResolver(schema) to useForm. The resolver runs Ajv on every form submission and maps JSON Pointer error paths (/email) to react-hook-form field names. Set allErrors: true in your Ajv instance so all field errors are collected in a single pass rather than stopping at the first failure. Pass mode: 'onChange' to useForm for live field-level feedback.
What is zodResolver in react-hook-form?
zodResolver from @hookform/resolvers/zod connects a Zod schema to react-hook-form. It calls schema.safeParse(values) and converts ZodError.issues to react-hook-form's FieldErrors map. The main advantage over raw JSON Schema is native TypeScript integration: useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) }) gives you fully typed register() calls and watch() values with no separate interface needed.
What is react-jsonschema-form (@rjsf/core)?
@rjsf/core auto-generates a complete HTML form from a JSON Schema. You provide a JSON Schema document and optionally a UI Schema for widget overrides; @rjsf/core renders all fields, labels, and error messages. A string renders as a text input, an enum as a select, a boolean as a checkbox, and an array as a dynamic list. It uses Ajv internally for validation and supports theme packages for Material UI, Bootstrap, Chakra UI, and Ant Design.
How do I convert a Zod schema to JSON Schema?
Install zod-to-json-schema and call zodToJsonSchema(mySchema). The output is a Draft 7-compatible JSON Schema object that can be used with Ajv, included in an OpenAPI spec, or shared with a Python backend for server-side validation. Zod refinements created with .refine() are not expressible in JSON Schema and are dropped during conversion. For OpenAPI, you can pass the result directly to the components.schemas section of your spec.
Can I use the same validation schema on the client and server?
Yes — this is the primary motivation for using JSON Schema or Zod for form validation. Place the schema in a shared module (shared/schemas/registration.ts), import it in the React form with zodResolver, and import it in the Next.js Server Action or Express route to call schema.safeParse(req.body). Both sides run the exact same rules from the exact same source file, eliminating validation drift. This also means adding a new required field to the schema automatically enforces that requirement on both client and server.
How do I generate TypeScript types from a validation schema?
With Zod: type FormData = z.infer<typeof schema> — no extra step needed. With a JSON Schema file: run npx json2ts -i schema.json -o types.d.ts using the json-schema-to-typescript package. With Ajv: use JSONSchemaType<T> as the schema type annotation, which makes TypeScript verify that your JSON Schema matches the interface at compile time. For OpenAPI specs, openapi-typescript generates types for every schema in the spec in a single command.
How do I show custom error messages with JSON Schema and react-hook-form?
With Ajv: install ajv-errors and use the errorMessage keyword in your JSON Schema. This maps each validation keyword (minLength, format, required) to a specific message string. With Zod: pass messages directly in the schema methods — z.string().min(8, 'At least 8 characters'). These messages appear in errors.fieldName?.message in your JSX. For required field messages with Zod, pass z.string({ required_error: "Field is required" }).
What is a JSON Pointer error path in form validation?
A JSON Pointer (RFC 6901) identifies a value's location within a JSON document using slash-separated path segments: /email, /address/street, /items/0/name. Ajv reports each validation error with an instancePath field containing the JSON Pointer to the failing value. The ajvResolver converts these pointers to react-hook-form field names by stripping the leading slash and replacing / with . for nested paths — so /address/street becomes address.street, matching react-hook-form's nested naming convention exactly.
Further reading and primary sources
- react-hook-form Resolvers — Validation resolvers for react-hook-form: Yup, Zod, Ajv, Joi, and more
- react-jsonschema-form — Auto-generate React forms from JSON Schema
- zod-to-json-schema — Convert Zod schemas to JSON Schema