February 25, 2026

Building a Contact Form in Astro 5 with Resend and Cloudflare Turnstile

Building a Contact Form in Astro 5 with Resend and Cloudflare Turnstile

For years, building a functional contact form in a static site meant reaching for a third party service like Formspree, Netlify Forms, Basin or wiring up a separate backend just to send an email. The site might be fast and simple, but the form always dragged in an external dependency with its own pricing, limits, and failure modes.

Astro 5 changes that equation. With Astro Actions, you get a typed, server side handler that runs inside your existing Astro project so there is no separate API, no external form service. Pair it with Resend for reliable transactional email and Cloudflare Turnstile for invisible bot protection, and you have a complete contact form stack that stays entirely under your control. When the form submits, Turnstile verifies the visitor server side, Resend fires two emails in parallel (one to you, one to the sender), and the page re renders with a success or error message and no JavaScript required.

What You’ll Need

  • An Astro 5 project with output: 'server' or output: 'hybrid' in astro.config.mjs
  • Any SSR adapter (this tutorial uses @astrojs/cloudflare, but the form code is adapter agnostic)
  • Node 18+
  • A Resend account. The free tier includes 3,000 emails/month and one custom sending domain
  • A Cloudflare account with Turnstile enabled. Free, no credit card required

Step 1: Install Dependencies

Resend is the only package you need to add. Zod ships with Astro 5 via astro:schema, and the Turnstile widget loads from Cloudflare’s CDN. No install required.

npm install resend

Step 2: Configure Environment Variables

2a. What you need

  • RESEND_API_KEY — generated in your Resend dashboard under API Keys
  • CONTACT_EMAIL — the address that will receive form submission notifications
  • TURNSTILE_SITE_KEY — public key from your Turnstile widget settings (safe to expose in HTML)
  • TURNSTILE_SECRET_KEY — secret key from your Turnstile widget settings (server only. Never expose this)

2b. Register them in astro.config.mjs

Astro 5 has a typed environment variable system built in. Add an env block to your config:

import { defineConfig, envField } from 'astro/config';

export default defineConfig({
  env: {
    schema: {
      RESEND_API_KEY: envField.string({ context: 'server', access: 'secret' }),
      CONTACT_EMAIL: envField.string({ context: 'server', access: 'secret' }),
      TURNSTILE_SITE_KEY: envField.string({ context: 'client', access: 'public' }),
      TURNSTILE_SECRET_KEY: envField.string({ context: 'server', access: 'secret' }),
    },
  },
  // ... rest of your config
});

Tip: The context and access fields control where each variable is available. The only combination that exposes a variable to the browser is context: 'client' + access: 'public'. Everything else stays server side only. Use this system to enforce that secrets like RESEND_API_KEY and TURNSTILE_SECRET_KEY can never accidentally end up in client side code. TypeScript will catch the import at build time.

Create a .env file in your project root:

RESEND_API_KEY=re_xxxxxxxx
CONTACT_EMAIL=you@yourdomain.com
TURNSTILE_SITE_KEY=0x4AAAAAAA...
TURNSTILE_SECRET_KEY=0x4AAAAAAA...

Add .env to your .gitignore This should never be committed.

Step 3: Create the Astro Action (src/actions/index.ts)

Astro Actions live in src/actions/index.ts. This is where form data gets validated, the CAPTCHA is verified, and emails are sent.

3a. File setup and input schema

import { ActionError, defineAction } from 'astro:actions';
import { z } from 'astro:schema';
import { Resend } from 'resend';
import { RESEND_API_KEY, CONTACT_EMAIL, TURNSTILE_SECRET_KEY } from 'astro:env/server';

export const server = {
  send: defineAction({
    accept: 'form',
    input: z.object({
      name: z.string().min(1),
      email: z.string().email(),
      phone: z.string().optional(),
      service: z.string(),
      message: z.string().min(1),
      'cf-turnstile-response': z.string().min(1),
    }),
    handler: async ({ name, email, phone, service, message, 'cf-turnstile-response': turnstileToken }) => {
      // ... handler code below
    },
  }),
};

accept: 'form' tells Astro to parse the request as FormData automatically and validate it against the Zod schema before the handler runs. If any field fails validation, Astro returns a typed error before your handler even executes.

Note 'cf-turnstile-response' in the schema. This is the exact field name that Cloudflare’s Turnstile script injects into the form on submit. You don’t add this as a hidden input yourself; the widget handles it.

3b. Verify the Turnstile token

Inside the handler, the first thing to do is verify the CAPTCHA token server side:

const verifyRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ secret: TURNSTILE_SECRET_KEY, response: turnstileToken }),
});
const verifyData = await verifyRes.json() as { success: boolean };
if (!verifyData.success) {
  throw new ActionError({ code: 'FORBIDDEN', message: 'CAPTCHA verification failed.' });
}

Warning: Turnstile verification must happen server side. The token the client sends is a proof of work that can only be validated by Cloudflare’s API using your secret key. Never trust the client to self report whether the challenge passed. Bots will just lie.

3c. Send the emails

After verification passes, initialize the Resend client and fire both emails in parallel:

const resend = new Resend(RESEND_API_KEY);

const [notification, confirmation] = await Promise.all([
  // Internal notification — sent to you
  resend.emails.send({
    from: 'Your Business <no-reply@yourdomain.com>',
    replyTo: [email],
    to: CONTACT_EMAIL,
    subject: `New Contact Form Submission from ${name}`,
    html: `<p>Name: ${name}</p><p>Email: ${email}</p><p>Message: ${message}</p>`,
  }),

  // Customer confirmation — sent to the person who submitted
  resend.emails.send({
    from: 'Your Business <no-reply@yourdomain.com>',
    replyTo: [CONTACT_EMAIL],
    to: [email],
    subject: `Got your message, ${name}!`,
    html: `<p>Hi ${name}, we received your message and will reply within one business day.</p>`,
  }),
]);

if (notification.error) {
  throw new ActionError({ code: 'BAD_REQUEST', message: notification.error.message });
}
if (confirmation.error) {
  throw new ActionError({ code: 'BAD_REQUEST', message: confirmation.error.message });
}

return notification.data;

Tip: The replyTo direction matters. On the internal notification, replyTo is set to the visitor’s email so you can hit Reply in your inbox and respond directly. On the confirmation sent to the visitor, replyTo is set to your business address so they can reply to you. Replace the html strings with your own templates for production.

Tip: Promise.all runs both Resend calls concurrently. Since each call is an independent HTTP request with no shared state, there’s no reason to await them sequentially. In practice this saves 200–500ms per form submission.

Warning: The from address domain must be verified in Resend before you can send from it in production. During development, you can use onboarding@resend.dev as a from address without domain verification (Resend’s sandbox address).

Step 4: Build the Contact Form (src/pages/contact.astro)

4a. Frontmatter

---
import { actions } from 'astro:actions';
import { TURNSTILE_SITE_KEY } from 'astro:env/client';

const result = Astro.getActionResult(actions.send);
---

Astro.getActionResult(actions.send) returns undefined on a normal GET request and { data, error } after a POST that triggered the action. This is Astro’s progressive enhancement model. The form works without JavaScript, and feedback is server rendered on the redirect back to the page.

4b. Success and error feedback

Render conditional feedback above the form based on the action result:

{result?.error && (
  <div class="error-banner">
    Something went wrong. Please try again or call us directly.
  </div>
)}
{result?.data && (
  <div class="success-banner">
    Message sent! We'll be in touch within one business day.
  </div>
)}

4c. The form

<form method="POST" action={actions.send}>
  <input type="text" name="name" required />
  <input type="email" name="email" required />
  <input type="tel" name="phone" />
  <select name="service" required>
    <option value="">Select one...</option>
    <option value="new-website">I need a new website</option>
    <option value="existing-website">I have a website that needs help</option>
  </select>
  <textarea name="message" required></textarea>

  <div class="cf-turnstile" data-sitekey={TURNSTILE_SITE_KEY} data-theme="light"></div>

  <button type="submit">Send Message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer is:inline></script>

Add your own styling. Tailwind utility classes or plain CSS work fine on all of these elements.

Tip: is:inline on the script tag prevents Astro from processing it through its Vite bundler. External CDN scripts must use is:inline; without it Astro will try to bundle the external URL and fail.

Warning: The name attributes on your form fields must exactly match the keys in your Zod schema. If they don’t match, the action’s input validation will reject the submission before your handler runs. The cf-turnstile-response token is injected automatically by Cloudflare’s script when the form submits. Do not add your own hidden input with that name.

Step 5: How It All Fits Together

Here’s the full request lifecycle:

  1. User loads the contact page (GET) — Astro.getActionResult returns undefined, form renders normally
  2. User fills out the form and clicks Submit
  3. Browser submits a POST request with FormData to the same URL, targeting the send action
  4. Astro’s action runtime validates the form data against the Zod schema. Invalid fields return an error immediately
  5. The handler verifies the Turnstile token with Cloudflare’s API
  6. If verification passes, both Resend emails fire in parallel
  7. The action returns notification.data on success (or throws an ActionError on failure)
  8. Astro redirects the browser back to the contact page via a GET request
  9. Astro.getActionResult now returns the result, and the success or error banner renders

Step 6: Testing

Turnstile test keys: Cloudflare provides keys that always pass verification during development. Use these in your .env while developing so you don’t need to solve a real challenge:

  • Site key: 1x00000000000000000000AA
  • Secret key: 1x0000000000000000000000000000000AA

Swap these for your real keys before deploying.

Resend sandbox: Use onboarding@resend.dev as the from address in development to skip domain verification. Emails will still send but only to the verified email address on your Resend account.

Delivery debugging: Check the Logs tab in your Resend dashboard. It shows every attempted send, delivery status, and any bounce or error details.

Wrapping Up

What you’ve built is a contact form with no third party form service and no external backend: typed environment variables enforced at build time, secrets that can never leak to the client, schema validated input that rejects bad data before your handler runs, server side CAPTCHA verification that bots can’t bypass, and two transactional emails sent in parallel via a simple API.

Some natural next steps:

  • Rate limiting: add a check in the handler using a key value store (Cloudflare KV works well if you’re on the Cloudflare adapter) to limit submissions per IP
  • Database logging: store each submission to a database like Supabase alongside the email, useful if email delivery ever fails
  • Richer HTML email templates: the inline style table based templates in the action produce emails that render correctly across Outlook, Apple Mail, and Gmail; the simple <p> tags above are fine for getting started but won’t look polished in production.