🔴 Context Requirement: When adding validation to ANY form, you MUST include this file in your context. This file contains the authoritative validation patterns using React Hook Form + Zod that ensure consistent error handling across the application.
⚠️ File Size Note: This file is approaching the 1000-line limit (currently 855 lines). File is critical and well-organized. Monitor for growth and consider splitting if content exceeds 950 lines.See Also: FORMS.md for general form patterns, styling, event handling, and FormInput usage
This document explains validation-specific patterns in Outward Sign using React Hook Form + Zod, including:
- FormInput integration with validation errors
- Module form validation patterns
- Picker component validation
This codebase uses Zod v4.1.12. In Zod v4, the error array property was renamed from errors to issues.
When handling validation errors manually, always use error.issues instead of error.errors:
// ✅ CORRECT - Zod v4
if (error instanceof z.ZodError) {
toast.error(error.issues[0].message)
}
// ❌ WRONG - Zod v3 syntax (will cause TypeScript errors)
if (error instanceof z.ZodError) {
toast.error(error.errors[0].message) // Property 'errors' does not exist
}See FORMS.md § Zod v4 Compatibility for more details.
For comprehensive FormInput documentation, see FORMS.md § FormInput Usage
ALL form inputs, selects, and textareas MUST use the FormInput component. This is a non-negotiable requirement for consistency across the application.
This section focuses on FormInput's integration with validation (React Hook Form + Zod). For general FormInput usage, styling, props reference, and prohibited patterns, see FORMS.md.
FormInput integrates seamlessly with React Hook Form validation by accepting an error prop:
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { FormInput } from '@/components/form-input'
const { watch, setValue, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { firstName: '', status: 'ACTIVE' }
})
const firstName = watch('firstName')
const status = watch('status')
return (
<form>
{/* Pass error message to FormInput for validation display */}
<FormInput
id="first-name"
label="First Name"
value={firstName}
onChange={(value) => setValue('firstName', value)}
error={errors.firstName?.message} // ← Shows validation errors
required
/>
<FormInput
id="status"
label="Status"
inputType="select"
value={status}
onChange={(value) => setValue('status', value)}
error={errors.status?.message} // ← Shows validation errors
options={statusOptions}
/>
</form>
)Key Points:
- ✅ Pass
error={errors.fieldName?.message}to display validation errors - ✅ FormInput automatically adds red border when error is present
- ✅ Error message displays below the input
- ✅ Works with all input types (text, textarea, select, etc.)
This section explains how to implement form validation for module forms (weddings, funerals, presentations, baptisms, etc.) using React Hook Form + Zod.
We use React Hook Form (v7.65.0) + Zod (v4.1.12) for form validation with a dual validation pattern:
- Client-side validation - Automatic via React Hook Form with zodResolver (provides instant feedback)
- Server-side validation - Security boundary using
.parse()(required, always enforce)
Why React Hook Form?
- Automatic form state management (no manual useState for each field)
- Built-in validation with Zod via zodResolver
- Automatic error display
- Better performance (less re-renders)
- Cleaner, more maintainable code
The required packages are already installed in this project:
"react-hook-form": "^7.65.0",
"zod": "^4.1.12",
"@hookform/resolvers": "^3.9.1"To add validation to a module (e.g., presentations, weddings, baptisms):
- Create schema file -
src/lib/schemas/[entity].tswith Zod schemas - Update server actions - Import schema and add
.parse()validation in create/update functions - Update form component - Replace manual state with
useForm+zodResolver - Test - Run
npm test -- tests/[entity].spec.tsto verify validation works
Reference Implementation: Presentations module (src/app/(main)/presentations/)
CRITICAL: Zod schemas MUST be defined in a separate shared file, NOT in 'use server' files. Next.js only allows 'use server' files to export async functions.
Location: Create schemas in lib/schemas/[entity].ts
Pattern:
// lib/schemas/presentations.ts
import { z } from 'zod'
// Define the Zod schema
export const createPresentationSchema = z.object({
presentation_event_id: z.string().uuid().optional().nullable(),
child_id: z.string().uuid().optional().nullable(),
mother_id: z.string().uuid().optional().nullable(),
father_id: z.string().uuid().optional().nullable(),
coordinator_id: z.string().uuid().optional().nullable(),
is_baptized: z.boolean().optional(),
status: z.enum(['ACTIVE', 'INACTIVE', 'ARCHIVED']).optional().nullable(),
note: z.string().optional().nullable(),
presentation_template_id: z.string().optional().nullable(),
})
// Update schema (all fields optional)
export const updatePresentationSchema = createPresentationSchema.partial()
// Export types using z.infer
export type CreatePresentationData = z.infer<typeof createPresentationSchema>
export type UpdatePresentationData = z.infer<typeof updatePresentationSchema>Location: Import schemas in lib/actions/[entity].ts
Pattern:
// lib/actions/presentations.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { requireSelectedParish } from '@/lib/auth/parish'
import { ensureJWTClaims } from '@/lib/auth/jwt-claims'
import {
createPresentationSchema,
updatePresentationSchema,
type CreatePresentationData,
type UpdatePresentationData
} from '@/lib/schemas/presentations'
// Note: Schemas and types are imported from '@/lib/schemas/presentations'
// They cannot be re-exported from this 'use server' file
// Server action with validation
export async function createPresentation(data: CreatePresentationData): Promise<Presentation> {
const selectedParishId = await requireSelectedParish()
await ensureJWTClaims()
const supabase = await createClient()
// REQUIRED: Server-side validation (security boundary)
const validatedData = createPresentationSchema.parse(data)
const { data: presentation, error } = await supabase
.from('presentations')
.insert([{
...validatedData,
parish_id: selectedParishId
}])
.select()
.single()
if (error) {
console.error('Error creating presentation:', error)
throw new Error('Failed to create presentation')
}
revalidatePath('/presentations')
return presentation
}Location: Client components (app/(main)/[entities]/[entity]-form.tsx)
Pattern:
'use client'
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import {
createPresentation,
updatePresentation,
type PresentationWithRelations
} from '@/lib/actions/presentations'
import {
createPresentationSchema,
type CreatePresentationData
} from '@/lib/schemas/presentations'
import { FormBottomActions } from '@/components/form-bottom-actions'
import { FormInput } from '@/components/form-input'
interface PresentationFormProps {
presentation?: PresentationWithRelations
formId?: string
onLoadingChange?: (loading: boolean) => void
}
export function PresentationForm({ presentation, formId, onLoadingChange }: PresentationFormProps) {
const router = useRouter()
const isEditing = !!presentation
// Initialize React Hook Form with Zod validation
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setValue,
watch,
} = useForm<CreatePresentationData>({
resolver: zodResolver(createPresentationSchema),
defaultValues: {
presentation_event_id: presentation?.presentation_event_id || null,
child_id: presentation?.child_id || null,
status: presentation?.status || "ACTIVE",
note: presentation?.note || null,
},
})
// Notify parent component of loading state changes
useEffect(() => {
onLoadingChange?.(isSubmitting)
}, [isSubmitting, onLoadingChange])
// Watch form values for controlled components
const status = watch("status")
const note = watch("note")
const onSubmit = async (data: CreatePresentationData) => {
try {
if (isEditing) {
await updatePresentation(presentation.id, data)
toast.success('Presentation updated successfully')
router.refresh()
} else {
const newPresentation = await createPresentation(data)
toast.success('Presentation created successfully!')
router.push(`/presentations/${newPresentation.id}/edit`)
}
} catch (error) {
console.error(`Failed to ${isEditing ? 'update' : 'create'} presentation:`, error)
toast.error(`Failed to ${isEditing ? 'update' : 'create'} presentation. Please try again.`)
}
}
return (
<form id={formId} onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* Status Select - Using FormInput */}
<FormInput
id="status"
label="Status"
inputType="select"
value={status || "ACTIVE"}
onChange={(value) => setValue("status", value as any)}
options={[
{ value: 'ACTIVE', label: 'Active' },
{ value: 'INACTIVE', label: 'Inactive' },
{ value: 'ARCHIVED', label: 'Archived' }
]}
/>
{/* Notes Textarea - Using FormInput */}
<FormInput
id="note"
label="Notes"
description="Additional information about this presentation"
inputType="textarea"
value={note || ""}
onChange={(value) => setValue("note", value)}
rows={12}
/>
{/* Form Buttons */}
<FormBottomActions
isEditing={isEditing}
isLoading={isSubmitting}
cancelHref={isEditing ? `/presentations/${presentation.id}` : '/presentations'}
saveLabel={isEditing ? 'Update Presentation' : 'Save Presentation'}
/>
</form>
)
}Key Points:
- No manual useState for form fields - React Hook Form manages all form state
- Automatic validation - zodResolver connects Zod schema to React Hook Form
- Use setValue() to update fields - For FormInput components
- Use watch() to read field values - For controlled components
- Still use useState for UI state - Modal visibility, selected entities for display
- isSubmitting from formState - Use for loading state instead of manual isLoading
z.string() // Any string
z.string().min(1, 'Required') // Non-empty string
z.string().max(100, 'Too long') // Max length
z.string().email('Invalid email') // Email format
z.string().url('Invalid URL') // URL format
z.string().regex(/pattern/, 'Invalid format') // Custom regex
z.string().optional() // Optional field
z.string().nullable() // Can be null
z.string().trim() // Trim whitespacez.number() // Any number
z.number().int('Must be integer') // Integer only
z.number().positive('Must be positive') // > 0
z.number().min(0, 'Must be at least 0') // Minimum
z.number().max(100, 'Must be at most 100') // Maximum
z.coerce.number() // Convert string to numberz.string().date() // ISO date string
z.string().datetime() // ISO datetime string
z.date() // JavaScript Date object
z.coerce.date() // Convert to Datez.enum(['ACTIVE', 'INACTIVE', 'ARCHIVED']) // One of these values
z.nativeEnum(MyEnum) // TypeScript enumz.array(z.string()) // Array of strings
z.array(z.string()).min(1, 'Required') // Non-empty array
z.array(z.string()).max(10, 'Too many') // Max length❌ WRONG - Only client-side validation:
export async function createEntity(data: CreateEntityData) {
// No validation - SECURITY RISK!
const { data: entity, error } = await supabase
.from('entities')
.insert([data])
.select()
.single()
}✅ CORRECT - Server-side validation:
export async function createEntity(data: CreateEntityData) {
// REQUIRED: Validate on server
const validatedData = createEntitySchema.parse(data)
const { data: entity, error } = await supabase
.from('entities')
.insert([validatedData])
.select()
.single()
}// Throws ZodError if validation fails - good for server actions
const validatedData = schema.parse(data)CRITICAL: Schemas must be in lib/schemas/[entity].ts, NOT in 'use server' files.
// ❌ WRONG - Defining schema in 'use server' file
// lib/actions/presentations.ts
'use server'
export const schema = z.object({...}) // ERROR: Cannot export non-functions
// ✅ CORRECT - Separate schema file
// lib/schemas/presentations.ts
export const createPresentationSchema = z.object({...})
export type CreatePresentationData = z.infer<typeof createPresentationSchema>// ✅ CORRECT - Automatic validation via zodResolver
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createPresentationSchema } from '@/lib/schemas/presentations'
const { handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(createPresentationSchema),
defaultValues: {...}
})
// No manual .safeParse() needed - React Hook Form handles itThe presentations module has been fully implemented with React Hook Form + Zod validation.
Files to reference:
- Schema:
src/lib/schemas/presentations.ts- Zod schemas and types - Server Actions:
src/lib/actions/presentations.ts- Server-side validation with.parse() - Form Component:
src/app/(main)/presentations/presentation-form.tsx- React Hook Form with zodResolver
This section explains how to implement validation in picker components (PeoplePicker, EventPicker, LocationPicker, etc.) using React Hook Form + Zod for inline "Add New" forms.
Picker components that include inline "Add New" forms should use React Hook Form + Zod for validation:
- Client-side validation - Automatic via React Hook Form with zodResolver
- Visual error indicators - Red borders and error messages below invalid fields
- Standardized UI - Use FormInput component for consistent styling
At the top of the picker component file, define a Zod schema for the inline form:
import { z } from 'zod'
// Zod schema for inline "Add New" form
const newPersonSchema = z.object({
first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last name is required'),
email: z.string().optional(),
phone_number: z.string().optional(),
note: z.string().optional(),
})
type NewPersonFormData = z.infer<typeof newPersonSchema>Replace manual state management with React Hook Form:
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
// Inside component
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setValue,
watch,
reset,
} = useForm<NewPersonFormData>({
resolver: zodResolver(newPersonSchema),
defaultValues: {
first_name: '',
last_name: '',
email: '',
phone_number: '',
note: '',
},
})const onSubmitNewPerson = async (data: NewPersonFormData) => {
try {
const newPerson = await createPerson({
first_name: data.first_name,
last_name: data.last_name,
email: data.email || undefined,
phone_number: data.phone_number || undefined,
note: data.note || undefined
})
toast.success('Person created successfully')
// Reset form and close
reset()
setShowAddForm(false)
// Auto-select newly created entity
handlePersonSelect(newPerson)
} catch (error) {
console.error('Error creating person:', error)
toast.error('Failed to add person')
}
}The FormInput component now supports validation errors. Use it for all form fields:
<FormInput
id="first_name"
label="First Name"
inputType="text"
value={watch('first_name')}
onChange={(value) => setValue('first_name', value)}
placeholder="John"
required
error={errors.first_name?.message}
/>
<FormInput
id="last_name"
label="Last Name"
inputType="text"
value={watch('last_name')}
onChange={(value) => setValue('last_name', value)}
placeholder="Doe"
required
error={errors.last_name?.message}
/>
<FormInput
id="email"
label="Email"
inputType="email"
value={watch('email')}
onChange={(value) => setValue('email', value)}
placeholder="john.doe@example.com"
/>Key Points:
- Pass
error={errors.field_name?.message}to show validation errors - FormInput automatically adds red border and displays error message
- Use
watch()to get current field values - Use
setValue()to update field values - Mark required fields with
requiredprop
Replace manual form submission with handleSubmit:
<form onSubmit={handleSubmit(onSubmitNewPerson)} className="space-y-4">
{/* FormInput components */}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleCancelAddForm}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save
</>
)}
</Button>
</DialogFooter>
</form>When an error prop is provided:
- Input shows red border (
border-red-500) - Error message displays below input in red text
- Accessible via
aria-describedbyfor screen readers
// Required text field
field_name: z.string().min(1, 'Field name is required')
// Optional text field
field_name: z.string().optional()
// Email (optional)
email: z.string().email('Invalid email address').optional()
// Email (required)
email: z.string().min(1, 'Email is required').email('Invalid email address')
// URL (optional)
url: z.string().url('Invalid URL').optional()
// Phone (optional, no format validation)
phone: z.string().optional()
// Enum (optional)
status: z.enum(['ACTIVE', 'INACTIVE']).optional()
// Number (required)
capacity: z.number().min(1, 'Capacity must be at least 1')
// UUID (optional)
event_id: z.string().uuid('Invalid ID').optional().nullable()- Status: Completed
- Required fields: first_name, last_name
- File:
src/components/people-picker.tsx
- Status: Pending
- Required fields: name, start_date, event_type
- File:
src/components/event-picker.tsx
- Status: Pending
- Required fields: name
- File:
src/components/location-picker.tsx
- Status: Pending
- Required fields: pericope, text
- File:
src/components/reading-picker-modal.tsx
Define the Zod schema at the top of the component file (not in a separate schema file) since picker forms are component-specific:
// ✅ CORRECT - Define at component level
const newPersonSchema = z.object({
first_name: z.string().min(1, 'First name is required'),
// ...
})Use the standardized FormInput component instead of raw Input components:
// ❌ WRONG - Manual input with inline error handling
<Input
{...register('first_name')}
className={cn(errors.first_name && "border-red-500")}
/>
{errors.first_name && <p className="text-sm text-red-500">{errors.first_name.message}</p>}
// ✅ CORRECT - Use FormInput
<FormInput
id="first_name"
label="First Name"
value={watch('first_name')}
onChange={(value) => setValue('first_name', value)}
error={errors.first_name?.message}
required
/>Always reset the form when canceling:
const handleCancelAddForm = () => {
setShowAddForm(false)
reset() // Clear form state and errors
}After creating a new entity, automatically select it:
const onSubmitNew = async (data: FormData) => {
const newEntity = await createEntity(data)
toast.success('Created successfully')
reset()
setShowAddForm(false)
// Auto-select the new entity (closes picker)
handleEntitySelect(newEntity)
}Use React Hook Form's isSubmitting instead of manual loading state:
// ❌ WRONG - Manual loading state
const [isLoading, setIsLoading] = useState(false)
// ✅ CORRECT - Use isSubmitting from formState
const { formState: { isSubmitting } } = useForm(...)
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>When updating a picker component to use validation:
- Add React Hook Form and Zod imports
- Define Zod schema for the inline form
- Replace
useStateform state withuseForm - Update submit handler to use
handleSubmit(onSubmit) - Replace manual Input components with FormInput
- Add
errorprop to FormInput for validation errors - Use
isSubmittingfor loading state - Test: Submit empty form → Should show red borders and error messages
- Test: Submit valid form → Should create entity and auto-select it
- Test: Cancel → Should reset form and close dialog
See src/components/people-picker.tsx for the complete reference implementation.
Key features:
- ✅ Zod schema with required first_name and last_name
- ✅ React Hook Form integration
- ✅ FormInput components with error display
- ✅ Red borders on validation errors
- ✅ Auto-select newly created person
- ✅ Form reset on cancel
- Zod Documentation
- Zod GitHub
- React Hook Form Documentation
- React Hook Form + Zod Integration
- @hookform/resolvers Documentation
- Next.js Server Actions
-
FormInput Component (CRITICAL)
- ✅ ALWAYS use FormInput for all inputs, selects, and textareas
- ❌ NEVER use bare Input, Select, or Textarea components
⚠️ Ask user first if FormInput cannot be used
-
Module Form Validation
- ✅ Always validate on server with
.parse()(security boundary) - ✅ Use React Hook Form + zodResolver on client (automatic validation + better UX)
- ✅ Define schemas in shared files (
lib/schemas/[entity].ts, NOT in 'use server' files) - ✅ Export types with
z.infer<typeof schema> - ✅ Use
.partial()for update schemas
- ✅ Always validate on server with
-
Picker Component Validation
- ✅ Define schemas at component level (not in separate files)
- ✅ Use FormInput with error prop for consistent error display
- ✅ Auto-select newly created entities
- ✅ Reset form on cancel
- ✅ Use isSubmitting for loading state
- Consistency - FormInput ensures uniform styling and behavior
- Type Safety - Zod schemas provide runtime validation and TypeScript types
- Automatic Validation - No manual
.safeParse()calls needed - Better UX - Instant feedback with clear error messages
- Cleaner Code - Less boilerplate, no manual useState for form fields
- Security - Server-side validation always enforced