Skip to content

A contact form in Astro

One page, handling its own POST, sending through Resend on Cloudflare — including the DNS and the steps nobody writes down.

For an Astro project with an adapter, so it can render a page on request. If your site is static — Hugo, 11ty, Jekyll, or Astro with no adapter — you want A contact form in a static site instead.

This guide is Resend and Cloudflare, start to finish. Other combinations — Netlify, Vercel, a different sending service — work on the same principles but differ in enough small ways that following a hedged guide is worse than following none. Those will get their own pages, repeating whatever needs repeating.

Building the form is the easy half: FEWD does the markup. This is the other half, and most of it is DNS and dashboards rather than code, which is exactly why it feels harder than it is.

At the end you will have one page that shows a form, receives its own POST, and emails you the result.


Before you start

Roughly half an hour, most of it waiting for DNS. You need the domain your site is on and access to wherever its DNS lives, plus a Resend account — the free tier is 3,000 emails a month and 100 a day, which no contact form will trouble.

Three things worth knowing, because they are what makes this feel riskier than it is:

Nothing here touches your existing email. Everything Resend asks for is a new record — by default an MX and an SPF entry on a send. subdomain, and a DKIM key at resend._domainkey. None of them is your root MX, which is the record that delivers mail to you now, and nothing you add replaces anything that is already there. If mail arrives at your domain today it carries on exactly as before.

All of it is reversible. Delete three DNS records and the file you made, and you are back where you started. Nothing is migrated and nothing is overwritten.

You cannot break the live site by trying. The contact page is a new page. Until you link to it, the only person who can find it is you.

And you do not need the domain pointing at the new site to finish this — see testing before the site is live once you have read the steps.

Work through the steps in order. Each one ends with how to tell it worked, so you are never guessing whether to carry on.


1. Verify your domain in Resend

Add your domain at resend.com — the root, example.com, not a subdomain. Resend then gives you three DNS records to add. Add a root domain and they come out like this:

TypeNameValue
MXsendfeedback-smtp.<region>.amazonses.com
TXTsendv=spf1 include:amazonses.com ~all
TXTresend._domainkeya long DKIM key

Add all three in Cloudflare → your domain → DNS.

Set them to DNS only — the grey cloud, not orange. Proxying a mail record breaks it, and Cloudflare defaults some records to proxied, so check each one.

Press Verify in Resend. Usually minutes.

Two of those sit on a send. subdomain on purpose: it keeps Resend’s SPF off your root, where it cannot collide with anything else receiving mail.

You will know it worked when the domain shows as Verified in Resend. If it sits on Pending for more than ten minutes, the usual cause is a record left proxied — go back and check each one is grey, not orange.

2. Get an API key

Resend → API Keys → Create. Sending permission is enough.

Copy it now. It is shown once and never again.

You will know it worked when you have a string starting re_ on your clipboard. If you lose it, delete the key and make another — there is no way to see an existing one, and no harm in replacing it.

3. Declare it in your Astro config

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

export default defineConfig({
  env: {
    schema: {
      RESEND_API_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
      CONTACT_FROM:   envField.string({ context: 'server', access: 'public', optional: true,
                                        default: 'Your Site <hello@example.com>' }),
      CONTACT_TO:     envField.string({ context: 'server', access: 'public', optional: true,
                                        default: 'you@example.com' }),
    },
  },
});

CONTACT_FROM must be at the domain you verified in step 1. It does not need to be a real mailbox — nothing has to receive there.

You will know it worked when npm run dev starts without complaining. A mistake here is a startup error naming the field, not a silent failure.

4. Put the key where git cannot reach it

Two small files, both made the same way. In FEWD, open the project panel’s Files & Folders tab and use File+ at the bottom to create a file in your project root. Dot-files show in that tree like any other, so you will be able to see both afterwards.

The key itself

Create .dev.vars, and open it in a text panel — the Add text/file panel button on the toolbar — then put one line in it:

RESEND_API_KEY=re_your_key_here

This is the copy your machine uses while you are working. The deployed site never sees it; that key goes in the host’s dashboard at step 7.

Telling git to ignore it

.gitignore is a plain list of things git should pretend are not there. One path per line. Anything matching a line in it is never committed, never pushed, and never appears on GitHub.

Look in the file tree first — you probably already have one. Astro creates a .gitignore when it scaffolds a project, as do most starters.

  • If it is there, open it in a text panel and add .dev.vars on its own line at the end.
  • If it is not, make one with File+, named exactly .gitignore — leading dot, no extension — and put .dev.vars in it as the only line.
.dev.vars

Save it. Order does not matter and neither does where in the file it goes.

Check it worked

Git only ignores a file it has not already been told to track, so it is worth confirming rather than assuming. In FEWD’s terminal:

git status

You should see .gitignore listed as changed, and .dev.vars should not appear anywhere. If it does appear, it is not being ignored — check the spelling, and that the file is really called .gitignore rather than gitignore or .gitignore.txt.

A key that reaches GitHub has to be revoked in Resend and replaced, even in a private repository. Getting this right first is much less work than undoing it.

You will know it worked when git status shows .gitignore as changed and does not mention .dev.vars at all.

5. Build the page

In FEWD, open the Forms tab in Inserts — or type /astro-contact — in a new .astro page.

That is the whole thing in one file: the POST handler, the fields, the honeypot and the error states.

What to change

Four things, and the file names them at the top too.

1. The subject line — what you will see in your own inbox, so make it say which site it came from.

subject: 'Website contact form',      // →  subject: 'Acme — website enquiry',

2. The three fallback addresses. These appear to the visitor when sending fails, so they are a way to reach you when the form cannot. Use the address you show publicly.

error = 'The form is not connected yet. Please email you@example.com instead.';

There are three of them — one for a missing key, two for Resend failing. Change all three, or search for you@example.com and check none is left.

3. Where it sends the visitor after sending, if your thank-you page is not at /thanks/. Twice in the file.

return Astro.redirect('/thanks/');    // →  '/thank-you/'

4. Who it emails, and from where — but not in this file. Those are the CONTACT_FROM and CONTACT_TO defaults you set in astro.config.mjs back at step 3:

ExampleWhere it comes from
CONTACT_FROMAcme <hello@acme.com>must be at the domain you verified in Resend at step 1. It does not need to be a real mailbox
CONTACT_TOchris@acme.co.ukan address you actually read. It does not have to be on the same domain

The email’s layout — the html: block — is ordinary HTML and yours to change if you want it to look like something.

Why the first line matters

export const prerender = false;

That is what makes this one page render on request instead of being built as static HTML — which it must do, because it has to be there to receive the POST when someone presses Send. Every other page on your site stays static.

And a page to land on

The form redirects to /thanks/ once it has sent, so you need one. It is an ordinary static page — no prerender line, nothing special about it at all — and it is one of the pages the paragraph above is talking about.

You will know it worked when the form appears in the preview and you can type in it. It will not send yet — that is step 7.

6. The honeypot

The insert includes one. Worth knowing what it is, because it looks like a mistake:

<div class="hp" aria-hidden="true">
  <label for="company">Company</label>
  <input id="company" name="company" type="text" tabindex="-1" autocomplete="off" />
</div>
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

A field named plausibly, hidden from people, left empty by them and filled in by anything crawling the markup.

Three details do the work. Off-screen rather than display: none, because some bots skip what is not rendered. tabindex="-1" and aria-hidden, so keyboard and screen-reader users never reach it. And when it is tripped the form answers as though it worked and sends nothing — show an error and the bot learns what tripped it.

No CAPTCHA, nothing for a real person to do.

7. Add the key to Cloudflare

Cloudflare → your Worker or Pages project → Settings → Variables and Secrets.

Add RESEND_API_KEY as a Secret, not a Variable.

This is the one that catches people. A Variable set in the dashboard is wiped by the next deploy; a Secret survives. Everything works, you deploy once more, and the form quietly stops sending with nothing obviously changed.

CONTACT_FROM and CONTACT_TO are not secret and can live in wrangler.jsonc.

You will know it worked when the deploy log lists RESEND_API_KEY among the bindings. If it is absent, it was added as a Variable rather than a Secret, or to a different project.

8. Test it

Locally, npm run dev, and send yourself one. The terminal logs which source the key came from and what Resend answered:

[form] key read from astro:env; Resend responded 200

Then deploy and do it again in production. Test both — the local and deployed paths read the key differently, and only one of them is the one your visitors use.

You will know it worked when an email arrives and the browser lands on your thanks page. That is the whole thing working end to end.

Testing before the site is live

You do not need the domain pointing at the new site to finish and prove the form. This is the part that surprises people, and it changes how you plan the work.

Resend does not care where the request came from. It checks two things: your API key, and whether the domain in your from: address is verified. A site sitting on a temporary address, sending from: hello@clientdomain.com, sends exactly as it will on the day — as long as clientdomain.com is verified.

And verifying it does not disturb the site that is live now. The three records from step 1 are all new, and none of them is the root MX. The old site and its email carry on while you work.

You already have a temporary address. Every Cloudflare Pages project gets a free <project>.pages.dev. No domain to buy and nothing to configure.

So the order that works:

  1. Build locally. The form, the validation, the redirects and the honeypot all work with no Resend account at all — with no key set, a submission falls through to the error redirect, which is itself worth watching happen.
  2. Verify the domain in Resend whenever you like, even months before launch, even while the old site is still serving.
  3. Push to <project>.pages.dev and send yourself a real one. This is the genuine test: the deployed runtime, the real key, the real Resend call.
  4. Point the domain at the new site when you are ready. The form does not change. It was already sending from the right address.

The form is finished and proven before go-live, rather than being the thing you discover is broken on the day.

What you have now

A page that renders on request, validates what was typed, turns away bots without asking anyone to prove they are human, emails you the result, and replies to the sender when you hit Reply. Your key is on your machine and in your host, and in neither case in your repository.

The same page is the pattern for any other form — a quote request, a booking, a newsletter sign-up. The fields change; nothing else does.


When it does not work

“The form is not connected yet.” The key is not readable. Check it is a Secret rather than a Variable, and that the name matches exactly.

Resend answers 403. The domain is not verified, or CONTACT_FROM is at a domain you have not verified.

Resend answers 422. Usually a malformed from — it wants Name <address@domain> or a bare address, nothing else.

It works locally and not in production. Almost always step 7.

Nothing arrives, no error. Check spam, then check CONTACT_TO is an address that actually receives.