| name | section-form |
| description | Adds forms to Astro pages using React with Zod validation, submitting to a Cloudflare Workers handler. Use when user requests "add form", "contact form", "newsletter signup", "inquiry form", "booking form", "feedback form", "registration form", "waitlist", or any form functionality requiring client-side validation and server submission. Skip for pure mailto: links, static HTML forms posting to third-party services (Formspree, Netlify Forms), or display-only fake forms used as visual mockups. |
Form Section
Adds accessible, validated forms using React state + Zod. Forms work as React islands with client:load. Submits to /~/form-{name} worker endpoint.
Why this stack
- React island over native HTML form: Zod schemas + reactive state give per-field error rendering, conditional fields, and on-blur validation that vanilla forms can't express without manual DOM wiring. The island is small (~5KB gzipped), hydrates only when the form is in scope, and the rest of the page stays static.
- Zod over native browser validation: native
required/pattern/type=email checks scatter rules across HTML attributes, give browser-localized error strings, and don't share types with the worker. Zod is a single typed source of truth for both client and server, with custom messages and composable schemas.
- Cloudflare Workers handler over
mailto: or third-party services: a worker route validates submissions server-side (mailto skips validation entirely), enables anti-spam (rate-limiting, honeypots, Turnstile), structures payloads for downstream systems (CRM, email, DB), and keeps secrets out of the client. mailto also leaks the recipient's address and depends on the user having a mail client configured.
Workflow
Skip redundant installs. Steps 1–2 invoke bun add and bunx shadcn. Always check first: skip the install if the package is already in package.json (Step 1) or if the shadcn primitive already exists in src/components/ui/ (Step 2).
- Install deps:
bun add zod sonner — first read package.json; skip if both are already listed
- Install shadcn:
bunx --bun shadcn@latest add input textarea label button --overwrite — first check src/components/ui/; only add the primitives that are missing
- Create
src/components/ui/field.tsx (see assets/field.tsx - adapt styling)
- Create
worker/form.js handler (see assets/form.js - adapt logic)
- Register route in
worker/index.js
- Create form component (see
assets/ContactForm.tsx - adapt fields)
- Add to page with
client:load (above-the-fold) or client:visible (below-the-fold, e.g. footer newsletter — defers hydration until the form scrolls into view, saving initial JS work)
Worker Setup
Create worker/form.js based on assets/form.js, then register in worker/index.js:
import { handleForm } from './form.js';
const ROUTES = {
'/~/form-': { handler: handleForm, description: 'Form Handler' },
};
Usage in Astro
---
import { ContactForm } from "@/components/ContactForm"
---
<section class="py-16 px-4">
<div class="max-w-md mx-auto">
<h2 class="text-3xl font-bold text-center mb-8">Get in Touch</h2>
<ContactForm client:load />
</div>
</section>
For below-the-fold forms (newsletter signup in the footer, contact form near page bottom), prefer client:visible over client:load — hydration is deferred until the form scrolls into view, which trims initial JS execution without harming UX.
Field Pattern
For additional fields, use standard React controlled inputs with the Field UI components:
const [values, setValues] = useState({ fieldName: "" })
const [errors, setErrors] = useState<Record<string, string>>({})
<Field data-invalid={!!errors.fieldName}>
<FieldLabel htmlFor="fieldName">Label</FieldLabel>
<Input
id="fieldName"
value={values.fieldName}
onBlur={() => validateField("fieldName")}
onChange={(e) => setValues(v => ({ ...v, fieldName: e.target.value }))}
aria-invalid={!!errors.fieldName}
/>
{errors.fieldName && <FieldError message={errors.fieldName} />}
</Field>
Additional Field Types
Select: bunx --bun shadcn@latest add select --overwrite (skip if src/components/ui/select.tsx already exists)
<Select value={values.option} onValueChange={(v) => setValues(s => ({ ...s, option: v }))}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent><SelectItem value="opt1">Option</SelectItem></SelectContent>
</Select>
Checkbox: bunx --bun shadcn@latest add checkbox --overwrite (skip if src/components/ui/checkbox.tsx already exists)
<Field orientation="horizontal">
<Checkbox
checked={values.agree}
onCheckedChange={(v) => setValues(s => ({ ...s, agree: !!v }))}
/>
<FieldLabel>I agree to terms</FieldLabel>
</Field>
Validation
Zod schemas validate on submit and optionally on blur:
const schema = z.object({
name: z.string().min(2, "Name required"),
email: z.string().email("Invalid email"),
})
function validateField(name: string) {
const fieldSchema = schema.shape[name]
const result = fieldSchema.safeParse(values[name])
setErrors(e => ({
...e,
[name]: result.success ? "" : result.error.errors[0].message,
}))
}
function validateAll() {
const result = schema.safeParse(values)
if (!result.success) {
const fieldErrors: Record<string, string> = {}
result.error.errors.forEach(e => {
if (e.path[0]) fieldErrors[String(e.path[0])] = e.message
})
setErrors(fieldErrors)
return false
}
setErrors({})
return true
}
Accessibility
aria-invalid on inputs
data-invalid on Field wrapper
role="alert" on errors
htmlFor/id associations
autoComplete attributes
Assets
assets/field.tsx - Field components (Field, FieldLabel, FieldError, etc.)
assets/form.js - Worker handler example
assets/ContactForm.tsx - Complete contact form example