Contact Us

Forms & Submission

DOC-00017 guide implementor, developer

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

FileOwnershipPurpose
src/core/components/forms/FormField.astroCOREUniversal field wrapper: label, input/textarea, help text, error display.
src/core/components/forms/FormError.astroCOREField-level (aria-live="polite") and form-level (role="alert") error display.
src/core/components/forms/FormStatus.astroCOREPost-submission status: loading spinner, success replacement, inline error.
src/core/components/forms/CaptchaField.astroCORECloudflare Turnstile widget; dev-mode placeholder with bypass token.
src/core/components/forms/ListBox.astroCOREVanilla JS custom select with <noscript> native fallback.
src/core/components/forms/NewsletterForm.astroCOREEmbeddable newsletter form fragment (no section wrapper).
src/core/components/sections/ContactForm.astroCORETwo-column contact form section (info + form).
src/core/components/sections/NewsletterSignup.astroCOREStandalone newsletter section wrapping NewsletterForm.
src/pages/api/contact.tsCOREContact form API route (server-side).
src/pages/api/newsletter.tsCORENewsletter signup API route (server-side).
src/lib/forms/validation.tsCOREShared server-side form validation.
src/lib/forms/honeypot.tsCOREHoneypot field check.
src/lib/forms/rate-limiter.tsCOREIn-memory IP-based rate limiter.
src/lib/forms/turnstile.tsCOREServer-side Turnstile token verification.
src/lib/forms/email-provider.tsCOREEmail provider interface + Postmark adapter.
src/lib/forms/newsletter-provider.tsCORENewsletter provider interface + Postmark notification adapter.

Form Primitives

FormField

Universal form field wrapper composing a label, input or textarea, optional help text, and error display.

PropTypeDefaultDescription
namestringField name (required). Used for id, aria-describedby, and error association.
labelstringVisible label text (required).
type"text" | "email" | "tel" | "textarea""text"Input type. "textarea" renders a <textarea>.
requiredbooleanfalseAdds required and aria-required="true".
helpTextstringHelp text below the input, linked via aria-describedby.
placeholderstringInput placeholder text.
autocompletestringHTML autocomplete hint.
rowsnumber4Textarea rows (ignored for input types).
classstringAdditional 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.

PropTypeDefaultDescription
fieldNamestringWhen provided: renders field-level error with id="{fieldName}-error" and aria-live="polite". When omitted: renders form-level error with role="alert".
classstringAdditional CSS classes.

FormStatus

Post-submission status display with three visual states: loading spinner, success (replaces form), and error (inline above form).

PropTypeDefaultDescription
successTitlestring"Message sent"Success heading text.
successMessagestring"We'll get back to you soon."Success body text.
errorMessagestring"Something went wrong. Please try again."Error body text.
resetLabelstring"Send another message"Reset link text (resets form status to idle).
classstringAdditional 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.

PropTypeDefaultDescription
siteKeystringTurnstile public site key. Resolution chain: prop → siteConfig.forms.turnstileSiteKeyTURNSTILE_SITE_KEY env var.
theme"light" | "dark" | "auto""auto"Widget theme.
size"normal" | "compact""normal"Widget size.
classstringAdditional 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.

PropTypeDefaultDescription
optionsOption[]Array of { value, label, disabled? } objects (required).
namestringHidden input name for form submission.
placeholderstring"Select an option…"Default display text and aria-label.
initialValuestringPre-select the option whose value matches.
classstringAdditional 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/.

PropTypeDefaultDescription
headingstring"Get in Touch"Section heading.
descriptionstringDescription paragraph below heading.
showContactInfobooleantrueShow contact info column (hidden when no contact data in site config).
subjectsstring[]["General Inquiry", "Project Quote", "Support", "Other"]Subject dropdown options.
headingLevel2 | 3 | 42HTML heading level.
headingSizestring"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.

PropTypeDefaultDescription
headingstring"Stay Updated"Section heading.
descriptionstring"Subscribe to our newsletter for the latest updates."Description text.
headingLevel2 | 3 | 42HTML heading level.
headingSizestring"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/.

PropTypeDefaultDescription
placeholderstring"Enter your email"Email input placeholder.
buttonLabelstring"Subscribe"Submit button text.
successMessagestring"Thanks! We've received your request."Success message text.
compactbooleanfalseReduced spacing for embedded contexts.
classstringAdditional CSS classes.

Server-Side Pipeline

Both API routes share a common pipeline:

  1. Parse JSON body — Returns 400 if body is missing or malformed.
  2. Honeypot check — If _gotcha field is non-empty, returns 422 silently.
  3. Rate limiting — In-memory IP counter (5 requests per 15 minutes). Returns 429 with retryAfter seconds.
  4. Turnstile verification — Validates cf-turnstile-response token server-side. Returns 403 on failure.
  5. Field validation — Schema validation with per-field error messages. Returns 422 with errors object.
  6. 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.

FieldTypeRequiredNotes
namestringyes
emailstringyesValidated format.
phonestringno
subjectstringyes
messagestringyesMinimum 10 characters.
consentbooleanyesMust be true.
_gotchastringHoneypot (must be empty).
cf-turnstile-responsestringTurnstile token.

POST /api/newsletter/ — Newsletter signup.

FieldTypeRequiredNotes
emailstringyesValidated format.
consentbooleanyesMust be true.
_gotchastringHoneypot (must be empty).
cf-turnstile-responsestringTurnstile token.

Environment Variables

VariableRequiredPurpose
TURNSTILE_SITE_KEYProductionCloudflare Turnstile public site key. Also configurable via siteConfig.forms.turnstileSiteKey.
TURNSTILE_SECRET_KEYProductionCloudflare Turnstile secret key for server-side verification.
POSTMARK_API_KEYProductionPostmark server API token for sending email.
POSTMARK_FROM_EMAILNoSender address. Default: "[email protected]". Also configurable via siteConfig.forms.postmarkFromEmail.
CONTACT_EMAIL_TONoRecipient 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:

  1. Open the project in Cloudflare Dashboard → Pages → your project.

  2. Go to Settings → Environment variables.

  3. Add each variable under Production (and optionally Preview if you want staging to send real emails):

    VariableValue
    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
  4. Click Save and trigger a new deployment. Variables take effect on the next build.

Adapter note: Swap @astrojs/node for @astrojs/cloudflare in astro.config.mjs before 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).

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:

  1. Honeypot field — A hidden _gotcha input. Bots that fill it are silently rejected (422).
  2. Cloudflare Turnstile — Invisible CAPTCHA challenge. Token verified server-side via turnstile.ts.
  3. 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.

Manual accessibility test entries verified during the accessibility audit. Covers keyboard operability, screen-reader announcements, and ARIA semantics.

ContactForm

Interaction Expected Behavior WCAG Criterion Test Method
Tab through all form fields (name, email, message, submit)All fields are reachable by keyboard in logical order with visible focus rings2.1.1 Keyboard Keyboard
Submit the form with empty required fieldsValidation errors appear; focus moves to the first invalid field or error summary3.3.1 Error Identification Keyboard
Fill in valid data and submitSuccess state is communicated3.3.1 Error Identification Keyboard
Inspect form fields in DevToolsEach input has an associated label; required fields have aria-required or required; errors use aria-describedby and aria-invalid1.3.1 Info and Relationships Visual Inspection
Submit with errors while NVDA is runningNVDA announces error messages via aria-describedby, aria-invalid, or live region4.1.3 Status Messages Screen Reader
Submit valid data while NVDA is runningNVDA announces the success message via live region or focus move4.1.3 Status Messages Screen Reader

NewsletterForm

Interaction Expected Behavior WCAG Criterion Test Method
Tab to the newsletter email input and submit buttonBoth controls are focusable in logical order with visible focus rings2.1.1 Keyboard Keyboard
Submit with an empty or invalid emailValidation error appears3.3.1 Error Identification Keyboard
Submit with a valid emailSuccess state is communicated3.3.1 Error Identification Keyboard
Inspect the newsletter form fields in DevToolsEmail input has an associated label; aria-required or required is present; error/success messages use aria-live or role="status"; aria-invalid toggles on validation1.3.1 Info and Relationships Visual Inspection
Submit with errors while NVDA is runningNVDA announces the validation error4.1.3 Status Messages Screen Reader

ListBox

Interaction Expected Behavior WCAG Criterion Test Method
Tab to the ListBox, press Enter or Space to openDropdown list opens, focus moves into the list2.1.1 Keyboard Keyboard
Use Arrow Down/Up to navigate optionsFocus moves between options with a visible indicator on the current option2.1.1 Keyboard Keyboard
Press Enter to select an optionOption is selected, listbox closes, focus returns to the trigger2.1.1 Keyboard Keyboard
Press Escape while the listbox is openListbox closes without changing selection, focus returns to the trigger2.1.1 Keyboard Keyboard
Inspect the listbox container and options in DevToolsTrigger has aria-expanded toggling; aria-haspopup="listbox" is present; options have role="option" with aria-selected on the active option4.1.2 Name, Role, Value Visual Inspection
Open the ListBox with NVDA runningNVDA announces the listbox role, current selection, and each option label during navigation4.1.2 Name, Role, Value Screen Reader
REQ-00008 normative The theme shall support hybrid rendering patterns.
REQ-00009 normative Hybrid rendering shall support dynamic behaviors including search, filtering, pagination, sorting, and forms requiring runtime logic.
REQ-00020 implemented All data-driven views shall define deterministic empty and error states.
REQ-00086 implemented Forms shall display visible validation and error messaging.
REQ-00087 implemented Form validation shall implement correct ARIA relationships and semantics.
REQ-00104 normative GDPR compliance mechanisms shall be supported.
REQ-00172 implemented The system shall support environment-aware configuration so that behavior can differ between development and production builds.
REQ-00206 implemented The contact form shall submit validated data to the Postmark HTTP API for transactional email delivery; API credentials shall not be exposed to the client.
REQ-00235 implemented Form submissions shall pass through a layered server-side validation pipeline in this order: honeypot check, rate limiting, CAPTCHA verification, field validation, provider dispatch. Each layer shall return a typed JSON response with appropriate HTTP status code (200 success, 422 validation error, 429 rate limit, 403 authorization failure, 500 provider error).
REQ-00236 implemented Server-side form handling shall use pluggable provider adapter interfaces for email delivery (EmailProvider) and newsletter subscription (NewsletterProvider), enabling backend substitution without form component changes. In development, providers shall fall back to console logging when credentials are absent; in production, missing credentials shall cause requests to fail closed.
REQ-00237 implemented All forms collecting personal data shall include a required GDPR consent checkbox linking to the site's privacy policy URL, preventing submission without explicit consent.
REQ-00238 implemented Form endpoints shall enforce rate limiting per client IP address, with configurable thresholds (default: 5 submissions per 15 minutes) and adapter-aware IP resolution supporting reverse proxy and CDN headers (X-Forwarded-For, CF-Connecting-IP, X-Real-IP).
REQ-00239 implemented Forms shall render as valid, accessible HTML without JavaScript. A noscript message shall inform users that JavaScript is required for submission. Native HTML constraints (required, type, minlength) shall provide first-line validation before client-side vanilla JS enhancement.
REQ-00261 implemented Form submission UX shall implement hybrid status feedback: replace the entire form with a success message on successful submission; display field-level error messages inline on validation failure, keeping the form visible for correction.

Search

Search across pages and articles. Use arrow keys to navigate results.

Search across pages and articles.

Loading search...

Search is unavailable. Please try again later.

    No results for ""

    Try different keywords or fewer words.