Building Type-Safe Forms with Zod, React Hook Form & Next.js 16
- Zod schema is the single source of truth β drives client validation, server validation, and TypeScript types
- React Hook Form uses uncontrolled inputs β 90% fewer re-renders than useState-based forms
- Server actions must re-validate with the same Zod schema β client validation is bypassable
- Zod defines the validation schema β one schema drives both client and server validation
- React Hook Form manages form state with minimal re-renders β 90% fewer than useState
- Server Actions validate the same Zod schema on the server β no duplicate validation logic
- The resolver pattern connects Zod to React Hook Form β zodResolver bridges the two
- TypeScript infers the form type from the Zod schema β no manual type definitions needed
- Biggest mistake: validating on client only β Server Actions must re-validate to prevent bypass
Zod schema type not inferring correctly
npx tsc --noEmit 2>&1 | grep -i 'zod\|schema\|infer' | head -20cat node_modules/zod/package.json | grep versionReact Hook Form not registering fields
grep -rn 'register(' components/ --include='*.tsx' | head -10grep -rn 'Controller\|useController' components/ --include='*.tsx' | head -10Server Action not receiving form data
grep -rn 'action={' app/ --include='*.tsx' | head -10grep -rn 'useActionState\|useFormStatus' app/ --include='*.tsx' | head -10Zod discriminated union not narrowing types
grep -n 'discriminatedUnion\|z.discriminatedUnion' types/*.ts lib/*.tsnpx tsc --noEmit 2>&1 | grep -i 'union\|never\|discriminat' | head -10Production Incident
ts
// In server action
if (formData.get('website_hp')) {
return { errors: { root: ['Bot detected'] }, success: false }
}
// Rate limiting (example with Upstash)
const ratelimit = new Ratelimit({ redis, limiter: ... })
const { success } = await ratelimit.limit(ip)
if (!success) return { errors: { root: ['Rate limit exceeded'] }, success: false }
``Production Debug GuideDiagnose common form validation and state issues
Form handling is where most React applications accumulate technical debt. Developers start with useState for every field, add ad-hoc validation in event handlers, and end up with 500-line form components that re-render on every keystroke. The performance degrades. The validation is inconsistent. The types drift from the actual data shape.
Zod, React Hook Form, and Next.js Server Actions solve this as a stack. Zod defines the validation schema once β it drives client validation, server validation, and TypeScript type inference. React Hook Form manages form state with near-zero re-renders. Server Actions validate the same schema on the server, preventing client-side bypass.
This article covers the integration patterns that work in production: schema-driven forms, Server Action validation, error handling, multi-step wizards, and dynamic field arrays.
Schema-Driven Forms: Zod as the Single Source of Truth
The foundation of type-safe forms is a Zod schema that defines the shape, types, and validation rules for every field. This schema is imported by the client form (via zodResolver), the server action (via schema.parse()), and TypeScript (via z.infer). One definition drives everything.
The key insight: Zod schemas are not just validators β they are type providers. z.infer<typeof schema> produces a TypeScript type that matches the validated data. If you change the schema, the type updates automatically. If you add a field, TypeScript tells you every place that needs to use it.
This eliminates the three most common form bugs: validation on client but not server, types that drift from validation rules, and fields that exist in the schema but are missing from the form.
// ============================================ // Zod Schema: Single Source of Truth // ============================================ // This file is imported by: // 1. The client form (React Hook Form + zodResolver) // 2. The server action (schema.parse()) // 3. TypeScript type inference (z.infer) import * as z from 'zod' // ---- Define the schema ---- // Each field has a type and validation rules // The schema drives everything: validation, types, error messages export const signupSchema = z.object({ name: z .string() .min(2, 'Name must be at least 2 characters') .max(50, 'Name must be at most 50 characters'), email: z .string() .email('Invalid email address') .toLowerCase(), password: z .string() .min(8, 'Password must be at least 8 characters') .regex(/[A-Z]/, 'Password must contain an uppercase letter') .regex(/[0-9]/, 'Password must contain a number') .regex(/[^A-Za-z0-9]/, 'Password must contain a special character'), confirmPassword: z.string(), role: z.enum(['member', 'admin'], { errorMap: () => ({ message: 'Please select a role' }), }), acceptTerms: z .boolean() .refine((val) => val === true, { message: 'You must accept the terms and conditions', }), // Optional fields β may be undefined bio: z.string().max(500, 'Bio must be at most 500 characters').optional(), website: z .string() .url('Invalid URL') .startsWith('https://', 'URL must use HTTPS') .optional() .or(z.literal('')), // Nested object β Zod handles nested validation address: z .object({ street: z.string().min(1, 'Street is required'), city: z.string().min(1, 'City is required'), state: z.string().length(2, 'Use 2-letter state code'), zip: z .string() .regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'), }) .optional(), }) // Cross-field validation β refine runs after all field validations pass .refine((data) => data.password === data.confirmPassword, { message: 'Passwords do not match', path: ['confirmPassword'], // Error attaches to this field }) // ---- Type inference ---- // z.infer produces a TypeScript type from the schema // This type is used by React Hook Form and the server action type SignupFormValues = z.infer<typeof signupSchema> // ---- Partial schema for update forms ---- // Derive update schema from the create schema export const updateProfileSchema = signupSchema .pick({ name: true, bio: true, website: true, address: true, }) .partial() // All fields become optional .refine((data) => Object.keys(data).length > 0, { message: 'At least one field must be provided', }) // ---- Login schema β simpler export const loginSchema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(1, 'Password is required'), rememberMe: z.coerce.boolean().default(false), }) type LoginFormValues = z.infer<typeof loginSchema> // ---- Invoice schema β complex nested structure ---- export const invoiceItemSchema = z.object({ description: z.string().min(1, 'Description is required'), quantity: z.coerce .number() .int('Must be a whole number') .min(1, 'Minimum quantity is 1') .max(10000, 'Maximum quantity is 10,000'), unitPrice: z.coerce .number() .min(0.01, 'Price must be positive') .max(999999.99, 'Price too high'), taxable: z.boolean().default(false), }) export const invoiceSchema = z.object({ clientName: z.string().min(2, 'Client name is required'), clientEmail: z.string().email('Invalid email'), dueDate: z.string().min(1, 'Due date is required'), items: z .array(invoiceItemSchema) .min(1, 'Add at least one line item') .max(50, 'Maximum 50 line items'), notes: z.string().max(1000).optional(), paymentTerms: z.enum(['net15', 'net30', 'net60', 'due_on_receipt']), }) type InvoiceFormValues = z.infer<typeof invoiceSchema>
- Client uses the schema via zodResolver β validates on blur and on submit
- Server uses the schema via schema.parse() β validates before processing
- TypeScript uses z.infer<typeof schema> β derives types automatically
- Change the schema once β validation, types, and error messages update everywhere
- The schema is the single source of truth β no duplicate validation logic
React Hook Form Integration: Minimal Re-Renders
React Hook Form manages form state with uncontrolled inputs by default. It registers DOM elements directly via refs, not React state. This means typing in a field does not trigger a re-render of the form component β only the changed field updates.
The integration with Zod happens through zodResolver β a bridge that connects the Zod schema to React Hook Form's validation lifecycle. When the user submits or blurs a field, zodResolver runs the Zod validation and maps errors to the form state.
The key architectural decision: use register for native inputs (input, textarea, select) and Controller for custom components (shadcn/ui, etc.). Mixing these patterns incorrectly causes validation to silently fail.
// ============================================ // React Hook Form + Zod Integration // ============================================ 'use client' import * as React from 'react' import { useForm, Controller, useFieldArray } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { signupSchema, type SignupFormValues } from '@/lib/schemas/signup' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Checkbox } from '@/components/ui/checkbox' import { Textarea } from '@/components/ui/textarea' export function SignupForm() { const form = useForm<SignupFormValues>({ resolver: zodResolver(signupSchema), defaultValues: { name: '', email: '', password: '', confirmPassword: '', role: 'member', acceptTerms: false, bio: '', website: '', }, mode: 'onBlur', reValidateMode: 'onChange', }) async function onSubmit(data: SignupFormValues) { try { const response = await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) if (!response.ok) { const error = await response.json() if (error.fieldErrors) { Object.entries(error.fieldErrors).forEach(([field, message]) => { form.setError(field as any, { type: 'server', message: message as string, }) }) } return } } catch (err) { form.setError('root', { type: 'network', message: 'Network error β please try again', }) } } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="Alice Johnson" {...field} /> </FormControl> <FormDescription>Your display name on the platform</FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input type="email" placeholder="alice@example.com" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="password" render={({ field }) => ( <FormItem> <FormLabel>Password</FormLabel> <FormControl> <Input type="password" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="confirmPassword" render={({ field }) => ( <FormItem> <FormLabel>Confirm Password</FormLabel> <FormControl> <Input type="password" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="role" render={({ field }) => ( <FormItem> <FormLabel>Role</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger> <SelectValue placeholder="Select a role" /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value="member">Member</SelectItem> <SelectItem value="admin">Admin</SelectItem> </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="bio" render={({ field }) => ( <FormItem> <FormLabel>Bio (optional)</FormLabel> <FormControl> <Textarea placeholder="Tell us about yourself" className="resize-none" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="acceptTerms" render={({ field }) => ( <FormItem className="flex flex-row items-start space-x-3 space-y-0"> <FormControl> <Checkbox checked={field.value} onCheckedChange={field.onChange} /> </FormControl> <div className="space-y-1 leading-none"> <FormLabel>Accept terms and conditions</FormLabel> <FormDescription> You agree to our Terms of Service and Privacy Policy </FormDescription> </div> <FormMessage /> </FormItem> )} /> {form.formState.errors.root && ( <div className="text-sm text-destructive"> {form.formState.errors.root.message} </div> )} <Button type="submit" disabled={form.formState.isSubmitting} className="w-full" > {form.formState.isSubmitting ? 'Creating account...' : 'Sign Up'} </Button> </form> </Form> ) }
Server Actions: Server-Side Validation with the Same Schema
Next.js Server Actions run on the server when a form is submitted. They receive FormData, validate it against the Zod schema, and return errors or success. The critical pattern: the server action imports the same Zod schema that the client form uses. This guarantees client and server validation are always in sync.
The validation flow: client validates for UX (instant feedback), server validates for security (prevents bypass). If the client validation passes but the server rejects, it means either the client schema diverged from the server schema, or a bot bypassed the browser. Both cases need investigation.
// ============================================ // Server Action with Zod Validation // ============================================ // File: app/signup/actions.ts // This file runs ONLY on the server β never sent to the client 'use server' import { redirect } from 'next/navigation' import { signupSchema } from '@/lib/schemas/signup' import { prisma } from '@/lib/db' import bcrypt from 'bcrypt' // ---- Server Action: Signup ---- // Receives FormData from the form submission // Validates with the SAME Zod schema as the client export async function signupAction( prevState: { errors: Record<string, string[]>; success: boolean }, formData: FormData ): Promise<{ errors: Record<string, string[]>; success: boolean }> { // ---- Step 1: Extract data from FormData ---- const raw = { name: formData.get('name') as string, email: formData.get('email') as string, password: formData.get('password') as string, confirmPassword: formData.get('confirmPassword') as string, role: formData.get('role') as string, acceptTerms: formData.get('acceptTerms') === 'true', bio: (formData.get('bio') as string) || undefined, website: (formData.get('website') as string) || undefined, } // ---- Step 2: Validate with Zod (safeParse returns result, does not throw) ---- const result = signupSchema.safeParse(raw) if (!result.success) { const fieldErrors: Record<string, string[]> = {} result.error.issues.forEach((issue) => { const path = issue.path.join('.') if (!fieldErrors[path]) { fieldErrors[path] = [] } fieldErrors[path].push(issue.message) }) return { errors: fieldErrors, success: false } } // ---- Step 3: Business logic validation ---- const { email, password, name, role, bio, website } = result.data const existingUser = await prisma.user.findUnique({ where: { email }, }) if (existingUser) { return { errors: { email: ['An account with this email already exists'] }, success: false, } } // ---- Step 4: Process the valid data ---- const passwordHash = await bcrypt.hash(password, 12) await prisma.user.create({ data: { name, email, passwordHash, role, bio: bio ?? null, website: website ?? null, }, }) // ---- Step 5: Redirect on success ---- redirect('/dashboard') } // ---- Server Action: Update Profile ---- // Uses a partial schema β only changed fields are validated import { updateProfileSchema } from '@/lib/schemas/signup' export async function updateProfileAction( userId: string, prevState: { errors: Record<string, string[]>; success: boolean }, formData: FormData ): Promise<{ errors: Record<string, string[]>; success: boolean }> { const raw: Record<string, unknown> = {} const name = formData.get('name') const bio = formData.get('bio') const website = formData.get('website') if (name) raw.name = name if (bio) raw.bio = bio if (website) raw.website = website if (Object.keys(raw).length === 0) { return { errors: { root: ['No changes provided'] }, success: false, } } const result = updateProfileSchema.safeParse(raw) if (!result.success) { const fieldErrors: Record<string, string[]> = {} result.error.issues.forEach((issue) => { const path = issue.path.join('.') if (!fieldErrors[path]) fieldErrors[path] = [] fieldErrors[path].push(issue.message) }) return { errors: fieldErrors, success: false } } await prisma.user.update({ where: { id: userId }, data: result.data, }) return { errors: {}, success: true } }
- Client validation runs on blur and submit β users see errors immediately
- Server validation runs on form submission β prevents bots and API bypass
- Same Zod schema on both sides β structural validation is always in sync
- Business rules (duplicate email, auth checks) go in the server action, not the schema
- If client passes but server rejects, the schemas have diverged or the request was bypassed
Connecting Server Actions to Forms with useActionState (React 19)
The bridge between a React form and a Server Action is useActionState (React 19). This hook wraps the server action and provides the result state to the component. The component renders errors from the server action result.
The pattern: the form's action prop points to the wrapped server action. On submit, the action runs on the server, returns a state object, and the component re-renders with the result.
// ============================================ // Connecting Server Actions to Forms (Next.js 16 / React 19) // ============================================ 'use client' import * as React from 'react' import { useActionState } from 'react' import { useFormStatus } from 'react-dom' import { signupAction } from '@/app/signup/actions' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' function SubmitButton() { const { pending } = useFormStatus() return ( <Button type="submit" disabled={pending} className="w-full"> {pending ? 'Creating account...' : 'Sign Up'} </Button> ) } export function SignupFormWithServerAction() { const [state, formAction] = useActionState(signupAction, { errors: {}, success: false, }) return ( <form action={formAction} className="space-y-4"> <div className="space-y-2"> <Label htmlFor="name">Name</Label> <Input id="name" name="name" placeholder="Alice Johnson" required /> {state.errors.name && ( <p className="text-sm text-destructive"> {state.errors.name.join(', ')} </p> )} </div> {/* Other fields ... */} {state.errors.root && ( <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive"> {state.errors.root.join(', ')} </div> )} <SubmitButton /> </form> ) }
- useActionState is the new React 19 hook β imported from 'react'
- useFormState (from 'react-dom') is deprecated in React 19
- useActionState returns [state, formAction, pending] β pending replaces useFormStatus in most cases
- The form still uses action={formAction} β no onSubmit needed
Dynamic Field Arrays: Invoice Line Items
Production forms often need dynamic fields β invoice line items, survey questions, team member invitations. React Hook Form's useFieldArray manages dynamic arrays with add, remove, and move operations. Combined with Zod's array schema, each item is validated independently.
The key pattern: the Zod schema defines an array of objects, useFieldArray manages the UI, and each array item is a FormField with its own validation. The total is computed from watched field values.
// ============================================ // Dynamic Field Arrays with useFieldArray // ============================================ 'use client' import * as React from 'react' import { useForm, useFieldArray, useWatch } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { invoiceSchema, type InvoiceFormValues } from '@/lib/schemas/invoice' import { cn } from '@/lib/utils' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' export function InvoiceForm() { const form = useForm<InvoiceFormValues>({ resolver: zodResolver(invoiceSchema), defaultValues: { clientName: '', clientEmail: '', dueDate: '', items: [ { description: '', quantity: 1, unitPrice: 0, taxable: false }, ], notes: '', paymentTerms: 'net30', }, }) const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items', }) const items = useWatch({ control: form.control, name: 'items' }) const subtotal = items.reduce((sum, item) => { return sum + (item?.quantity || 0) * (item?.unitPrice || 0) }, 0) const taxTotal = items.reduce((sum, item) => { if (!item?.taxable) return sum return sum + (item?.quantity || 0) * (item?.unitPrice || 0) * 0.08 }, 0) const total = subtotal + taxTotal async function onSubmit(data: InvoiceFormValues) { const response = await fetch('/api/invoices', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) if (!response.ok) { const error = await response.json() if (error.fieldErrors) { Object.entries(error.fieldErrors).forEach(([field, message]) => { form.setError(field as any, { type: 'server', message: message as string, }) }) } } } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <div className="grid gap-4 md:grid-cols-2"> <FormField control={form.control} name="clientName" render={({ field }) => ( <FormItem> <FormLabel>Client Name</FormLabel> <FormControl> <Input placeholder="Acme Corp" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="clientEmail" render={({ field }) => ( <FormItem> <FormLabel>Client Email</FormLabel> <FormControl> <Input placeholder="billing@acme.com" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div> <div className="space-y-4"> <div className="flex items-center justify-between"> <FormLabel className="text-base">Line Items</FormLabel> <Button type="button" variant="outline" size="sm" onClick={() => append({ description: '', quantity: 1, unitPrice: 0, taxable: false, }) } > Add Item </Button> </div> {fields.map((field, index) => ( <div key={field.id} className="grid gap-4 rounded-lg border p-4 md:grid-cols-5" > <FormField control={form.control} name={`items.${index}.description`} render={({ field }) => ( <FormItem className="md:col-span-2"> <FormLabel className={cn(index !== 0 && 'sr-only')}> Description </FormLabel> <FormControl> <Input placeholder="Service description" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name={`items.${index}.quantity`} render={({ field }) => ( <FormItem> <FormLabel className={cn(index !== 0 && 'sr-only')}> Qty </FormLabel> <FormControl> <Input type="number" min="1" {...field} onChange={(e) => field.onChange(e.target.valueAsNumber)} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name={`items.${index}.unitPrice`} render={({ field }) => ( <FormItem> <FormLabel className={cn(index !== 0 && 'sr-only')}> Price </FormLabel> <FormControl> <Input type="number" step="0.01" min="0" {...field} onChange={(e) => field.onChange(e.target.valueAsNumber)} /> </FormControl> <FormMessage /> </FormItem> )} /> <div className="flex items-end gap-2"> <FormField control={form.control} name={`items.${index}.taxable`} render={({ field }) => ( <FormItem className="flex items-center gap-2"> <FormControl> <Switch checked={field.value} onCheckedChange={field.onChange} /> </FormControl> <FormLabel className="text-xs">Tax</FormLabel> </FormItem> )} /> {fields.length > 1 && ( <Button type="button" variant="ghost" size="sm" onClick={() => remove(index)} > Remove </Button> )} </div> </div> ))} <div className="flex flex-col items-end gap-1 text-sm"> <div className="flex w-48 justify-between"> <span>Subtotal</span> <span>\${subtotal.toFixed(2)}</span> </div> <div className="flex w-48 justify-between"> <span>Tax (8%)</span> <span>\${taxTotal.toFixed(2)}</span> </div> <div className="flex w-48 justify-between font-bold"> <span>Total</span> <span>\${total.toFixed(2)}</span> </div> </div> </div> <Button type="submit" disabled={form.formState.isSubmitting} className="w-full" > {form.formState.isSubmitting ? 'Creating...' : 'Create Invoice'} </Button> </form> </Form> ) }
- Each field in the array has a unique id β use it as the React key, not the index
- Use useWatch for computed values (totals) β not form.getValues() which does not trigger re-renders
- Zod validates each array item independently β errors appear on the specific item's fields
- append() adds a new item with default values β match the Zod schema's default shape
- remove() takes an index β guard against removing the last item if the schema requires min 1
Multi-Step Form Wizard with State Preservation
Multi-step forms split a large form into smaller sections, each on its own step. The challenge is preserving data across steps while validating incrementally. React Hook Form does not have built-in wizard support β the pattern uses a single form instance with conditional rendering based on the current step.
The key insight: do not create a separate form instance per step. Use one form, one schema, and validate only the current step's fields on step transition. The full schema validates on final submission.
// ============================================ // Multi-Step Form Wizard // ============================================ 'use client' import * as React from 'react' import { useForm, useFormContext, FormProvider } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' // ---- Full schema β validated on final submit ---- const wizardSchema = z.object({ firstName: z.string().min(2, 'First name is required'), lastName: z.string().min(2, 'Last name is required'), email: z.string().email('Invalid email'), street: z.string().min(1, 'Street is required'), city: z.string().min(1, 'City is required'), state: z.string().length(2, 'Use 2-letter code'), zip: z.string().regex(/^\d{5}$/, 'Invalid ZIP'), cardNumber: z.string().regex(/^\d{16}$/, 'Card must be 16 digits'), expiry: z.string().regex(/^\d{2}\/\d{2}$/, 'Format: MM/YY'), cvv: z.string().regex(/^\d{3,4}$/, 'CVV must be 3-4 digits'), }) type WizardValues = z.infer<typeof wizardSchema> // ---- Per-step validation schemas ---- const stepSchemas = [ wizardSchema.pick({ firstName: true, lastName: true, email: true }), wizardSchema.pick({ street: true, city: true, state: true, zip: true }), wizardSchema.pick({ cardNumber: true, expiry: true, cvv: true }), ] const steps = ['Personal Info', 'Address', 'Payment'] export function MultiStepForm() { const [currentStep, setCurrentStep] = React.useState(0) const form = useForm<WizardValues>({ resolver: zodResolver(wizardSchema), defaultValues: { firstName: '', lastName: '', email: '', street: '', city: '', state: '', zip: '', cardNumber: '', expiry: '', cvv: '', }, mode: 'onBlur', }) async function nextStep() { const currentSchema = stepSchemas[currentStep] const currentFields = Object.keys(currentSchema.shape) as (keyof WizardValues)[] const isValid = await form.trigger(currentFields) if (isValid) { setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1)) } } function prevStep() { setCurrentStep((prev) => Math.max(prev - 1, 0)) } async function onSubmit(data: WizardValues) { const response = await fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) if (response.ok) { // Success } } return ( <FormProvider {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <div className="flex items-center gap-2"> {steps.map((step, index) => ( <div key={step} className={`flex items-center gap-2 ${ index <= currentStep ? 'text-primary' : 'text-muted-foreground' }`} > <span className={`flex h-8 w-8 items-center justify-center rounded-full border text-sm ${ index <= currentStep ? 'border-primary bg-primary text-primary-foreground' : 'border-muted' }`} > {index + 1} </span> <span className="text-sm font-medium">{step}</span> {index < steps.length - 1 && ( <span className="text-muted-foreground">/</span> )} </div> ))} </div> {currentStep === 0 && <PersonalInfoStep />} {currentStep === 1 && <AddressStep />} {currentStep === 2 && <PaymentStep />} <div className="flex justify-between"> <Button type="button" variant="outline" onClick={prevStep} disabled={currentStep === 0} > Previous </Button> {currentStep < steps.length - 1 ? ( <Button type="button" onClick={nextStep}> Next </Button> ) : ( <Button type="submit" disabled={form.formState.isSubmitting}> {form.formState.isSubmitting ? 'Processing...' : 'Submit'} </Button> )} </div> </form> </FormProvider> ) } function PersonalInfoStep() { const { register, formState: { errors } } = useFormContext<WizardValues>() return ( <div className="space-y-4"> <div> <Label htmlFor="firstName">First Name</Label> <Input id="firstName" {...register('firstName')} /> {errors.firstName && <p className="text-sm text-destructive">{errors.firstName.message}</p>} </div> {/* other fields */} </div> ) } function AddressStep() { const { register, formState: { errors } } = useFormContext<WizardValues>() return ( <div className="space-y-4"> <div> <Label htmlFor="street">Street</Label> <Input id="street" {...register('street')} /> {errors.street && <p className="text-sm text-destructive">{errors.street.message}</p>} </div> {/* other fields */} </div> ) } function PaymentStep() { const { register, formState: { errors } } = useFormContext<WizardValues>() return ( <div className="space-y-4"> <div> <Label htmlFor="cardNumber">Card Number</Label> <Input id="cardNumber" {...register('cardNumber')} /> {errors.cardNumber && <p className="text-sm text-destructive">{errors.cardNumber.message}</p>} </div> {/* other fields */} </div> ) }
| Approach | Re-Renders | Type Safety | Server Validation | Complexity | Best For |
|---|---|---|---|---|---|
| useState per field | Every keystroke | Manual types | Manual | Low | Simple forms, prototypes |
| React Hook Form | Minimal β uncontrolled | With z.infer | Manual | Medium | Production forms, complex UX |
| RHF + Zod + Server Action | Minimal β uncontrolled | Automatic from schema | Same schema | Medium-High | Full-stack type-safe forms |
| useActionState only | On server response | From action type | Built-in | Low | Simple server-only forms |
| Formik | Every keystroke | Manual types | Manual | Medium | Legacy projects (not recommended for new code) |
π― Key Takeaways
- Zod schema is the single source of truth β drives client validation, server validation, and TypeScript types
- React Hook Form uses uncontrolled inputs β 90% fewer re-renders than useState-based forms
- Server actions must re-validate with the same Zod schema β client validation is bypassable
- z.infer<typeof schema> produces TypeScript types automatically β no manual type definitions
- Use register for native inputs, Controller for custom components β mixing causes silent failures
- safeParse returns { success, data, error } β never use parse() in server actions (it throws)
β Common Mistakes to Avoid
Interview Questions on This Topic
- QHow does Zod, React Hook Form, and Next.js Server Actions work together to create type-safe forms?Mid-levelReveal
- QA form validates correctly in the browser but the server receives invalid data. What is happening and how do you fix it?SeniorReveal
- QWhat is the difference between register and Controller in React Hook Form?JuniorReveal
- QHow would you build a multi-step form wizard with React Hook Form that validates incrementally?Mid-levelReveal
Frequently Asked Questions
Can I use Zod without React Hook Form?
Yes. Zod is a standalone validation library β it works with any form library or no form library at all. You can call schema.safeParse(data) in a plain function, a Server Action, or an API route. React Hook Form is optional β zodResolver is the bridge between them.
How do I handle file uploads with Zod and React Hook Form?
Zod can validate files with z.instanceof(File) or z.custom<File>(). React Hook Form handles file inputs via register β the File object is available in the form data. For Server Actions, use FormData directly (formData.get('file')) and validate the File object with Zod after extraction.
What is the difference between safeParse and parse in Zod?
parse() throws a ZodError if validation fails β you must wrap it in try/catch. safeParse() returns { success: true, data } or { success: false, error } β it never throws. In server actions, always use safeParse() to avoid unhandled exceptions that show 500 errors to users.
Can I use Yup instead of Zod with React Hook Form?
Yes β React Hook Form supports Yup via @hookform/resolvers/yup. However, Zod has better TypeScript integration (z.infer produces types automatically, Yup requires manual type definitions) and is more widely adopted in the Next.js ecosystem. For new projects, Zod is the recommended choice.
How do I test forms built with Zod and React Hook Form?
Test at two levels: unit test the Zod schema directly (schema.safeParse(validData) should pass, schema.safeParse(invalidData) should fail) and integration test the form with React Testing Library (fill fields, submit, assert errors appear). The schema tests are fast and do not require a DOM β run them in your CI pipeline.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.