Form Validation with Zod
Schema-first form validation with React Hook Form and Zod. Type-safe, declarative error messages, and zero boilerplate for create and edit flows.
Principle
The Zod schema is the single source of truth — it defines the shape, types, and error messages. React Hook Form handles registration, submission, and field state. Components never write validation logic; they display what the schema declares.
Write the schema before a single input. Share schemas across forms with .pick(), .extend(), or .omit(). Keep all error messages inside the schema, not in JSX.
Rules
- check_circleSchema before formDefine the Zod schema first. Never add validation inline with register options or manual if-statements.
- check_circleOmit server-generated fieldsUse .omit({ id: true, createdAt: true }) for create forms. The schema reflects what the user provides.
- check_circlehandleSubmit owns errorsWrap mutation calls in handleSubmit. Validation errors surface automatically without try/catch in the component.
- check_circleReset after successCall reset() after a successful mutation to clear all field values and dirty state.
Pattern
import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; const createUserSchema = z.object({ name: z.string().min(1, 'Name is required'), email: z.string().email('Enter a valid email address'), role: z.enum(['viewer', 'editor', 'admin']), status: z.enum(['active', 'inactive']), }); type CreateUserValues = z.infer<typeof createUserSchema>; export function UserForm() { const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<CreateUserValues>({ resolver: zodResolver(createUserSchema), defaultValues: { name: '', email: '', role: 'viewer', status: 'active' }, }); const onSubmit = async (data: CreateUserValues) => { await createUser(data); reset(); }; return <form onSubmit={handleSubmit(onSubmit)}>{/* fields */}</form>; }
Implementation
Version Compatibility
Requires React 19+ and the latest stable versions of all dependencies shown.
In Next.js App Router, pair the form with a Server Action for zero-client-bundle mutations. Validate with the same Zod schema on the server to prevent bypassing client validation.
'use server'; import { createUserSchema } from '@/lib/schemas'; import { db } from '@/lib/db'; export async function createUserAction(values: unknown) { const data = createUserSchema.parse(values); // validates server-side too await db.user.create({ data }); }