The forms system delivers a contact form, newsletter signup, and the server-side submission pipeline that supports them. Forms use vanilla TypeScript for client-side validation and AJAX submission, with Cloudflare Turnstile CAPTCHA and honeypot spam protection. JavaScript is required for submission; without it, forms degrade to readable HTML with a <noscript> message (D-C8-16).
Who this is for
- Implementors configuring contact forms, newsletter signups, or CAPTCHA providers
- Developers extending the form submission pipeline, adding new form types, or modifying validation logic
Architecture
Two API routes handle submissions server-side. Both share a common pipeline of helpers in src/lib/forms/. Six form primitives compose the UI. Two section components and one embeddable fragment expose them to pages.
Key files
| File | Ownership | Purpose |
|---|---|---|
src/core/components/forms/FormField.astro | CORE | Universal field wrapper: label, input/textarea, help text, error display. |
src/core/components/forms/FormError.astro | CORE | Field-level (aria-live="polite") and form-level (role="alert") error display. |
src/core/components/forms/FormStatus.astro | CORE | Post-submission status: loading spinner, success replacement, inline error. |
src/core/components/forms/CaptchaField.astro | CORE | Cloudflare Turnstile widget; dev-mode placeholder with bypass token. |
src/core/components/forms/ListBox.astro | CORE | Vanilla JS custom select with <noscript> native fallback. |
src/core/components/forms/NewsletterForm.astro | CORE | Embeddable newsletter form fragment (no section wrapper). |
src/core/components/sections/ContactForm.astro | CORE | Two-column contact form section (info + form). |
src/core/components/sections/NewsletterSignup.astro | CORE | Standalone newsletter section wrapping NewsletterForm. |
src/pages/api/contact.ts | CORE | Contact form API route (server-side). |
src/pages/api/newsletter.ts | CORE | Newsletter signup API route (server-side). |
src/lib/forms/validation.ts | CORE | Shared server-side form validation. |
src/lib/forms/honeypot.ts | CORE | Honeypot field check. |
src/lib/forms/rate-limiter.ts | CORE | In-memory IP-based rate limiter. |
src/lib/forms/turnstile.ts | CORE | Server-side Turnstile token verification. |
src/lib/forms/email-provider.ts | CORE | Email provider interface + Postmark adapter. |
src/lib/forms/newsletter-provider.ts | CORE | Newsletter provider interface + Postmark notification adapter. |
Form Primitives
FormField
Universal form field wrapper composing a label, input or textarea, optional help text, and error display.
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | Field name (required). Used for id, aria-describedby, and error association. |
label | string | — | Visible label text (required). |
type | "text" | "email" | "tel" | "textarea" | "text" | Input type. "textarea" renders a <textarea>. |
required | boolean | false | Adds required and aria-required="true". |
helpText | string | — | Help text below the input, linked via aria-describedby. |
placeholder | string | — | Input placeholder text. |
autocomplete | string | — | HTML autocomplete hint. |
rows | number | 4 | Textarea rows (ignored for input types). |
class | string | — | Additional CSS classes. |
Rest props ([key: string]: unknown) are spread onto the outer wrapper for data attributes.
FormError
Error display for field-level and form-level errors. Hidden by default; vanilla JS populates and shows when validation fails.
| Prop | Type | Default | Description |
|---|---|---|---|
fieldName | string | — | When provided: renders field-level error with id="{fieldName}-error" and aria-live="polite". When omitted: renders form-level error with role="alert". |
class | string | — | Additional CSS classes. |
FormStatus
Post-submission status display with three visual states: loading spinner, success (replaces form), and error (inline above form).
| Prop | Type | Default | Description |
|---|---|---|---|
successTitle | string | "Message sent" | Success heading text. |
successMessage | string | "We'll get back to you soon." | Success body text. |
errorMessage | string | "Something went wrong. Please try again." | Error body text. |
resetLabel | string | "Send another message" | Reset link text (resets form status to idle). |
class | string | — | Additional CSS classes. |
The success container uses role="status" for screen reader announcement.
CaptchaField
Cloudflare Turnstile widget wrapper. Loads the Turnstile script lazily on first form interaction. In dev mode (no site key), renders a placeholder with an auto-bypass token.
| Prop | Type | Default | Description |
|---|---|---|---|
siteKey | string | — | Turnstile public site key. Resolution chain: prop → siteConfig.forms.turnstileSiteKey → TURNSTILE_SITE_KEY env var. |
theme | "light" | "dark" | "auto" | "auto" | Widget theme. |
size | "normal" | "compact" | "normal" | Widget size. |
class | string | — | Additional CSS classes. |
ListBox
Vanilla TypeScript custom select with correct ARIA semantics, keyboard navigation, and a native <select> fallback via <noscript>. Relocated from src/core/components/primitives/ to the forms system.
| Prop | Type | Default | Description |
|---|---|---|---|
options | Option[] | — | Array of { value, label, disabled? } objects (required). |
name | string | — | Hidden input name for form submission. |
placeholder | string | "Select an option…" | Default display text and aria-label. |
initialValue | string | — | Pre-select the option whose value matches. |
class | string | — | Additional CSS classes. |
Emits listbox-change custom event on selection with { value, label } detail. Listens for listbox-set custom event to set value programmatically.
Section Components
ContactForm
Two-column contact form section. Left column displays heading, description, and contact info from siteConfig.contact (phone, email, address). Right column is a vanilla JS-enhanced form with blur validation and AJAX submission to /api/contact/.
| Prop | Type | Default | Description |
|---|---|---|---|
heading | string | "Get in Touch" | Section heading. |
description | string | — | Description paragraph below heading. |
showContactInfo | boolean | true | Show contact info column (hidden when no contact data in site config). |
subjects | string[] | ["General Inquiry", "Project Quote", "Support", "Other"] | Subject dropdown options. |
headingLevel | 2 | 3 | 4 | 2 | HTML heading level. |
headingSize | string | "2xl" | Visual heading size. |
All other HTML attributes (id, aria-*, data-*, etc.) are forwarded to the root <div> via attribute passthrough. Surface and container framing are the caller’s responsibility via LayoutSection composition.
Usage:
---
import ContactForm from "@core/components/sections/ContactForm.astro";
---
<ContactForm
heading="Get in Touch"
description="Fill out the form and we'll get back to you."
/>
NewsletterSignup
Standalone newsletter section with centered heading, description, and an embedded NewsletterForm.
| Prop | Type | Default | Description |
|---|---|---|---|
heading | string | "Stay Updated" | Section heading. |
description | string | "Subscribe to our newsletter for the latest updates." | Description text. |
headingLevel | 2 | 3 | 4 | 2 | HTML heading level. |
headingSize | string | "2xl" | Visual heading size. |
All other HTML attributes are forwarded to the root <div> via attribute passthrough. Surface and container framing are the caller’s responsibility via LayoutSection composition.
NewsletterForm
Embeddable newsletter form fragment with no section wrapper. Designed for footer, CTA, sidebar, or NewsletterSignup contexts. Submits via AJAX to /api/newsletter/.
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | "Enter your email" | Email input placeholder. |
buttonLabel | string | "Subscribe" | Submit button text. |
successMessage | string | "Thanks! We've received your request." | Success message text. |
compact | boolean | false | Reduced spacing for embedded contexts. |
class | string | — | Additional CSS classes. |
Server-Side Pipeline
Both API routes share a common pipeline:
- Parse JSON body — Returns 400 if body is missing or malformed.
- Honeypot check — If
_gotchafield is non-empty, returns 422 silently. - Rate limiting — In-memory IP counter (5 requests per 15 minutes). Returns 429 with
retryAfterseconds. - Turnstile verification — Validates
cf-turnstile-responsetoken server-side. Returns 403 on failure. - Field validation — Schema validation with per-field error messages. Returns 422 with
errorsobject. - Provider dispatch — Sends email (contact) or lead-capture notification (newsletter). Returns 500 on provider failure.
All responses use a consistent JSON envelope:
{ "success": true }
{ "success": false, "error": "message" }
{ "success": false, "errors": { "field": "message" } }
API Routes
POST /api/contact/ — Contact form submission.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
email | string | yes | Validated format. |
phone | string | no | |
subject | string | yes | |
message | string | yes | Minimum 10 characters. |
consent | boolean | yes | Must be true. |
_gotcha | string | — | Honeypot (must be empty). |
cf-turnstile-response | string | — | Turnstile token. |
POST /api/newsletter/ — Newsletter signup.
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | Validated format. |
consent | boolean | yes | Must be true. |
_gotcha | string | — | Honeypot (must be empty). |
cf-turnstile-response | string | — | Turnstile token. |
Environment Variables
| Variable | Required | Purpose |
|---|---|---|
TURNSTILE_SITE_KEY | Production | Cloudflare Turnstile public site key. Also configurable via siteConfig.forms.turnstileSiteKey. |
TURNSTILE_SECRET_KEY | Production | Cloudflare Turnstile secret key for server-side verification. |
POSTMARK_API_KEY | Production | Postmark server API token for sending email. |
POSTMARK_FROM_EMAIL | No | Sender address. Default: "[email protected]". Also configurable via siteConfig.forms.postmarkFromEmail. |
CONTACT_EMAIL_TO | No | Recipient address. Default: "[email protected]". Also configurable via siteConfig.forms.contactEmailTo. |
Local development (.env)
Create a .env file in the project root. It is gitignored by default — never commit it.
# .env
# Leave Turnstile and Postmark keys absent for dev-mode bypass (see below).
# Set them here only if you want to test real integration locally.
TURNSTILE_SITE_KEY=your-turnstile-site-key
TURNSTILE_SECRET_KEY=your-turnstile-secret-key
POSTMARK_API_KEY=your-postmark-server-api-token
POSTMARK_FROM_EMAIL=[email protected]
CONTACT_EMAIL_TO=[email protected]
Astro reads .env automatically during npm run dev and npm run build. Variables are only available server-side (API routes). They are never exposed to the browser.
If you omit all keys, dev-mode fallback activates automatically (see below) — no .env is needed for local development.
Cloudflare Pages
Set environment variables in the Cloudflare dashboard:
-
Open the project in Cloudflare Dashboard → Pages → your project.
-
Go to Settings → Environment variables.
-
Add each variable under Production (and optionally Preview if you want staging to send real emails):
Variable Value TURNSTILE_SITE_KEYFrom Cloudflare Turnstile dashboard → your widget → Site Key TURNSTILE_SECRET_KEYFrom Cloudflare Turnstile dashboard → your widget → Secret Key POSTMARK_API_KEYFrom Postmark → your server → API Tokens POSTMARK_FROM_EMAILA sender address verified in Postmark CONTACT_EMAIL_TOWhere contact form submissions should be delivered -
Click Save and trigger a new deployment. Variables take effect on the next build.
Adapter note: Swap
@astrojs/nodefor@astrojs/cloudflareinastro.config.mjsbefore deploying to Pages (D-C8-15). The API route code is already Workers-compatible — no other changes are needed.
Node server
For a Node.js deployment (e.g., a VPS or container), pass variables via the process environment. The @astrojs/node adapter is already configured.
Option A — shell export (one-off or scripted):
export TURNSTILE_SITE_KEY=your-turnstile-site-key
export TURNSTILE_SECRET_KEY=your-turnstile-secret-key
export POSTMARK_API_KEY=your-postmark-server-api-token
export POSTMARK_FROM_EMAIL=[email protected]
export CONTACT_EMAIL_TO=[email protected]
node dist/server/entry.mjs
Option B — systemd unit (Environment= directives):
[Service]
Environment=TURNSTILE_SITE_KEY=your-turnstile-site-key
Environment=TURNSTILE_SECRET_KEY=your-turnstile-secret-key
Environment=POSTMARK_API_KEY=your-postmark-server-api-token
Environment=POSTMARK_FROM_EMAIL=[email protected]
Environment=CONTACT_EMAIL_TO=[email protected]
ExecStart=/usr/bin/node /var/www/mysite/dist/server/entry.mjs
Option C — dotenv file loaded by the process manager (PM2, Docker, etc.):
Create a .env file in your deployment directory (outside the repo) and load it via your process manager’s env file option. Never commit this file.
Dev-mode fallback (D-C8-02)
When credentials are absent and import.meta.env.PROD is false:
- Turnstile: Verification is bypassed (returns success).
- Email/newsletter providers: Log the payload to the server console and return success.
In production, missing credentials fail closed — Turnstile returns 403, email provider returns 500.
Site Configuration
Forms are configured via siteConfig.forms (type FormConfig in site.schema.ts):
forms: {
privacyPolicyUrl: "/privacy/", // Linked from consent checkboxes
turnstileSiteKey: undefined, // Override TURNSTILE_SITE_KEY env var
contactEmailTo: undefined, // Override CONTACT_EMAIL_TO env var
postmarkFromEmail: undefined, // Override POSTMARK_FROM_EMAIL env var
}
Env vars take precedence over site config values for server-side operations (Turnstile verification, email sending). Site config values are used for client-side rendering (Turnstile widget site key).
GDPR Consent (D-C8-09)
Both forms include a consent checkbox linking to the privacy policy URL from siteConfig.forms.privacyPolicyUrl. The consent field is required (true) and included in the API payload for auditability. Forms will not submit without consent checked.
Spam Protection
Three layers of spam protection:
- Honeypot field — A hidden
_gotchainput. Bots that fill it are silently rejected (422). - Cloudflare Turnstile — Invisible CAPTCHA challenge. Token verified server-side via
turnstile.ts. - Rate limiter — In-memory IP counter (5 submissions per 15-minute window). Returns 429 with
retryAfter.
Graceful Degradation (D-C8-16)
JavaScript is required for form submission because Turnstile CAPTCHA needs JS to generate tokens. Without JavaScript:
- Forms render as readable, tab-navigable HTML with all fields, labels, and help text visible.
- A
<noscript>message informs users that JavaScript is required for submission. - No broken UI or error states appear.
With JavaScript, vanilla TypeScript manages the full form lifecycle: blur validation on required fields, AJAX submission with loading state, and success/error status transitions.
Customization
Changing subject options
Pass a subjects array to ContactForm:
<ContactForm subjects={["Consulting", "Development", "Design"]} />
Embedding NewsletterForm
NewsletterForm has no section wrapper, so it can be placed anywhere:
---
import NewsletterForm from "@core/components/forms/NewsletterForm.astro";
---
<footer>
<NewsletterForm compact placeholder="Your email" buttonLabel="Join" />
</footer>
Swapping providers
Replace the default Postmark adapters by modifying the factory functions in email-provider.ts and newsletter-provider.ts. Both expose a provider interface (EmailProvider, NewsletterProvider) that any adapter can implement.