| name | astro-forms |
| description | Form infrastructure for Astro projects. Contact forms, booking forms, quote requests. Zod validation, email delivery (Resend/Brevo), rate limiting, Google Sheets, postcode lookup, spam protection. |
Astro Forms Skill
Server-side form handling for lead generation sites on Cloudflare Workers.
Scope
This skill covers the backend pipeline: validation → spam check → storage → email → redirect → analytics. For form UI components, field layout, and styling see the project's frontend patterns. The one exception is modifiers.md which documents common form layout variants for reference.
Implementation Sequence
When building a form, follow this order:
- Define the schema —
references/schemas.md → copy + adapt Zod schema
- Copy boilerplate —
assets/boilerplate/lib/ → into src/lib/forms/
- Configure Worker —
wrangler.toml with KV binding, wrangler secret put for all secrets
- Set up Google Sheet — create service account, share sheet, add column headers (row 1)
- Wire frontend — form posts to
/api/submit, includes Turnstile + honeypot + hidden fields
- Test locally —
wrangler dev, submit form, check email + Sheets
- Deploy —
wrangler deploy
- Audit — run through Conversion Verdict below
Required Policy (non-negotiable)
These apply regardless of tech stack choice:
| Policy | Rule |
|---|
| Server-side validation | All input validated on server. Client validation is UX only. |
| Cookie consent | Handled by CookieYes banner (not per-form). |
| Spam protection | Minimum 2 layers required (e.g. honeypot + Turnstile). |
| Storage | Every valid submission persisted (Sheets, DB, or equivalent). |
| Confirmation email | Sent to customer. Failure = log + continue (don't lose the lead). |
| Business notification | Sent to business owner. Failure = alert. |
| Thank-you state | Redirect to /thank-you or show confirmation UI. |
| Analytics event | GTM form_submit event fired. |
| Input sanitisation | User input HTML-escaped before use in email templates. |
| Duplicate protection | Dedupe within 60s window on same email + formId. |
Recommended Stack
| Concern | Tool | Notes |
|---|
| Validation | Zod | Server-side, type-safe |
| CAPTCHA | Cloudflare Turnstile | Invisible mode |
| Email (primary) | Resend | 3,000/month free tier |
| Email (fallback) | Brevo | Auto-switch on Resend failure |
| Rate limiting | Cloudflare KV | Eventually consistent — fine for form spam control |
| Storage | Google Sheets API | Service account auth, direct API. No webhook. |
| Runtime | Cloudflare Workers | export default { fetch } entry point |
Canonical Submission Schema
One naming convention everywhere: camelCase in TypeScript, maps to storage columns.
type FormSubmission = {
leadId: string;
formId: string;
sourcePage: string;
submittedAt: string;
name: string;
email: string;
phone?: string;
message?: string;
ipHash: string;
userAgent?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
referrer?: string;
};
All boilerplate, schemas, and references use these field names.
Primary Conversion Declaration
One form per page is THE conversion.
primary_conversion:
type: form
id: "contact-form"
page: "/contact"
All other forms on the page are secondary (newsletter, etc.).
| Page Type | Forms Allowed |
|---|
| landing | Primary only |
| service | Primary only |
| contact | Primary only |
| calculator | Contact capture as final step only (calculator logic lives in lead-gen-calculator skill) |
| thank-you | Forbidden |
| 404 | Forbidden |
Progressive Disclosure
Personal data only AFTER value is established.
| Step | Can Ask |
|---|
| 1 | Service type, location, non-personal choices |
| 2 | Details, preferences |
| 3+ | Name, email, phone |
Asking email on step 1 is acceptable on high-intent pages (e.g. /contact) but should be avoided on exploratory pages. Use judgement, not a blanket rule.
Spam Protection
All four layers recommended. Minimum two required.
| Layer | Implementation | Required |
|---|
| Honeypot | Hidden empty field, reject if filled | Yes |
| Time check | Reject if submitted < 3s after page load. Checked in handler, NOT in Zod schema. | Yes |
| Turnstile | Cloudflare invisible CAPTCHA | Recommended |
| Rate limit | 5 submissions per hour per IP via KV | Recommended |
Failure Policy
What happens when part of the pipeline fails:
| Component | On Failure | Rationale |
|---|
| Zod validation | Return 400 + field errors | Normal flow |
| Turnstile | Return 400 | Likely bot |
| Rate limit | Return 429 | Protect system |
| Duplicate detected | Return 200 (silent OK) | Don't confuse user |
| Google Sheets API save | Log error, continue | Don't lose the lead over storage |
| Customer confirmation email | Log warning, continue | Lead is already captured |
| Business notification email | Log error + alert | Business must know about leads |
| Analytics/GTM | Log warning, continue | Client-side, can't guarantee |
Core principle: never lose a valid lead because a secondary system failed. Email and Sheets failures are logged and alerted, but the user sees success and the lead data is preserved in logs at minimum.
Conversion Verdict
| Condition | Verdict |
|---|
| Form on forbidden page (thank-you, 404) | FAIL |
| CookieYes not configured | FAIL |
| No server-side validation | FAIL |
| No confirmation email configured | FAIL |
| No business notification configured | FAIL |
| No thank-you redirect/state | FAIL |
| No GTM event | FAIL |
| No input sanitisation in email templates | FAIL |
Astro SSR API handler reads Astro.locals.runtime.env (removed in v6) | FAIL |
| Astro SSR API handler without top-level try/catch (silent 500, no logs) | FAIL |
Frontend fetch('/api/name') missing trailing slash when trailingSlash: 'always' | FAIL |
Deploy command missing --keep-vars (dashboard plaintext vars get wiped) | FAIL |
| Fewer than 2 spam protection layers | WARN |
| No duplicate protection | WARN |
| Personal data on step 1 (non-contact page) | WARN |
| Rate limiting not configured | WARN |
| All checks pass | PASS |
Locale Support
The skill supports both en-GB and hu-HU locales. Locale affects:
- Zod validation error messages
- Phone number regex pattern
- Email template language
- Postcode format and lookup endpoint
Default locale is en-GB (UK service businesses). See references/schemas.md for locale-specific validation.
Environment Variables
RESEND_API_KEY=re_xxxxx
BREVO_API_KEY=xkeysib-xxxxx
GOOGLE_SERVICE_ACCOUNT_EMAIL=forms@project.iam.gserviceaccount.com
GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
GOOGLE_SHEET_ID=1aBcDeFgHiJkLmNoPqRsTuVwXyZ
TURNSTILE_SITE_KEY=0x... # Frontend only
TURNSTILE_SECRET_KEY=0x... # Backend only
IP_HASH_SALT=random-daily-salt
SITE_URL=https://example.com
Critical: all backend env vars are accessed via the env parameter (Workers binding), NOT import.meta.env. The submit.ts handler receives env from the Workers fetch(request, env, ctx) signature. Every boilerplate helper accepts env as a parameter.
If the form runs inside an Astro SSR API route (src/pages/api/*.ts): use import { env } from 'cloudflare:workers' at the top of each handler file. Astro.locals.runtime.env was removed in Astro v6 and throws Worker unhandled exception at runtime. See astro-v6-runtime.md for the full writeup (covers try/catch, --keep-vars, trailing slash, Turnstile hostname allowlist).
For Google Sheets: create a service account in Google Cloud Console, download the JSON key, share the target spreadsheet with the service account email address, then set GOOGLE_SERVICE_ACCOUNT_EMAIL, GOOGLE_PRIVATE_KEY, and GOOGLE_SHEET_ID as secrets.
References
Integration with Tracking Skill
This skill handles the server-side pipeline (validation, storage, email). The soborbo-tracking skill handles client-side conversion tracking (dataLayer, Meta CAPI beacon). They work together:
User clicks Submit
→ <TrackedForm> intercepts (tracking skill)
→ trackLeadSubmit() fires:
• pushes lead_submit to dataLayer (GTM → GA4, Google Ads)
• sends beacon to /api/track (Meta CAPI)
• populates hidden fields (event_id, gclid, UTM)
→ form.submit() fires (native POST)
→ /api/submit receives the POST (this skill)
• validates, dedup, Sheets, email
• event_id links the two systems
Key integration point: The event_id hidden field connects the client-side conversion event with the server-side lead record. Both the Meta CAPI event and the Google Sheets row contain the same event_id, enabling deduplication and attribution reconciliation.
What goes where:
<TrackedForm> wraps the HTML form (tracking skill component)
/api/track receives the Meta CAPI beacon (tracking skill endpoint)
/api/submit receives the form POST (this skill's endpoint)
Boilerplate
Copy assets/boilerplate/lib/ → src/lib/forms/. The key file is submit.ts which exports a Workers-compatible fetch handler and wires together all other modules. Use ctx.waitUntil() for non-blocking Sheets saves.
Definition of Done