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=emailchecks 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 addandbunx shadcn. Always check first: skip the install if the package is already inpackage.json(Step 1) or if the shadcn primitive already exists insrc/components/ui/(Step 2).
- Install deps:
bun add zod sonner— first readpackage.json; skip if both are already listed - Install shadcn:
bunx --bun shadcn@latest add input textarea label button --overwrite— first checksrc/components/ui/; only add the primitives that are missing - Create
src/components/ui/field.tsx(seeassets/field.tsx- adapt styling) - Create
worker/form.jshandler (seeassets/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) orclient: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 = {
// ... existing 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"),
})
// Validate single field (for onBlur)
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,
}))
}
// Validate all fields (for onSubmit)
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-invalidon inputsdata-invalidon Field wrapperrole="alert"on errorshtmlFor/idassociationsautoCompleteattributes
Assets
assets/field.tsx- Field components (Field, FieldLabel, FieldError, etc.)assets/form.js- Worker handler exampleassets/ContactForm.tsx- Complete contact form example