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 I use on personal projects: simple enough to maintain, but 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
1) Environment variables
Add these locally in .env:
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
In production, set these as Worker vars/secrets 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(client/public)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
On the page:
- render the form fields
- include a hidden honeypot input (basic bot trap)
- load Turnstile script
- render Turnstile widget with
PUBLIC_TURNSTILE_SITE_KEY - submit JSON to
/api/contact
Keep the UX simple: one clear success message and one clear failure message.
4) Validate + verify in /api/contact
In your API route:
- parse JSON body
- validate name/email/message lengths and format
- if honeypot is filled, return fake success
- verify Turnstile token against:
https://challenges.cloudflare.com/turnstile/v0/siteverify - if valid, send through Resend
The critical rule: never trust client validation alone. Always validate and verify on the server.
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.
Common pitfalls
- Missing
TURNSTILE_SECRET_KEYin production - Using an unverified
CONTACT_FROM_EMAILdomain in Resend - Reading client env vars from server code (or vice versa)
- Running multiple stale
astro devprocesses and debugging the wrong port
If local behavior is inconsistent, reset:
pkill -f "astro dev" || true
pkill -f "npm run dev" || true
rm -rf .astro
npm run dev
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.
Hero image: Computer icon by Dan Fuhry, licensed CC BY-SA 2.5.