A cat lying across a keyboard

Turnstile + Resend Contact Form: Complete Setup for Astro


If you want a contact form on an Astro site without opening yourself up to bot spam, this setup is a strong baseline:

  • Cloudflare Turnstile for bot verification
  • Resend for reliable email delivery
  • a small Astro API route for server-side validation and sending

This walkthrough matches the architecture on mayfield.io: simple enough to maintain, production-safe enough to trust.

What you are building

By the end, you will have:

  1. a /contact page with name, email, and message fields
  2. Turnstile challenge verification
  3. a server route that validates input and sends email via Resend
  4. environment-based configuration for local + production

Status codes (which fix post to open)

StatusMeaningPost
404API route not on the WorkerFix API route 404
503Turnstile or Resend secret missing in prod503 Turnstile or check RESEND_API_KEY
400Validation or missing Turnstile tokenWidget / payload — verification failed DevTools section
403siteverify failedTurnstile verification failed
502Resend rejected the sendResend 403/422
429WAF rate limit on /api/contactWait; see production hardening below
200SuccessDone

File map (this repo)

PathRole
src/pages/contact.astroSSR contact page, Turnstile widget, client fetch to /api/contact
src/pages/api/contact.tsPOST handler — validation, Turnstile, Resend
src/lib/contact.tsparseContactPayload, verifyTurnstile, sendContactEmail
src/lib/worker-env.tsreadEnvcloudflare:workers env + Astro secrets
astro.config.mjsenv.schema for Turnstile and Resend keys
.dev.vars / .env.exampleLocal secret templates (not committed)

Both /contact and /api/contact use export const prerender = false so they stay on the Worker at deploy time.

1) Environment variables

Use two local files on this stack:

.dev.vars (Wrangler bindings — required for /contact and /api/contact during astro dev on Workers):

PUBLIC_TURNSTILE_SITE_KEY=your_turnstile_site_key
TURNSTILE_SECRET_KEY=your_turnstile_secret_key
RESEND_API_KEY=re_your_resend_api_key
CONTACT_FROM_EMAIL=contact@yourdomain.com
CONTACT_TO_EMAIL=you@yourdomain.com

.env (optional but used by fetch-apod on predev / prebuild):

NASA_API_KEY=your_nasa_api_key

The site key is read server-side in contact.astro via readEnv and rendered into the Turnstile widget—it is not a client-imported import.meta.env value.

In production, set Worker vars and secrets in the Cloudflare dashboard instead of committing them.

2) Define env schema in Astro config

In astro.config.mjs, define Turnstile and Resend fields in env.schema so usage is typed and explicit:

  • PUBLIC_TURNSTILE_SITE_KEY (public Worker variable)
  • TURNSTILE_SECRET_KEY (server/secret)
  • RESEND_API_KEY (server/secret)
  • CONTACT_FROM_EMAIL, CONTACT_TO_EMAIL (server/secret)

That catches missing config early instead of failing in a random runtime path.

3) Build the /contact page

contact.astro is SSR and reads the site key server-side (not via client import.meta.env):

export const prerender = false;

const turnstileSiteKey = readEnv('PUBLIC_TURNSTILE_SITE_KEY');

Load the Turnstile script when the key exists, render the widget, and POST JSON from a small client script:

<div class="cf-turnstile" data-sitekey={turnstileSiteKey} />
const turnstileInput = form.querySelector('input[name="cf-turnstile-response"]');
const payload = {
  name, email, message,
  website: honeypotField,
  turnstileToken: turnstileInput?.value || undefined,
};
await fetch('/api/contact', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(payload),
});

Also include a hidden honeypot (website) and clear success/error status text.

4) Validate + verify in /api/contact

src/pages/api/contact.ts must export the POST handler and stay dynamic:

export const prerender = false;

export const POST: APIRoute = async ({ request, clientAddress, locals }) => {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: 'Invalid JSON.' }, { status: 400 });
  }
  const payload = parseContactPayload(body);
  // honeypot → fake 200; missing token → 400; siteverify fail → 403; Resend fail → 502
};

Order of operations in src/lib/contact.ts:

  1. Parse and validate name, email, message (length and email shape)
  2. If honeypot is filled, return fake success (bots only)
  3. If Turnstile is configured, require turnstileToken and call siteverify
  4. Send mail through Resend with server-only RESEND_API_KEY

Never trust client validation alone. The browser only collects the token; the Worker verifies it.

If production returns turnstile verification failed, Verification failed. Please try again., or failed to verify cloudflare turnstile token, see Turnstile verification failed: fix in production—usually domain or secret mismatch, not the form UI. For 503 and “not configured,” see the 503 Turnstile post instead.

5) Send mail through Resend

Use a server-only helper that:

  • reads RESEND_API_KEY
  • sends from, to, reply_to, subject, and plain-text message body
  • returns clear errors when Resend responds non-2xx

This makes it easy to diagnose issues in logs without exposing sensitive internals to users.

6) Quick smoke test

After wiring everything:

  1. run npm run dev
  2. submit a real message from /contact
  3. confirm Turnstile challenge appears and validates
  4. confirm message arrives at CONTACT_TO_EMAIL
  5. confirm Reply goes to the sender address from the form

For API-only verification:

curl -i -X POST "http://localhost:4321/api/contact" \
  -H "Content-Type: application/json" \
  -d '{"name":"Test","email":"test@example.com","message":"hello world"}'

Without a valid Turnstile token, this should fail. That is expected and correct.

7) Production hardening (WAF)

Turnstile stops casual bots; a strict Cloudflare WAF rate limiting rule on POST /api/contact caps abuse and protects Resend quota. Scope the rule to the API path only—not the HTML /contact page—so page loads are not throttled. Legitimate users who submit twice in quick succession may see 429; that is rate limiting, not a Turnstile misconfiguration.

Probe locally or against production with npm run test:contact-rate-limit (see the project README Contact form security section).

Common pitfalls

  • Missing TURNSTILE_SECRET_KEY in production (503 before verify runs)
  • Unverified CONTACT_FROM_EMAIL domain in Resend (502 with 403/422 in the body)
  • Secrets only in .env but not .dev.vars during astro dev

If local behavior is inconsistent, see Fix Astro dev port conflicts.

Final take

Turnstile + Resend is a practical combo for small Astro sites. It keeps form UX simple for real users while reducing spam and preserving maintainability for you.

Related: Fix Resend 403 and 422 on Astro, Astro secrets on Cloudflare Workers, and fix Turnstile verification failed in production when the challenge passes locally but fails after deploy.


Hero photo: Cat lying on keyboard, freely licensed on Wikimedia Commons.