Computer icon illustration

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:

  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

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:

  1. parse JSON body
  2. validate name/email/message lengths and format
  3. if honeypot is filled, return fake success
  4. verify Turnstile token against: https://challenges.cloudflare.com/turnstile/v0/siteverify
  5. 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:

  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.

Common pitfalls

  • Missing TURNSTILE_SECRET_KEY in production
  • Using an unverified CONTACT_FROM_EMAIL domain in Resend
  • Reading client env vars from server code (or vice versa)
  • Running multiple stale astro dev processes 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.