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 example

A 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
}

Create schemas in src/schemas/ using Zod:

src/schemas/login.schema.ts
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:

src/schemas/client.schema.ts
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')}
/>

To use forms in Astro pages, add client:load for hydration:

src/pages/dashboard/clients/create.astro
---
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>
  1. Always infer types from schemas

    // Good - type derived from schema
    type FormData = z.infer<typeof schema>
    // Bad - manual type definition
    interface FormData {
    email: string
    }
  2. Write clear, user-facing error messages inline

    z.string().min(1, 'We need your email to get back to you')
  3. Provide default values

    useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: { name: '', email: '' },
    })
  4. Use path aliases for imports

    import { FormField } from '#components/react/form/FormField'
    import { clientSchema } from '#schemas/client.schema'

See the full implementation in:

  • Schema: src/schemas/client.schema.ts
  • FormField: src/components/react/form/FormField.tsx
  • ClientFormRHF: src/components/react/ClientFormRHF.tsx

For detailed documentation including advanced patterns, multi-step forms, and troubleshooting, see:

Full Guide: project-docs/02-guides/react-hook-form-zod-guide.md