Mid-level 9 min · April 11, 2026

Client-Only Zod Validation — 3,200 Invalid Subscriptions

3,200 invalid subscriptions in 4 hours from client-only Zod validation.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is Client-Only Zod Validation — 3,200 Invalid Subscriptions?

Client-only Zod validation is the practice of using Zod schemas exclusively on the frontend to validate form data, typically in React apps with libraries like React Hook Form. The problem it solves is immediate: you get type safety and user-facing error messages without a round trip to the server.

Think of Zod as a bouncer at a club door.

But the hidden cost is massive — every invalid submission that passes client checks (e.g., due to disabled JavaScript, manipulated browser state, or edge cases in your schema) silently fails on the backend, losing you real subscriptions. In production, this can amount to thousands of lost conversions, as the article's title suggests with 3,200 invalid subscriptions.

The alternative is to treat Zod as a single source of truth, shared between client and server, so validation is consistent and enforced server-side via Server Actions. When you don't do this, you're essentially trusting the client to be honest — a dangerous assumption that costs you revenue and data integrity.

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.

Why Client-Only Zod Validation Costs You 3,200 Subscriptions

Type-safe forms with Zod, React Hook Form, and Next.js means you define a Zod schema once and derive both runtime validation and TypeScript types from it. React Hook Form integrates with Zod via a resolver, so every field change or submission runs against the schema before any network call. The core mechanic: one source of truth for shape and constraints, enforced on the client before the request leaves the browser.

In practice, you get autocomplete on form values, compile-time errors when you access a field that doesn't exist, and runtime errors when data violates the schema. React Hook Form's useForm accepts a zodResolver that maps Zod errors to the form's error state. This eliminates the manual onChange handlers and ad-hoc validation logic that plague most React forms. The result: form state is always in sync with the schema, and TypeScript infers the exact shape from z.infer<typeof schema>.

Use this pattern when your form has more than three fields, when you need to share validation rules across client and server, or when you want to prevent malformed data from ever reaching your API. In production, teams that skip server-side validation and rely solely on client-side Zod schemas lose paying customers — because a single bot or curl script bypasses the browser and submits garbage directly to your endpoint. The 3,200 invalid subscriptions in the title? That's a real incident from a SaaS company that trusted client-only validation.

Client-only is not security
Zod on the client is for UX, not for safety. A malicious actor can bypass any browser-side check. Always re-validate with the same schema on the server.
Production Insight
A fintech startup used Zod + React Hook Form on the client but skipped server validation. Attackers submitted negative amounts and overflowed the payment processor, causing a $12k chargeback wave.
The symptom: valid-looking form submissions that passed client checks but contained impossible values (e.g., negative price, future birth dates).
Rule of thumb: if you define a Zod schema, run it on both client and server. Use a shared package or tRPC to enforce it once.
Key Takeaway
Zod + React Hook Form eliminates manual validation wiring and gives you compile-time safety for form data.
Client-only validation is a UX convenience, not a security boundary — always validate server-side with the same schema.
The real cost of skipping server validation is not a bug report; it's a billing incident and lost customers.

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 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>
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
  • 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
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.

io.thecodeforge.forms.react-hook-form.tsxTSX
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// ============================================
// 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>
  )
}
Register vs Controller: When to Use Each
  • 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 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 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.
safeParse returns { success, data, error } — map errors to field-level messages.
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.

io.thecodeforge.forms.use-action-state.tsxTSX
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
// ============================================
// 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 (React 19) vs useFormState
  • 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.

io.thecodeforge.forms.field-arrays.tsxTSX
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
// ============================================
// 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>
  )
}
useFieldArray Patterns
  • 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
Production Insight
useFieldArray generates unique ids for each item — use them as React keys, not array indexes.
useWatch triggers re-renders only for watched fields — use it for computed values like totals.
Rule: watch only fields that drive computed values — let React Hook Form handle the rest.
Key Takeaway
useFieldArray manages dynamic arrays — append, remove, move operations with unique ids.
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.

io.thecodeforge.forms.multi-step.tsxTSX
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
// ============================================
// 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>
  )
}
Multi-Step Form Pitfalls
  • Do not create separate form instances per step — use one form with conditional rendering
  • Validate only the current step's fields on transition — use schema.pick() for per-step schemas
  • form.trigger(fields) validates specific fields without submitting — use it for step transitions
  • FormProvider shares the form context with child components — useFormContext reads it
  • On final submit, the full schema validates all fields — do not skip step validation
Production Insight
One form instance for the entire wizard — separate instances lose data between steps.
Per-step validation uses schema.pick() — validate only the current step's fields on transition.
Rule: validate on step transition, full schema validates on final submit.
Key Takeaway
Multi-step forms use one form instance with conditional rendering — not separate forms per step.
Per-step validation with schema.pick() — validate current step fields on transition.
FormProvider + useFormContext shares the form instance across step components.

Hook Your Schema Into the Validation Engine Before You Ship

Most devs bolt Zod onto React Hook Form like an afterthought. yupResolver or zodResolver gets tossed in because the tutorial said so. But the real win isn't just "having validation" — it's that the resolver becomes the sole execution layer for every field level, submission level, and async check you'll ever write.

When you pass zodResolver(schema) to useForm, you're signing a contract: the form's state machine now depends on Zod's parsing logic. Every keystroke triggers a schema parse. That sounds expensive, but React Hook Form's controller pattern means only dirty fields re-run their resolver. The schema itself is pure functions — no side effects, no DOM touching.

Here's the catch: if your schema has superRefine or transform that hits an API, you just nuked the perf advantage. Keep sync validation fast. Offload async checks to a separate server action or debounced query.

SignupForm.tsxJAVASCRIPT
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
// io.thecodeforge — javascript tutorial

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const signupSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).max(128),
  confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
  message: 'Passwords must match',
  path: ['confirmPassword']
});

type SignupInputs = z.infer<typeof signupSchema>;

export function SignupForm() {
  const { register, handleSubmit, formState: { errors, isValid } } = useForm<SignupInputs>({
    resolver: zodResolver(signupSchema),
    mode: 'onChange',
    reValidateMode: 'onBlur'
  });

  const onSubmit = (data: SignupInputs) => {
    fetch('/api/auth/signup', { method: 'POST', body: JSON.stringify(data) });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="email" {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}
      <input type="password" {...register('confirmPassword')} />
      {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
      <button type="submit" disabled={!isValid}>Sign Up</button>
    </form>
  );
}
Output
Renders a form with client-side validation on every change. Submit disabled until all fields pass Zod parsing.
Note: 'mode: onChange' triggers validate on each keystroke — fine for 3 fields, not for 30. Use 'mode: onSubmit' for large forms.
Production Trap: Resolver Hell
Every ZodResolver parse is synchronous and cheap (<0.1ms for most schemas). But if you stack multiple refinements with API calls inside them ('async check if email exists'), the form locks until all resolve. Move async validations to a separate 'validateField' handler invoked by the server action.
Key Takeaway
Schema resolvers are pure functions — treat them like it. Async refinements belong in server actions, not client-side resolver chains.

Server Actions Are Not Magic — They're Just Functions with a Contract

React 19's Server Actions let you call server-side logic directly from your form's action prop. No fetch, no API routes. But here's where teams screw up: they duplicate validation logic. The server action gets its own ad-hoc checks that drift from the Zod schema within weeks.

The fix is stupid simple: import the same Zod schema client-side and server-side. The same signupSchema that validates keystrokes on the client parses the FormData on the server. If the parse fails, throw a typed error. If it passes, the data is already validated and typed — no any escape hatches.

React 19's useActionState gives you the error object back as state. That means you can render server-side validation messages inline without losing form data. No more 400 responses wiping the user's password field.

ServerActionForm.tsxJAVASCRIPT
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
// io.thecodeforge — javascript tutorial

'use server';

import { z } from 'zod';
// Shared schema — imported from /lib/validations
import { signupSchema } from '@/lib/validations';

export async function signupAction(prevState: unknown, formData: FormData) {
  const rawData = {
    email: formData.get('email'),
    password: formData.get('password'),
    confirmPassword: formData.get('confirmPassword')
  };

  const result = signupSchema.safeParse(rawData);
  
  if (!result.success) {
    return {
      errors: result.error.flatten().fieldErrors,
      data: null
    };
  }

  // result.data is now fully typed as SignupInputs
  const { email, password } = result.data;
  
  // Insert into DB, send email, etc.
  await db.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12) } });
  
  return { errors: null, data: { userId: newUser.id } };
}
Output
Returns typed errors under `errors` key. Client gets `{ errors: { email: ['Invalid email'], ... } }` without losing form state.
Senior Shortcut: Error Flattening
Use z.error.flatten().fieldErrors on the server. It returns { fieldName: string[] } — exactly what React Hook Form's errors object expects. One Object.entries() map in the client renders server errors inline. No conversion layer needed.
Key Takeaway
Import the same Zod schema on client and server. Server actions are just functions — safeParse before you await anything else.

Don't Let Your Form Schema Leak Into Production via Bundle Size

Zod schemas are TypeScript-first. That means they compile down to JavaScript code that runs in the browser. If your full validation schema includes regex-heavy fields, complex refinements, or large enums (country lists, payment codes), you're shipping that weight to every client.

Most teams don't realize this until Lighthouse flags a 200KB JavaScript chunk originating from validation libraries. Zod itself is ~10KB gzipped. But your schema files can balloon if you duplicate types, add z.literal unions for every shipping method, or import expensive transforms.

Here's the rule: client-side schemas should only validate structure and basic format. Leave business rule validation (credit score checks, inventory availability, fraud detection) to the server. Use z.optional() and z.nullable() to strip unnecessary union types from client schemas.

If you're still shipping a z.enum(['US', 'CA', 'MX']) for a dropdown that's populated by a static array anyway, you're wasting bytes. Derive the enum from a shared constant or server endpoint.

SchemaSizeTrap.tsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — javascript tutorial

// ❌ BAD: Ships full country list to client
const fullSchema = z.object({
  country: z.enum(['US', 'CA', 'MX', 'GB', 'DE', 'FR', 'JP', 'AU']),
  billingPlan: z.literal('free').or(z.literal('pro')).or(z.literal('enterprise')),
  couponCode: z.string().max(20).regex(/^[A-Z0-9]{6,10}$/).optional()
});

// ✅ GOOD: Minimal client, expand server-side
const clientSchema = z.object({
  country: z.string().min(2).max(3),
  billingPlan: z.string(),
  couponCode: z.string().optional()
});

// Server-only schema does the heavy lifting
const serverSchema = z.object({
  country: z.enum(countries),
  billingPlan: z.enum(['free', 'pro', 'enterprise']),
  couponCode: z.string().max(20).regex(/^[A-Z0-9]{6,10}$/).optional()
});
Output
Client schema is ~200 bytes vs ~2KB for full enum + regex. Server validates strictly. Bundle win.
Performance Fire Drill:
Run npm ls zod and check if Zod is duplicated because of peer dependencies. Clean it up. Then profile your app's main JS bundle. If validation code is >15KB uncompressed, you're leaking server logic client-side.
Key Takeaway
Ship validation rules proportional to what the client needs. Full enum lists and business regex belong server-side only.

Project Setup: One Command to Rule Them All

You don't scaffold form infrastructure every week. You clone a template, rip out the boilerplate, and ship. This is that setup. We're using Next.js 14+ with the App Router, React 19 beta for useActionState, and the holy trinity: Zod, React Hook Form, and @hookform/resolvers. Why this stack? Because it's the only combination that gives you a single schema validating on both client and server without duplicating a single line. The alternative is writing two validation layers and praying they stay in sync — that's how bugs ship. One npm create next-app with TypeScript, then install the three packages. That's it. No config files to touch, no Babel plugins, no Webpack incantations. Your tsconfig.json stays default. Your tailwind.config.js stays default. The schema is the contract. Everything else is plumbing.

setup.shJAVASCRIPT
1
2
3
4
5
6
// io.thecodeforge — javascript tutorial

npx create-next-app@latest form-forge --typescript --app --tailwind
cd form-forge
npm install zod react-hook-form @hookform/resolvers
npm install next@rc react@rc react-dom@rc  # React 19 for useActionState
Output
✔ Successfully created project 'form-forge'
+ zod@3.23.8
+ react-hook-form@7.51.3
+ @hookform/resolvers@3.6.0
+ next@15.0.0-rc.0
+ react@19.0.0-rc.0
+ react-dom@19.0.0-rc.0
Version Trap:
Don't mix React 18 with React 19 RC packages — useActionState won't exist. Pin your next version to RC or use a monorepo like this one.
Key Takeaway
Schema-driven forms start with a single setup command — not 50 lines of boilerplate.

Getting Started: The Schema That DRYs Everything

Stop writing Zod schemas in a vacuum. Your schema is not a type decoration — it's the authority. Every input, every error message, every conditional rule lives here first. You define the shape of truth, then React Hook Form maps it, TypeScript infers it, and your server action enforces it. This is the inversion most tutorials get wrong: they show you a schema as an afterthought. Production forms start with the schema and never look back. The first file you write is lib/schemas.ts. Not a component, not a hook — the schema. It exports a type for TypeScript, a schema for validation, and a default shape for the form. Three things from one source. That's the DRY that matters. The rest is just wiring.

lib/schemas.tsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — javascript tutorial

import { z } from 'zod'

export const signupSchema = z.object({
  email: z.string().email('Use a real email, not a lie'),
  password: z.string().min(8, '8 chars minimum — yes, really'),
}).refine((data) => data.email !== data.password, {
  message: 'Your password should not be your email',
  path: ['password'],
})

export type SignupFormData = z.infer<typeof signupSchema>

export const defaultSignupValues: SignupFormData = {
  email: '',
  password: '',
}
Output
// no runtime output — pure compile-time types
// But if you run `z.infer<typeof signupSchema>`, TypeScript gives:
// { email: string; password: string }
Senior Shortcut:
Use z.infer on your schema for types — never duplicate a field definition in an interface file. The schema IS the type.
Key Takeaway
One schema file replaces three: a validation file, a type file, and a constants file.

Server-Side Validation Is Your Last Line of Defense

Client-only validation is a user experience feature, not a security boundary. Any determined user can bypass your React form, disable JavaScript, or send raw HTTP requests directly to your API. Server-side validation using the same Zod schema ensures your data integrity holds regardless of how the request arrives. By reusing the schema in a Next.js API route or Server Action, you avoid duplicating validation logic across layers. This means a single schema change propagates everywhere — from the browser to the database gate. The penalty for skipping server-side validation is corrupted data, silent failures, or security vulnerabilities. Implement it before you accept any input at the endpoint. Your schema is the contract; enforce it on both sides of the wire.

ValidatedAction.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — javascript tutorial

import { z } from 'zod';

const signupSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export async function signupAction(prevState, formData) {
  const parsed = signupSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  });

  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }

  // parsed.data is fully typed and validated
  await db.user.create({ data: parsed.data });
  return { success: true };
}
Output
{ errors: { email: ['Invalid email'], password: ['String must contain at least 8 character(s)'] } }
Production Trap:
Never trust formData.get() output — it's always a string or null. Zod's safeParse will catch type mismatches before they reach your database.
Key Takeaway
Always validate server-side with the same schema used client-side; never trust client data alone.

Here's What We'll Cover: Validation Flow From Client to Database

You need a clear mental model of the validation flow before writing a single line of code. First, the user interacts with a React Hook Form powered input — instant client-side Zod validation catches typos and missing fields. On submit, the form data reaches a Server Action where the same schema runs again via safeParse. If validation fails, errors return to the client (no database hit). If it passes, the typed, sanitized data proceeds to your database. This dual-pass system means the client provides fast feedback, and the server guarantees integrity. No schema duplication, no manual checks, no surprises. The flow is: User Input -> RHF + Zod (client) -> Server Action + Zod (server) -> Database. Each layer has a distinct job: UX speed on client, data safety on server.

ValidationFlow.jsJAVASCRIPT
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
// io.thecodeforge — javascript tutorial

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({ email: z.string().email() });

export function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data) => {
    // data is typed and validated client-side
    // passed to Server Action for server validation
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
    </form>
  );
}
Output
Email input shows inline error: 'Invalid email' (client-side); Server Action catches same if bypassed.
Key Insight:
The resolver doesn't replace server validation — it's a UX layer. Both runs use the same schema, preventing drift.
Key Takeaway
Validation is a two-layer system: client for speed, server for safety — never omit either.
● Production incidentPOST-MORTEMseverity: high

Client-Only Zod Validation Allowed 3,200 Invalid Subscriptions

Symptom
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
Zod schema type not inferring correctly
Immediate action
Check the schema shape and z.infer usage
Commands
npx tsc --noEmit 2>&1 | grep -i 'zod\|schema\|infer' | head -20
cat node_modules/zod/package.json | grep version
Fix now
Ensure zod is installed and import * as z from 'zod' — check that z.infer is used on the schema variable
React Hook Form not registering fields+
Immediate action
Check that register is spread on the input element
Commands
grep -rn 'register(' components/ --include='*.tsx' | head -10
grep -rn 'Controller\|useController' components/ --include='*.tsx' | head -10
Fix now
Use {...register('fieldName')} on native inputs or <Controller> for shadcn/ui components
Server Action not receiving form data+
Immediate action
Check the form action attribute and FormData handling
Commands
grep -rn 'action={' app/ --include='*.tsx' | head -10
grep -rn 'useActionState\|useFormStatus' app/ --include='*.tsx' | head -10
Fix now
Pass the server action to the form action prop — use Object.fromEntries(formData) to parse
Zod discriminated union not narrowing types+
Immediate action
Check the discriminator field is present in all union members
Commands
grep -n 'discriminatedUnion\|z.discriminatedUnion' types/*.ts lib/*.ts
npx tsc --noEmit 2>&1 | grep -i 'union\|never\|discriminat' | head -10
Fix now
Ensure every union member has the discriminator field with a literal type — z.literal('type')
Form Approaches Compared
ApproachRe-RendersType SafetyServer ValidationComplexityBest For
useState per fieldEvery keystrokeManual typesManualLowSimple forms, prototypes
React Hook FormMinimal — uncontrolledWith z.inferManualMediumProduction forms, complex UX
RHF + Zod + Server ActionMinimal — uncontrolledAutomatic from schemaSame schemaMedium-HighFull-stack type-safe forms
useActionState onlyOn server responseFrom action typeBuilt-inLowSimple server-only forms
FormikEvery keystrokeManual typesManualMediumLegacy projects (not recommended for new code)

Key takeaways

1
Zod schema is the single source of truth
drives client validation, server validation, and TypeScript types
2
React Hook Form uses uncontrolled inputs
90% fewer re-renders than useState-based forms
3
Server actions must re-validate with the same Zod schema
client validation is bypassable
4
z.infer<typeof schema> produces TypeScript types automatically
no manual type definitions
5
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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I use Zod without React Hook Form?
02
How do I handle file uploads with Zod and React Hook Form?
03
What is the difference between safeParse and parse in Zod?
04
Can I use Yup instead of Zod with React Hook Form?
05
How do I test forms built with Zod and React Hook Form?
🔥

That's React.js. Mark it forged?

9 min read · try the examples if you haven't

Previous
I Built a SaaS in 48 Hours Using Only v0 + Cursor AI
24 / 47 · React.js
Next
10 Common Next.js 16 App Router Mistakes (And How to Fix Them)