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:
- a
/contactpage with name, email, and message fields - Turnstile challenge verification
- a server route that validates input and sends email via Resend
- environment-based configuration for local + production
Status codes (which fix post to open)
| Status | Meaning | Post |
|---|---|---|
| 404 | API route not on the Worker | Fix API route 404 |
| 503 | Turnstile or Resend secret missing in prod | 503 Turnstile or check RESEND_API_KEY |
| 400 | Validation or missing Turnstile token | Widget / payload — verification failed DevTools section |
| 403 | siteverify failed | Turnstile verification failed |
| 502 | Resend rejected the send | Resend 403/422 |
| 429 | WAF rate limit on /api/contact | Wait; see production hardening below |
| 200 | Success | Done |
File map (this repo)
| Path | Role |
|---|---|
src/pages/contact.astro | SSR contact page, Turnstile widget, client fetch to /api/contact |
src/pages/api/contact.ts | POST handler — validation, Turnstile, Resend |
src/lib/contact.ts | parseContactPayload, verifyTurnstile, sendContactEmail |
src/lib/worker-env.ts | readEnv — cloudflare:workers env + Astro secrets |
astro.config.mjs | env.schema for Turnstile and Resend keys |
.dev.vars / .env.example | Local 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:
- Parse and validate name, email, message (length and email shape)
- If honeypot is filled, return fake success (bots only)
- If Turnstile is configured, require
turnstileTokenand callsiteverify - 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:
- run
npm run dev - submit a real message from
/contact - confirm Turnstile challenge appears and validates
- confirm message arrives at
CONTACT_TO_EMAIL - 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_KEYin production (503 before verify runs) - Unverified
CONTACT_FROM_EMAILdomain in Resend (502 with 403/422 in the body) - Secrets only in
.envbut not.dev.varsduringastro 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.