Schema-Driven
Define validation once in Zod, automatically infer TypeScript types.
The astro-basics project includes a comprehensive form validation system that combines React Hook Form for state management, Zod for schema validation, and @fpkit/acss for accessible form components.
This integration provides type-safe, accessible forms with minimal boilerplate and optimized performance.
Schema-Driven
Define validation once in Zod, automatically infer TypeScript types.
Accessible
@fpkit/acss components provide ARIA attributes and keyboard navigation.
Performant
React Hook Form minimizes re-renders - only validates on submit/blur.
Type-Safe
Full TypeScript support with inferred types from schemas.
import { useForm } from 'react-hook-form'import { zodResolver } from '@hookform/resolvers/zod'import { z } from 'zod'import { FormField } from '#components/react/form/FormField'import { Button } from '@fpkit/acss'
const schema = z.object({ email: z.string().email('Invalid email'),})
type FormData = z.infer<typeof schema>
function MyForm() { const { register, handleSubmit, formState: { errors }, } = useForm<FormData>({ resolver: zodResolver(schema), })
return ( <form onSubmit={handleSubmit(data => console.log(data))}> <FormField id="email" label="Email" type="email" error={errors.email?.message} registration={register('email')} required /> <Button type="submit">Submit</Button> </form> )}src/├── schemas/│ └── client.schema.ts # Zod validation schemas└── components/react/ ├── form/ │ ├── FormField.tsx # Reusable field component │ └── index.ts # Barrel export └── ClientFormRHF.tsx # Complete form exampleA reusable form field that wraps @fpkit/acss components with React Hook Form integration.
export type Props = { id: string // Unique field identifier label: string // Label text type?: 'text' | 'email' | 'tel' | 'password' | 'url' | 'number' | 'textarea' placeholder?: string // Placeholder text error?: string // Error message from validation registration: UseFormRegisterReturn // React Hook Form register() return required?: boolean // Required field indicator disabled?: boolean // Disable the field rows?: number // Rows for textarea (default: 5) hintText?: string // Additional hint text className?: string // Additional CSS classes}import { FormField } from '#components/react/form/FormField'
<FormField id="email" label="Email Address" type="email" placeholder="Enter your email" error={errors.email?.message} registration={register('email')} required/>Create schemas in src/schemas/ using Zod:
import { z } from 'zod'
export const loginSchema = z.object({ email: z.string().min(1, 'Email is required').email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'),})
export type LoginFormData = z.infer<typeof loginSchema>Pass clear, user-facing messages inline to each validator:
import { z } from 'astro/zod'
export const clientSchema = z.object({ nickname: z.string().min(1, 'Full name or preferred name is required'), email: z .string() .email({ error: 'Please enter a valid email address' }) .optional() .or(z.literal('')), phone: z .string() .optional() .refine(val => !val || /^[\d\s-()]{7,}$/.test(val), { message: 'Please enter a valid phone number', }), gender: z.enum(['male', 'female', 'other', 'prefer_not_to_say']),})
export type ClientFormData = z.infer<typeof clientSchema>The FormField component supports multiple input types:
<FormField id="name" label="Full Name" type="text" error={errors.name?.message} registration={register('name')}/><FormField id="email" label="Email Address" type="email" error={errors.email?.message} registration={register('email')}/><FormField id="password" label="Password" type="password" hintText="Must be at least 8 characters" error={errors.password?.message} registration={register('password')}/><FormField id="message" label="Your Message" type="textarea" rows={7} error={errors.message?.message} registration={register('message')}/>import { Controller } from 'react-hook-form'import { Checkbox } from '@fpkit/acss'
<Controller name="veteran_status" control={control} render={({ field }) => ( <Checkbox id="veteran_status" label="Veteran Status" checked={field.value ?? false} onChange={field.onChange} size="lg" /> )}/>{errors.veteran_status && ( <p className="form-error" role="alert"> {errors.veteran_status.message} </p>)}To use forms in Astro pages, add client:load for hydration:
---import Layout from '#layouts/Base.astro'import ClientFormRHF from '#components/react/ClientFormRHF'import { generateCSRFToken } from '#utils/csrf'
const csrfToken = generateCSRFToken()---
<Layout title="New Client"> <main> <h1>New Client</h1> <ClientFormRHF client:load csrfToken={csrfToken} /> </main></Layout>Always infer types from schemas
// Good - type derived from schematype FormData = z.infer<typeof schema>
// Bad - manual type definitioninterface FormData { email: string}Write clear, user-facing error messages inline
z.string().min(1, 'We need your email to get back to you')Provide default values
useForm<FormData>({ resolver: zodResolver(schema), defaultValues: { name: '', email: '' },})Use path aliases for imports
import { FormField } from '#components/react/form/FormField'import { clientSchema } from '#schemas/client.schema'See the full implementation in:
src/schemas/client.schema.tssrc/components/react/form/FormField.tsxsrc/components/react/ClientFormRHF.tsxFor detailed documentation including advanced patterns, multi-step forms, and troubleshooting, see:
Full Guide: project-docs/02-guides/react-hook-form-zod-guide.md