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
Plain-English First
Think of Zod as a bouncer at a club door. It checks every piece of data against a set of rules — is the email a real email? Is the age a number? Is the name at least 2 characters? React Hook Form is the efficient receptionist who only asks the bouncer about the guest who just arrived, not every guest in the building. Together, they ensure only valid data gets through, and they do it fast.
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.
io.thecodeforge.forms.zod-schema.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// ============================================// 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 messagesexportconst 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 actiontypeSignupFormValues = z.infer<typeof signupSchema>
// ---- Partial schema for update forms ----// Derive update schema from the create schemaexportconst 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 — simplerexportconst loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required'),
rememberMe: z.coerce.boolean().default(false),
})
typeLoginFormValues = z.infer<typeof loginSchema>
// ---- Invoice schema — complex nested structure ----exportconst 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),
})
exportconst 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']),
})
typeInvoiceFormValues = z.infer<typeof invoiceSchema>
Zod Schema as a Contract
Client uses the schema via zodResolver — validates on blur and on submit
Server uses the schema via schema.parse() — validates before processing
Change the schema once — validation, types, and error messages update everywhere
The schema is the single source of truth — no duplicate validation logic
Production Insight
Zod schemas are imported by both client and server — one definition, two validation points.
If the schemas diverge, the client accepts data the server rejects — or vice versa.
Rule: define the schema in a shared file — import it on both sides.
Key Takeaway
Zod schema is the single source of truth — drives validation, types, and error messages.
z.infer<typeof schema> produces TypeScript types automatically — no manual type definitions.
Refine for cross-field validation — password matching, conditional requirements.
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.
Use register for native HTML inputs — input, textarea, select elements
Use Controller (or FormField in shadcn/ui) for custom components — shadcn/ui Select, Checkbox, Switch
Mixing them incorrectly causes validation to silently fail — the field value is not tracked
register returns { name, onChange, onBlur, ref } — spread directly on the element
Controller wraps the component and manages onChange/onBlur manually — required for non-native elements
Production Insight
React Hook Form uses uncontrolled inputs — typing does not trigger re-renders.
This is 90% fewer re-renders than useState-based forms — visible in React DevTools.
Rule: use register for native inputs, Controller for custom components — never mix.
Key Takeaway
React Hook Form + zodResolver connects Zod to form validation — one schema, two validation points.
Use register for native inputs, Controller for custom components — mixing causes silent failures.
Server errors must be explicitly set via form.setError() — they do not appear automatically.
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.
io.thecodeforge.forms.server-action.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// ============================================// 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 clientexportasyncfunctionsignupAction(
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') asstring,
email: formData.get('email') asstring,
password: formData.get('password') asstring,
confirmPassword: formData.get('confirmPassword') asstring,
role: formData.get('role') asstring,
acceptTerms: formData.get('acceptTerms') === 'true',
bio: (formData.get('bio') asstring) || undefined,
website: (formData.get('website') asstring) || 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 validatedimport { updateProfileSchema } from'@/lib/schemas/signup'exportasyncfunctionupdateProfileAction(
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 Validates for UX, Server Validates for Security
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
Production Insight
Server actions must re-validate with the same Zod schema — client validation is bypassable.
safeParse returns a result object — never use parse() in server actions (it throws).
Rule: validate on client for UX, validate on server for security — both use the same schema.
Key Takeaway
Server actions import the same Zod schema as the client — validation is always in sync.
Business rules (duplicate email, auth) go in the server action — not in the Zod schema.
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.
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
Production Insight
useActionState is the React 19 way to connect forms and Server Actions.
It provides state, the wrapped action, and pending status in one hook.
Rule: use useActionState for all new Next.js 16+ forms.
Key Takeaway
useActionState is the React 19 hook that connects forms to Server Actions.
It returns [state, formAction, pending] — pending tells you if the action is running.
The form uses action={formAction} — React handles submission automatically.
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.
Zod array schema validates each item independently — errors appear on specific fields.
useWatch for computed values (totals) — not form.getValues() which does not trigger re-renders.
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.
The billing dashboard showed 3,200 new subscriptions in 4 hours — far above the normal 50-100 per day. All subscriptions had email addresses like 'a@b' or 'test' and quantities of -1 or 0. The Stripe API accepted the requests because the server never validated the input.
Assumption
Zod validation on the client was sufficient — users would always submit through the browser form.
Root cause
The signup form used React Hook Form with Zod validation via zodResolver. The validation worked perfectly in the browser — invalid fields showed errors and the submit button was disabled. However, the server-side API route accepted the request body directly without re-validating. A bot sent POST requests to /api/subscribe with crafted JSON payloads that bypassed the browser entirely. The server trusted the client-validated data and passed it to Stripe.
Fix
Added the same Zod schema to the server action. The server action calls schema.parse(data) before processing — if validation fails, it returns field errors to the client. Added rate limiting to the API route (10 requests per minute per IP). Added honeypot fields to the form that bots fill but humans do not. The Zod schema is now the single source of truth — imported by both the client form and the server action.
``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 }
``
Key lesson
Client validation is for UX — server validation is for security. Never trust client-validated data.
The same Zod schema must be used on both client and server — define it once, import it twice.
Bots bypass browser forms entirely — they send raw HTTP requests to your API endpoints.
Rate limiting and honeypot fields are defense-in-depth — they do not replace server-side validation.
Production debug guideDiagnose common form validation and state issues5 entries
Symptom · 01
Form errors not displaying after submission
→
Fix
Check that FormMessage is rendered inside FormField — it reads errors from FormField context, not from useForm directly
Symptom · 02
Zod schema validation passes but server rejects the data
→
Fix
Compare the client Zod schema with the server Zod schema — they may have diverged. Use a single shared schema file.
Symptom · 03
Form re-renders on every keystroke
→
Fix
Verify you are using React Hook Form's register — not controlled state with useState. Check for useWatch on the entire form instead of specific fields.
Symptom · 04
Server Action returns errors but form does not display them
→
Fix
Use useActionState to receive server action results — pass the errors to form.setError() for each field
Symptom · 05
TypeScript errors on form field names
→
Fix
Ensure the form is typed with z.infer<typeof schema> — field names must match the Zod schema keys exactly
★ Zod + React Hook Form Quick Debug ReferenceFast commands for diagnosing form issues
Use register for native inputs, Controller for custom components
mixing causes silent failures
6
safeParse returns { success, data, error }
never use parse() in server actions (it throws)
Common mistakes to avoid
6 patterns
×
Validating on client only — not re-validating on the server
Symptom
Bots bypass the browser and send invalid data directly to the API. Users with modified JavaScript can submit any data. The server accepts it because it trusts the client.
Fix
Import the same Zod schema in the server action and call schema.safeParse(data) before processing. Client validation is for UX — server validation is for security.
×
Using useState for form fields instead of React Hook Form
Symptom
Every keystroke triggers a re-render of the entire form component. Forms with 10+ fields become noticeably slow. React DevTools shows hundreds of re-renders per minute during typing.
Fix
Use React Hook Form with register for native inputs. It uses refs instead of state — typing does not trigger re-renders. The performance difference is 90% fewer re-renders.
×
Defining separate Zod schemas for client and server
Symptom
Client accepts data that the server rejects — or vice versa. Validation rules drift over time as developers update one schema but forget the other.
Fix
Define the Zod schema in a shared file (lib/schemas/). Import it on both the client (via zodResolver) and the server (via safeParse). One schema, two validation points.
×
Using register on shadcn/ui custom components
Symptom
The component does not respond to validation — errors do not display, the value is not tracked, form submission includes undefined for the field.
Fix
Use Controller or FormField for custom components (shadcn/ui Select, Checkbox, Switch). Use register only for native HTML inputs (input, textarea, select).
×
Not using useWatch for computed values in field arrays
Symptom
Invoice totals do not update as the user types — the values are stale because form.getValues() does not trigger re-renders.
Fix
Use useWatch({ control: form.control, name: 'items' }) to subscribe to field array changes. Recompute totals from the watched values — they update on every change.
×
Using z.parse() in server actions instead of z.safeParse()
Symptom
Server action throws an unhandled ZodError — the user sees a 500 error page instead of field-level validation errors.
Fix
Use safeParse() which returns { success, data, error } — never throws. Map the error.issues to field-level messages and return them to the client.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
How does Zod, React Hook Form, and Next.js Server Actions work together ...
Q02SENIOR
A form validates correctly in the browser but the server receives invali...
Q03JUNIOR
What is the difference between register and Controller in React Hook For...
Q04SENIOR
How would you build a multi-step form wizard with React Hook Form that v...
Q01 of 04SENIOR
How does Zod, React Hook Form, and Next.js Server Actions work together to create type-safe forms?
ANSWER
The three tools form a stack where Zod is the foundation:
1. Zod schema defines the validation rules and data shape. It is the single source of truth — imported by both client and server.
2. React Hook Form manages form state with uncontrolled inputs (refs, not state). zodResolver connects the Zod schema to React Hook Form's validation lifecycle. z.infer<typeof schema> produces the TypeScript type for the form values.
3. Next.js Server Actions receive FormData on the server. They import the same Zod schema and call safeParse() to validate before processing. If validation fails, they return field-level errors that the client displays.
The result: one schema drives validation on both client and server, TypeScript types are inferred automatically, and the form has minimal re-renders because React Hook Form uses uncontrolled inputs.
Q02 of 04SENIOR
A form validates correctly in the browser but the server receives invalid data. What is happening and how do you fix it?
ANSWER
This means either the client validation was bypassed (a bot sent raw HTTP requests) or the client and server Zod schemas have diverged.
Diagnosis steps:
1. Check if the server action re-validates with Zod — if it trusts the request body without validation, that is the root cause.
2. Compare the client schema import path with the server schema import path — they should reference the same file.
3. Check if the schemas have diverged — add a field to the client schema but not the server schema (or vice versa).
Fix:
1. Import the same Zod schema in the server action: import { signupSchema } from '@/lib/schemas/signup'
2. Call schema.safeParse(data) before processing — return field errors if validation fails.
3. Add a CI check that verifies the client and server import the same schema file.
4. Add rate limiting to the API route to prevent bot abuse.
The fundamental rule: client validation is for UX (instant feedback), server validation is for security (prevents bypass). Both are required.
Q03 of 04JUNIOR
What is the difference between register and Controller in React Hook Form?
ANSWER
register returns props (name, onChange, onBlur, ref) that you spread on a native HTML input element. It uses the DOM's native change events and refs to track values — no React state involved. This is why it has minimal re-renders.
Controller wraps a custom component and manages its value and change handler manually. It is required when the component does not accept standard input props — like shadcn/ui's Select, Checkbox, or Switch, which have their own onChange signatures (onValueChange, onCheckedChange).
The rule: use register for native inputs (input, textarea, select HTML elements). Use Controller (or FormField in shadcn/ui) for custom components. Mixing them — trying to register a custom component — causes the value to not be tracked and validation to silently fail.
Q04 of 04SENIOR
How would you build a multi-step form wizard with React Hook Form that validates incrementally?
ANSWER
The pattern uses a single form instance with conditional rendering per step:
1. Define the full Zod schema with all fields from all steps.
2. Create per-step schemas using schema.pick() — each picks only the fields for that step.
3. Use one useForm instance — do not create separate forms per step.
4. On step transition, call form.trigger(stepFields) to validate only the current step's fields. If valid, advance. If invalid, show errors and stay.
5. On final submit, the full schema validates all fields — the user cannot skip step validation.
6. Use FormProvider to share the form context with step components. Each step uses useFormContext() to access register and formState.
The key insight: one form, one schema, per-step validation on transition, full validation on submit. This preserves all data across steps and prevents skipping validation.
01
How does Zod, React Hook Form, and Next.js Server Actions work together to create type-safe forms?
SENIOR
02
A form validates correctly in the browser but the server receives invalid data. What is happening and how do you fix it?
SENIOR
03
What is the difference between register and Controller in React Hook Form?
JUNIOR
04
How would you build a multi-step form wizard with React Hook Form that validates incrementally?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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.
Was this helpful?
04
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.
Was this helpful?
05
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.