Back to .md Directory

Forms & React Hook Form Rules

- All forms **MUST** have a shared Zod schema for client and server validation.

May 2, 2026
0 downloads
0 views
ai
View source

trigger: always_on

Forms & React Hook Form Rules

1. Shared Zod Schema (Single Source of Truth)

Schema Location

  • All forms MUST have a shared Zod schema for client and server validation.
  • Schema should be placed in domain/<feature>/schemas/<form-name>.schema.ts.
  • Schema defines only data structure: types, required, min/max length, pattern, format.
  • DO NOT place in schema:
    • Business logic
    • Authorization checks
    • Database queries
    • Uniqueness validation
    • Validation of dependencies between entities

Schema Structure

// domain/happiness/schemas/createCampaign.schema.ts
import { z } from 'zod'

export const createCampaignSchema = z.object({
  projectId: z.string().uuid('Invalid project ID'),
  title: z.string()
    .min(3, 'Title must be at least 3 characters')
    .max(200, 'Title must not exceed 200 characters'),
  description: z.string()
    .max(2000, 'Description too long')
    .optional(),
  endsAt: z.date()
    .min(new Date(), 'End date must be in the future')
    .optional(),
  questions: z.array(z.object({
    type: z.enum(['SCALE', 'OPEN_TEXT']),
    label: z.string().min(3).max(500),
    order: z.number().int().positive()
  })).min(1, 'At least one question is required')
})

// Export type for TypeScript
export type CreateCampaignInput = z.infer<typeof createCampaignSchema>

Schema Best Practices

// ✅ CORRECT - Structural validation only
const schema = z.object({
  email: z.string().email(),
  age: z.number().min(0).max(120)
})

// ❌ WRONG - Business logic in schema
const schema = z.object({
  email: z.string().email().refine(async (email) => {
    const exists = await db.user.findUnique({ where: { email } })
    return !exists
  }, 'Email already exists')  // This is business validation!
})

// ❌ WRONG - Authorization in schema
const schema = z.object({
  projectId: z.string().refine(async (id) => {
    const member = await checkMembership(id)
    return member !== null
  }, 'Not authorized')  // This is authorization!
})

2. React Hook Form Integration (Client-Side)

Form Component Structure

'use client'

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createCampaignSchema, type CreateCampaignInput } from '@/domain/happiness/schemas/createCampaign.schema'
import { createCampaign } from '@/app/actions/happiness/createCampaign'
import { useTransition } from 'react'
import { toast } from 'sonner'

interface CreateCampaignFormProps {
  projectId: string
  onSuccess?: () => void
}

export function CreateCampaignForm({ projectId, onSuccess }: CreateCampaignFormProps) {
  const [isPending, startTransition] = useTransition()
  
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
    reset
  } = useForm<CreateCampaignInput>({
    resolver: zodResolver(createCampaignSchema),
    defaultValues: {
      projectId,
      questions: []
    }
  })

  async function onSubmit(data: CreateCampaignInput) {
    startTransition(async () => {
      const result = await createCampaign(data)

      if (!result.success) {
        // Handle different error types
        if (result.type === 'validation') {
          // Structural validation errors (should rarely happen with zodResolver)
          Object.entries(result.errors).forEach(([field, messages]) => {
            setError(field as any, {
              type: 'server',
              message: messages.join(', ')
            })
          })
        } else if (result.type === 'business') {
          // Business logic errors - show under specific field or as toast
          if (result.field) {
            setError(result.field as any, {
              type: 'server',
              message: result.message
            })
          } else {
            toast.error(result.message)
          }
        } else {
          // Unknown errors
          toast.error(result.message || 'An error occurred')
        }
        return
      }

      // Success
      toast.success('Campaign created successfully')
      reset()
      onSuccess?.()
    })
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
      <div>
        <label htmlFor="title" className="block text-sm font-medium">
          Campaign Title
        </label>
        <input
          id="title"
          type="text"
          {...register('title')}
          className="mt-1 block w-full rounded border"
          disabled={isPending}
        />
        {errors.title && (
          <p className="mt-1 text-sm text-red-600">{errors.title.message}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isPending || isSubmitting}
        className="rounded bg-primary px-4 py-2 text-white"
      >
        {isPending ? 'Creating...' : 'Create Campaign'}
      </button>
    </form>
  )
}

Key RHF Patterns

Use zodResolver for Validation

// ✅ CORRECT
const form = useForm<InputType>({
  resolver: zodResolver(sharedSchema)
})

// ❌ WRONG - Manual validation
const form = useForm({
  validate: (data) => {
    // Don't do this - use Zod
  }
})

Use setError for Server-Side Errors

// ✅ CORRECT - Map server errors to form fields
if (result.type === 'business' && result.field) {
  setError(result.field as keyof FormData, {
    type: 'server',
    message: result.message
  })
}

// ❌ WRONG - Generic alert
if (!result.success) {
  alert(result.error)  // Poor UX
}

Use useTransition for Server Actions

// ✅ CORRECT - Non-blocking UI updates
const [isPending, startTransition] = useTransition()

async function onSubmit(data: FormData) {
  startTransition(async () => {
    const result = await serverAction(data)
    // Handle result
  })
}

// Disable form during submission
<button disabled={isPending || isSubmitting}>Submit</button>

3. Server Actions (Orchestration Layer)

Server Action Structure

'use server'

import { z } from 'zod'
import { getAuthSession } from '@/lib/auth'
import { createCampaignSchema, type CreateCampaignInput } from '@/domain/happiness/schemas/createCampaign.schema'
import { createCampaignService } from '@/domain/happiness/services/campaign.service'
import { campaignToDTO } from '@/domain/happiness/mappers/campaign.mapper'
import type { ActionResult } from '@/types/common'
import type { HappinessCampaignDTO } from '@/types/happiness'

export async function createCampaign(
  input: CreateCampaignInput
): Promise<ActionResult<HappinessCampaignDTO>> {
  try {
    // 1. Validate input (structural validation)
    const validated = createCampaignSchema.safeParse(input)
    if (!validated.success) {
      return {
        success: false,
        type: 'validation',
        errors: validated.error.flatten().fieldErrors
      }
    }

    // 2. Authentication check
    const session = await getAuthSession()
    if (!session?.user?.id) {
      return {
        success: false,
        type: 'business',
        message: 'Unauthorized'
      }
    }

    // 3. Execute business logic via domain layer
    const campaign = await createCampaignService({
      ...validated.data,
      userId: session.user.id
    })

    // 4. Return DTO
    return {
      success: true,
      data: campaignToDTO(campaign)
    }

  } catch (error) {
    // Handle domain errors
    if (error instanceof ProjectNotFoundError) {
      return {
        success: false,
        type: 'business',
        field: 'projectId',
        message: 'Project not found'
      }
    }

    if (error instanceof ForbiddenError) {
      return {
        success: false,
        type: 'business',
        message: 'You do not have permission to create campaigns'
      }
    }

    if (error instanceof CampaignLimitExceededError) {
      return {
        success: false,
        type: 'business',
        message: 'Maximum number of active campaigns reached'
      }
    }

    // Unknown errors
    console.error('[createCampaign] Error:', error)
    return {
      success: false,
      type: 'unknown',
      message: 'Failed to create campaign'
    }
  }
}

Action Result Types

// types/common.ts
export type ActionResult<T = void> =
  | { success: true; data: T }
  | { success: false; type: 'validation'; errors: Record<string, string[]> }
  | { success: false; type: 'business'; field?: string; message: string }
  | { success: false; type: 'unknown'; message: string }

Server Action Best Practices

// ✅ CORRECT - Orchestrate only
export async function createRisk(input: CreateRiskInput) {
  const validated = schema.safeParse(input)
  if (!validated.success) {
    return { success: false, type: 'validation', errors: validated.error.flatten().fieldErrors }
  }

  const session = await getAuthSession()
  if (!session) {
    return { success: false, type: 'business', message: 'Unauthorized' }
  }

  // Domain layer handles business logic
  const risk = await createRiskService(validated.data, session.user.id)
  return { success: true, data: riskToDTO(risk) }
}

// ❌ WRONG - Business logic in Server Action
export async function createRisk(input: CreateRiskInput) {
  const validated = schema.safeParse(input)
  
  // Business logic should be in domain layer!
  const member = await db.projectMember.findUnique({
    where: { projectId_userId: { projectId: input.projectId, userId: session.user.id } }
  })
  
  if (!member || member.role !== 'PM') {
    return { success: false, type: 'business', message: 'Forbidden' }
  }

  const existingRisks = await db.risk.count({ where: { projectId: input.projectId, status: 'OPEN' } })
  if (existingRisks >= 50) {
    return { success: false, type: 'business', message: 'Too many open risks' }
  }

  // This is all business logic - belongs in domain layer!
  const risk = await db.risk.create({ data: input })
  return { success: true, data: riskToDTO(risk) }
}

4. Domain Layer (Business Logic)

Domain Service Structure

// domain/happiness/services/campaign.service.ts
import { db } from '@/lib/db'
import { canManageCampaigns } from '@/domain/auth/permissions'
import { ProjectNotFoundError, ForbiddenError, CampaignLimitExceededError } from '@/domain/happiness/errors'

interface CreateCampaignData {
  projectId: string
  title: string
  description?: string
  endsAt?: Date
  questions: Array<{
    type: 'SCALE' | 'OPEN_TEXT'
    label: string
    order: number
  }>
  userId: string
}

export async function createCampaignService(data: CreateCampaignData) {
  // 1. Verify project exists
  const project = await db.project.findUnique({
    where: { id: data.projectId }
  })
  
  if (!project) {
    throw new ProjectNotFoundError(data.projectId)
  }

  // 2. Check authorization
  const canManage = await canManageCampaigns(data.userId, data.projectId)
  if (!canManage) {
    throw new ForbiddenError('You must be a PM to create campaigns')
  }

  // 3. Business rule: Max 5 active campaigns per project
  const activeCampaignsCount = await db.happinessCampaign.count({
    where: {
      projectId: data.projectId,
      status: 'ACTIVE'
    }
  })

  if (activeCampaignsCount >= 5) {
    throw new CampaignLimitExceededError()
  }

  // 4. Business rule: Questions must have unique orders
  const orders = data.questions.map(q => q.order)
  const uniqueOrders = new Set(orders)
  if (orders.length !== uniqueOrders.size) {
    throw new Error('Question orders must be unique')
  }

  // 5. Create campaign with questions in transaction
  return await db.$transaction(async (tx) => {
    const campaign = await tx.happinessCampaign.create({
      data: {
        projectId: data.projectId,
        title: data.title,
        description: data.description,
        endsAt: data.endsAt,
        status: 'DRAFT'
      }
    })

    await tx.happinessQuestion.createMany({
      data: data.questions.map(q => ({
        campaignId: campaign.id,
        type: q.type,
        label: q.label,
        order: q.order
      }))
    })

    return campaign
  })
}

Domain Errors

// domain/happiness/errors.ts
export class ProjectNotFoundError extends Error {
  constructor(projectId: string) {
    super(`Project ${projectId} not found`)
    this.name = 'ProjectNotFoundError'
  }
}

export class ForbiddenError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'ForbiddenError'
  }
}

export class CampaignLimitExceededError extends Error {
  constructor() {
    super('Maximum number of active campaigns reached')
    this.name = 'CampaignLimitExceededError'
  }
}

Domain Layer Rules

  • Pure business logic - No framework dependencies (Next.js, React, RHF)
  • Authorization checks - Verify user permissions
  • Business rules - Enforce project-specific constraints
  • Data consistency - Use transactions for multi-step operations
  • Throw typed errors - Use custom error classes
  • No DB types exposure - Work with domain types, not Prisma types directly

5. Dynamic & Complex Forms

Using useFieldArray for Dynamic Fields

'use client'

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

export function CampaignQuestionsForm() {
  const { control, register, handleSubmit, formState: { errors } } = useForm<CreateCampaignInput>({
    resolver: zodResolver(createCampaignSchema),
    defaultValues: {
      questions: [{ type: 'SCALE', label: '', order: 1 }]
    }
  })

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'questions'
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      {fields.map((field, index) => (
        <div key={field.id} className="space-y-2">
          <select {...register(`questions.${index}.type`)}>
            <option value="SCALE">Scale (1-5)</option>
            <option value="OPEN_TEXT">Open Text</option>
          </select>

          <input
            {...register(`questions.${index}.label`)}
            placeholder="Question text"
          />
          
          {errors.questions?.[index]?.label && (
            <p className="text-sm text-red-600">
              {errors.questions[index].label?.message}
            </p>
          )}

          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}

      <button
        type="button"
        onClick={() => append({ type: 'SCALE', label: '', order: fields.length + 1 })}
      >
        Add Question
      </button>

      <button type="submit">Create Campaign</button>
    </form>
  )
}

Conditional Fields

const watchCampaignType = watch('type')

return (
  <form noValidate>
    <select {...register('type')}>
      <option value="QUICK">Quick Survey</option>
      <option value="DETAILED">Detailed Survey</option>
    </select>

    {watchCampaignType === 'DETAILED' && (
      <div>
        <label>Detailed Description</label>
        <textarea {...register('detailedDescription')} />
      </div>
    )}
  </form>
)

6. Form Validation Strategy

CRITICAL: Disable HTML5 Validation

ALL forms MUST use the noValidate attribute to disable browser's default HTML5 validation. This ensures consistent validation behavior across all browsers and allows Zod to be the single source of validation truth.

// ✅ CORRECT - noValidate attribute disables HTML5 validation
<form onSubmit={handleSubmit(onSubmit)} noValidate>
  <input
    type="email"
    {...register('email')}
  />
  <button type="submit">Submit</button>
</form>

// ❌ WRONG - Missing noValidate allows browser validation
<form onSubmit={handleSubmit(onSubmit)}>
  <input
    type="email"
    required  // Browser will validate this
    {...register('email')}
  />
</form>

Why Disable HTML5 Validation?

  1. Consistency - Browser validation messages vary across browsers and languages
  2. Control - Zod provides consistent, customizable error messages
  3. UX - React Hook Form handles validation timing (onBlur, onChange, onSubmit)
  4. Accessibility - Better control over error message presentation
  5. Testing - Easier to test when validation is handled by one system

Validation Layers

With noValidate, validation happens in this order:

  1. RHF + Zod (Client) - Immediate feedback with custom messages
  2. Server Action + Zod - Security validation (never trust client)
  3. Domain Layer - Business rules and authorization
// Complete validation flow
export function CreateCampaignForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(createCampaignSchema)  // Zod validates
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>  {/* Disable HTML5 */}
      <input
        type="text"
        {...register('title')}
        aria-invalid={!!errors.title}
      />
      {errors.title && (
        <p className="text-red-600">{errors.title.message}</p>
      )}
      <button type="submit">Submit</button>
    </form>
  )
}

Input Types for UX (Not Validation)

You can still use HTML5 input types for better UX (mobile keyboards, date pickers), but they won't validate:

// ✅ CORRECT - Use input types for UX, Zod for validation
<form noValidate>
  <input
    type="email"        // Shows email keyboard on mobile
    {...register('email')}
  />
  <input
    type="url"          // Shows URL keyboard on mobile
    {...register('website')}
  />
  <input
    type="date"         // Shows date picker
    {...register('startDate')}
  />
</form>

// Zod schema handles all validation
const schema = z.object({
  email: z.string().email('Invalid email address'),
  website: z.string().url('Invalid URL'),
  startDate: z.date()
})

7. Naming Conventions

File Structure

app/
├─ actions/
│  └─ happiness/
│     ├─ createCampaign.ts        # Server Action
│     ├─ updateCampaign.ts
│     └─ submitAnswer.ts

components/
├─ happiness/
│  ├─ CreateCampaignForm.tsx      # Form component
│  ├─ UpdateCampaignForm.tsx
│  └─ AnswerForm.tsx

domain/
├─ happiness/
│  ├─ schemas/
│  │  ├─ createCampaign.schema.ts # Zod schema
│  │  ├─ updateCampaign.schema.ts
│  │  └─ submitAnswer.schema.ts
│  ├─ services/
│  │  ├─ campaign.service.ts      # Business logic
│  │  └─ answer.service.ts
│  ├─ errors.ts                   # Domain errors
│  └─ mappers/
│     └─ campaign.mapper.ts       # DB → DTO mapping

Naming Pattern

  • Form Components: <Action><Feature>Form.tsx (e.g., CreateCampaignForm.tsx)
  • Server Actions: <action><Feature>.ts (e.g., createCampaign.ts)
  • Zod Schemas: <action><Feature>.schema.ts (e.g., createCampaign.schema.ts)
  • Domain Services: <feature>.service.ts (e.g., campaign.service.ts)
  • Domain Errors: <Feature><ErrorType>Error (e.g., CampaignLimitExceededError)

8. Testing Strategy

Test Schema Validation

// tests/domain/happiness/schemas/createCampaign.test.ts
import { createCampaignSchema } from '@/domain/happiness/schemas/createCampaign.schema'

describe('createCampaignSchema', () => {
  it('validates correct data', () => {
    const result = createCampaignSchema.safeParse({
      projectId: '123e4567-e89b-12d3-a456-426614174000',
      title: 'Q1 Survey',
      questions: [
        { type: 'SCALE', label: 'How happy are you?', order: 1 }
      ]
    })

    expect(result.success).toBe(true)
  })

  it('rejects title too short', () => {
    const result = createCampaignSchema.safeParse({
      projectId: '123e4567-e89b-12d3-a456-426614174000',
      title: 'Q1',
      questions: []
    })

    expect(result.success).toBe(false)
    expect(result.error?.issues[0].message).toContain('at least 3 characters')
  })

  it('requires at least one question', () => {
    const result = createCampaignSchema.safeParse({
      projectId: '123e4567-e89b-12d3-a456-426614174000',
      title: 'Q1 Survey',
      questions: []
    })

    expect(result.success).toBe(false)
  })
})

9. Security Considerations

Never Trust Client Validation

// ❌ WRONG - Only client validation
export function CreateRiskForm() {
  const form = useForm({ resolver: zodResolver(schema) })
  // If user bypasses client, no validation!
}

// ✅ CORRECT - Always validate on server
export async function createRisk(input: unknown) {
  const validated = schema.safeParse(input)
  if (!validated.success) {
    return { success: false, type: 'validation', errors: validated.error.flatten().fieldErrors }
  }
  // Proceed with validated data
}

Validate Authorization in Domain Layer

// ✅ CORRECT - Check permissions in domain service
export async function createCampaignService(data: CreateCampaignData) {
  const canManage = await canManageCampaigns(data.userId, data.projectId)
  if (!canManage) {
    throw new ForbiddenError()
  }
  // Proceed
}

// ❌ WRONG - Only check in form
export function CreateCampaignForm() {
  if (!isPM) {
    return <p>You must be a PM</p>
  }
  // User can bypass this!
}

Sanitize User Input

// Use Zod to restrict input format
const schema = z.object({
  title: z.string()
    .max(200)
    .regex(/^[a-zA-Z0-9\s\-_]+$/, 'Invalid characters'),
  url: z.string().url(),
  email: z.string().email()
})

// React automatically escapes JSX
<p>{user.input}</p>  // Safe from XSS

10. Accessibility Best Practices

Form Accessibility

export function AccessibleForm() {
  const { register, formState: { errors } } = useForm()

  return (
    <form noValidate>
      {/* Always use labels */}
      <label htmlFor="campaign-title">
        Campaign Title
        <span aria-label="required">*</span>
      </label>
      
      <input
        id="campaign-title"
        {...register('title')}
        aria-invalid={errors.title ? 'true' : 'false'}
        aria-describedby={errors.title ? 'title-error' : undefined}
      />

      {/* Associate errors with inputs */}
      {errors.title && (
        <p id="title-error" role="alert" className="text-red-600">
          {errors.title.message}
        </p>
      )}

      {/* Disabled state */}
      <button
        type="submit"
        disabled={isSubmitting}
        aria-disabled={isSubmitting}
      >
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  )
}

11. Performance Optimization

Avoid Unnecessary Re-renders

// ✅ CORRECT - Watch specific fields
const watchedFields = watch(['title', 'status'])

// ❌ WRONG - Watch everything
const allValues = watch()  // Re-renders on any field change

Debounce Expensive Operations

import { useDebouncedCallback } from 'use-debounce'

export function FormWithPreview() {
  const { watch } = useForm()
  const [preview, setPreview] = useState('')

  const updatePreview = useDebouncedCallback((value: string) => {
    // Expensive operation
    setPreview(generatePreview(value))
  }, 500)

  useEffect(() => {
    const subscription = watch((value) => {
      updatePreview(value.description)
    })
    return () => subscription.unsubscribe()
  }, [watch, updatePreview])
}

12. Common Patterns

Multi-Step Forms

export function MultiStepCampaignForm() {
  const [step, setStep] = useState(1)
  const form = useForm<CreateCampaignInput>({
    resolver: zodResolver(createCampaignSchema),
    mode: 'onBlur'
  })

  async function onSubmit(data: CreateCampaignInput) {
    if (step < 3) {
      // Validate current step
      const isValid = await form.trigger(getFieldsForStep(step))
      if (isValid) {
        setStep(step + 1)
      }
      return
    }

    // Final submit
    startTransition(async () => {
      const result = await createCampaign(data)
      // Handle result
    })
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} noValidate>
      {step === 1 && <BasicInfoStep form={form} />}
      {step === 2 && <QuestionsStep form={form} />}
      {step === 3 && <ReviewStep form={form} />}

      <button type="submit">
        {step < 3 ? 'Next' : 'Create Campaign'}
      </button>
    </form>
  )
}

Form with File Upload

export function FormWithFileUpload() {
  const { register, handleSubmit } = useForm()

  async function onSubmit(data: FormData) {
    const formData = new FormData()
    formData.append('title', data.title)
    
    if (data.file[0]) {
      formData.append('file', data.file[0])
    }

    const result = await uploadAction(formData)
    // Handle result
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input {...register('title')} />
      <input type="file" {...register('file')} accept=".pdf,.doc" />
      <button type="submit">Upload</button>
    </form>
  )
}

Optimistic Updates

export function UpdateRiskForm({ risk }: { risk: RiskDTO }) {
  const router = useRouter()
  const [optimisticRisk, setOptimisticRisk] = useOptimistic(risk)

  async function onSubmit(data: UpdateRiskInput) {
    // Optimistically update UI
    setOptimisticRisk({ ...risk, ...data })

    const result = await updateRisk(risk.id, data)

    if (!result.success) {
      // Revert on error
      setOptimisticRisk(risk)
      toast.error(result.message)
      return
    }

    toast.success('Risk updated')
    router.refresh()
  }

  return (
    <div>
      <h2>{optimisticRisk.title}</h2>
      <form onSubmit={handleSubmit(onSubmit)} noValidate>
        {/* Form fields */}
      </form>
    </div>
  )
}

Summary

Key Principles

  1. Shared Zod Schema - Single source of truth for client and server
  2. RHF + zodResolver - Type-safe forms with excellent UX
  3. Server Actions - Orchestration layer only
  4. Domain Layer - All business logic and authorization
  5. Structured Results - Consistent error handling pattern
  6. setError - Map server errors to form fields
  7. Progressive Enhancement - HTML5 + Zod + Server validation
  8. Security First - Never trust client-side validation
  9. Testing - Schema, domain, integration, E2E
  10. Accessibility - Labels, ARIA, keyboard navigation

Data Flow

User Input
    ↓
HTML5 Validation (instant)
    ↓
RHF + Zod Validation (rich UX)
    ↓
Server Action (orchestration)
    ↓
Zod Schema Parse (security)
    ↓
Domain Service (business logic + authorization)
    ↓
Database
    ↓
DTO Mapping
    ↓
Response to Client
    ↓
setError() or Success Toast

Quick Checklist

  • Zod schema in domain/<feature>/schemas/
  • Server Action validates with same schema
  • Domain service contains business logic
  • Form uses zodResolver(schema)
  • Form has noValidate attribute (disable HTML5 validation)
  • setError() used for server-side errors
  • useTransition for non-blocking submits
  • Structured ActionResult type
  • No business logic in Server Action
  • Authorization checked in domain layer
  • Tests for schema, domain, and integration
  • Accessible labels and error messages
  • No Prisma types exposed to UI

Related Documents