You can build an Astro contact form with Resend and Cloudflare while keeping your pages static. The form sends a request to a small Cloudflare Worker, which validates the submission and calls Resend. The same backend can offer an optional newsletter signup and confirm the email address before adding it to a segment.
This guide is based on the contact and newsletter forms we implemented at FastWeb. The downloadable example includes English and Spanish interfaces, spam protection and the complete confirmation flow.
Download the complete example (ZIP). It includes the Astro component, Worker, SQL schema, bilingual messages, setup instructions and automated tests with simulated email delivery. The snippets below explain the important parts; use the download for the full implementation.
What you will build
- Contact: a visitor sends a name, email address and message. You receive an email and can reply directly to the visitor.
- Optional subscription: an unchecked box lets the visitor request the newsletter without making it a condition for contacting you.
- Standalone signup: a reusable component at the end of each guide collects an email address and explicit consent.
- Email confirmation: the subscriber opens an email and presses a confirmation button before the Worker adds them to the correct Resend segment.
Astro → Cloudflare Worker → Resend. Turnstile checks submissions; D1 stores pending confirmations, consent records and short-lived request controls. Resend manages delivery and contacts. The site does not need React or server-side rendering for this architecture.
A static HTML file cannot process a POST request by itself. Here, the Worker is the backend. Astro’s own server-rendered form recipe is another option if your project already uses an adapter; it is not the setup used in this example.
Prepare your site and services
- An Astro site and basic familiarity with components, environment variables and terminal commands. The example was checked with Node.js 24, Astro 7.3.5 and Wrangler 4.147.0.
- A Cloudflare account with Workers and D1 access, plus a domain with an active Cloudflare zone and a proxied DNS record. Your existing static hosting can stay in place.
- A Resend account and a verified sending domain. Add the DNS records requested by Resend; the From address must use that domain.
- A Cloudflare Turnstile widget configured for your site’s hostname. Keep its public site key separate from its private secret key.
- Two Resend segments, such as Newsletter English and Newsletter Spanish. Copy their IDs.
For this combined contact-and-subscription Worker, create a dedicated Resend key with
full_access: it must send email and manage contacts and segments. A
sending_access key is enough for a contact-only email sender, but cannot complete
this subscription flow. Store the full-access key only as a Worker secret.
Open the example and configure your domain
Unzip the download and run these commands from its root directory:
npm install
npm test
npm run dev
The preview includes /contact/, /es/contacto/,
/newsletter/ and /es/newsletter/. Submissions are disabled until you
explicitly enable them. The demo pages are marked noindex; their purpose is testing and
integration into your own site.
The main files are:
src/components/EmailForm.astro: both form types and their browser feedback.-
workers/forms/index.ts: validation, contact email, subscription and confirmation. -
workers/forms/page.tsandmessages.ts: email templates, confirmation page and translations. workers/forms/schema.sql: database tables.workers/forms/wrangler.jsonc: deployment settings and non-secret values.
Replace the example domain, brand and mailbox in the source files and configuration. Replace the
placeholder segment IDs and D1 database ID with your own. In wrangler.jsonc, these
are the relevant settings; this excerpt belongs inside the supplied complete file:
"routes": [
{ "pattern": "example.com/api/forms/*", "zone_name": "example.com" }
],
"vars": {
"SITE_ORIGIN": "https://example.com",
"TURNSTILE_HOSTNAME": "example.com",
"RESEND_SEGMENT_EN": "YOUR_ENGLISH_SEGMENT_ID",
"RESEND_SEGMENT_ES": "YOUR_SPANISH_SEGMENT_ID",
"MAIL_FROM": "Example <hello@example.com>",
"MAIL_TO": "hello@example.com"
}
SITE_ORIGIN must be the exact HTTPS origin, without a trailing slash. If you use
www.example.com, use it consistently in the origin, Turnstile hostname and route
pattern; zone_name remains example.com.
The route covers only /api/forms/*. Do not assign the form Worker to the entire
website. Keep any existing routing in mind: the most specific matching Worker route wins.
Create D1 and store the secrets
Authenticate Wrangler, create the database, then copy the returned database ID into
d1_databases[0].database_id before executing the SQL command:
npx wrangler login
npx wrangler d1 create astro-forms
npx wrangler d1 execute astro-forms --remote \
--config workers/forms/wrangler.jsonc \
--file workers/forms/schema.sql
npx wrangler secret put RESEND_API_KEY \
--config workers/forms/wrangler.jsonc
npx wrangler secret put TURNSTILE_SECRET \
--config workers/forms/wrangler.jsonc
The two secret put commands ask for the secret values interactively. If the Worker
does not exist yet, accept Wrangler’s prompt to create it. The API key and Turnstile secret belong
in Cloudflare secrets, never in Astro components, public environment variables or version control.
D1 is used here because confirmation needs durable state. The example stores a hash of the random token, its expiry and the requested language. Contact request receipts contain a payload hash, not the message body. Consent records retain the email, source path, language, timestamps and consent-text version; set a retention and deletion policy appropriate to your site.
Add the Astro contact form
For an existing Astro site, copy EmailForm.astro into your components directory and
import it on the contact page. Adjust the import path to your file structure:
---
import EmailForm from '../components/EmailForm.astro';
---
<EmailForm locale="en" kind="contact" />
Use locale="es" on the Spanish contact page. The newsletter checkbox is optional and
unchecked. Submitting an inquiry without it sends only the contact email.
Set these public build-time values in your hosting environment, or in a local
ignored .env file when building:
PUBLIC_TURNSTILE_SITE_KEY=YOUR_PUBLIC_SITE_KEY
PUBLIC_FORMS_ENABLED=true
The site key is public by design. The Resend key and Turnstile secret must not have a
PUBLIC_ prefix. Rebuild Astro after changing the public values. Enable submissions
only after the backend is configured.
The component loads Turnstile when someone interacts with the form, shows success or failure in an accessible status area, and renews the challenge after a submission attempt. The supplied component supports one form per page. Update the privacy-policy links and your contact address before using it.
Validate the request and send with Resend
The browser sends JSON to /api/forms/en/contact or
/api/forms/es/contact. The Worker checks the origin, request size, fields and
submission limits. It also sends the Turnstile token to Cloudflare’s Siteverify endpoint and
checks success, hostname and action. Rendering the widget
alone does not validate a request.
After those checks, the email call follows this pattern. The full Worker also handles bounded responses, database state and error messages:
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `contact-${requestId}`,
},
body: JSON.stringify({
from: env.MAIL_FROM,
to: [env.MAIL_TO],
reply_to: email,
subject: 'Website inquiry',
text: `${name}\n\n${message}`,
}),
signal: AbortSignal.timeout(8000),
});
if (!response.ok) throw new Error('Email was not accepted');
from is your verified sender, to is your fixed receiving mailbox, and
reply_to is the validated visitor address. This lets you reply normally without
pretending to send from the visitor’s domain. The HTML version in the example escapes user-entered
text.
The form retains a request ID when retrying an unchanged inquiry. The Worker uses it with Resend’s
Idempotency-Key header to reduce duplicate sends; Resend retains these keys for 24
hours. If the optional subscription step fails after the inquiry was accepted, the page says so
without asking the visitor to resend the inquiry.
Add newsletter signup to the article layout
Place the newsletter component after the article’s content in your shared article layout:
---
import EmailForm from '../components/EmailForm.astro';
---
<article><slot /></article>
<EmailForm locale="en" kind="newsletter" />
Use the Spanish locale in the Spanish layout, or pass your existing locale variable. You edit the shared layout once; every article using it gets the signup form. There is no need to edit individual posts.
The newsletter form requires its consent checkbox. It calls
/api/forms/{locale}/subscribe; the Worker sends a confirmation email, but does not
yet create a marketing contact. The endpoint language determines the segment on the server. The
browser never chooses a segment ID.
Confirm the address before adding a contact
- Generate a cryptographically random token and store only its SHA-256 hash in D1, with a 24-hour expiry.
-
Email a link such as
https://example.com/api/forms/en/confirm#TOKEN. The fragment keeps the token out of the initial HTTP request. - Display a confirmation page. The script removes the fragment from the address bar and waits for the person to press the confirmation button.
- POST the token to the confirmation endpoint. Check its hash, language, expiry and previous use.
- Find or create the Resend contact, add it to the configured language segment and record consent.
Opening the link with a GET request does not subscribe anyone. This reduces accidental confirmations by simple email-link scanners. A used token cannot re-enable someone who later unsubscribed, and the example does not automatically reverse existing global unsubscribes. Existing contact names and other segment memberships are preserved.
The confirmation email is transactional. Future newsletters are separate marketing campaigns: prepare them in Resend Broadcasts, choose the intended segment and include the unsubscribe mechanism. The form itself does not schedule or send a campaign.
Test, deploy and check the complete flow
Run the checks, publish the separate Worker, then build your static site:
npm run types
npm run check:worker
npm run check
npm test
npm run dry-run
npx wrangler deploy --config workers/forms/wrangler.jsonc
npm run build
Upload the generated dist/ through your usual Astro hosting process. Deploying the
form Worker does not publish the Astro pages. For local development, Astro’s preview alone does
not run the Worker: use the automated tests, or configure a local proxy and separate test
credentials before attempting an end-to-end local flow.
The included tests simulate Resend and Turnstile: they do not send email or contact production services. Before enabling the form for visitors, test with your own mailbox on the configured domain:
- An inquiry with the checkbox clear arrives once and creates no newsletter subscription.
- An inquiry with the checkbox selected arrives, and a separate confirmation email follows.
- A newsletter signup creates no marketing contact before confirmation.
- A confirmed address joins the intended language segment; an expired or reused link cannot change subscription state.
- Validation, expired Turnstile challenges, provider failures and retries show useful feedback.
- Mobile layout, keyboard navigation and privacy links work. Check Resend’s delivery events as well as the inbox: API acceptance is not proof of delivery.
Common problems
-
404 on submission: check the proxied hostname, the Worker route and the exact
/api/forms/{locale}/...URL. -
Origin or Turnstile error: align the public hostname,
SITE_ORIGIN, widget hostnames and expected action. An expired token needs a new challenge. - Email accepted but no subscription: check that confirmation was completed, the key has contact-management permissions and the segment ID is correct.
- Old imported contacts appear twice: audit email casing and existing contact IDs before imports or synchronization. Do not assume normalizing new input repairs historical duplicates.
- 503 or throttling: inspect status codes and provider limits without logging secrets or message contents. The example uses bounded requests and conservative form limits; higher traffic may need a queue and a separate retry policy.
Documentation and next steps
- Astro: building HTML forms
- Resend: sending from Cloudflare Workers
- Resend API key permissions and adding a contact to a segment
- Turnstile server-side validation
- Cloudflare secrets, Worker routes and D1 setup
- Resend idempotency keys
Using WordPress instead? See our WordPress contact form guide. If you want help adapting this setup to your Astro site, contact FastWeb.

