Skip to content

A contact form in a static site

Two files — the page, and a function in your repo that the host builds — sending through Resend from Hugo, 11ty, Jekyll or plain HTML.

For a site built as plain files — Hugo, 11ty, Jekyll, hand-written HTML, or Astro with no adapter. If your Astro project has an adapter, you want A contact form in Astro instead: it needs one file rather than two.

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.

What generated the site does not matter here. The host does. This route needs a host that runs functions, and this guide uses Cloudflare Pages. If your site is somewhere that runs nothing at all, skip to the last section.

At the end you will have a page with a form, and a small function in your repo that receives it and emails you.


The shape of it

Two files:

  • /static-contact — the page. Ordinary HTML, posts to /api/contact.
  • /api-contact — the function that receives it. Lives in a folder in your repo, which the host builds and runs.

The endpoint is in your files. The key is not. Nothing in the browser ever sees it — that folder becomes a function on the host, and the key lives in the host’s settings. A Resend key in client-side JavaScript would be read out of view-source and used to send spam from the domain you just verified.


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, a host that runs functions, and 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 two files you made, and you are back where you started.

You cannot break the live site by trying. Both files are new. Until you link to the contact page, 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 at your DNS host. On Cloudflare, set them to DNS only — the grey cloud, not orange. Proxying a mail record breaks it.

Press Verify in Resend. Usually minutes.

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. Add the page

In FEWD, open the Forms tab in Inserts — or type /static-contact.

That is a complete page: head, the fields, the honeypot and its styles. It posts to /api/contact, which does not exist yet.

You also want a /thanks/ page for it to redirect to.

It works with JavaScript off. The browser posts, the function answers with a redirect, the visitor lands on the thanks page. No fetch, no client-side anything.

You will know it worked when the page shows the form and you can type in it. Pressing Send will fail for now — there is nothing at /api/contact until the next step.

4. Add the endpoint

Make the file with File+ in the project panel, at the path your host expects (below), then open it in a text panel — the Add text/file panel button on the toolbar — and type /api-contact followed by Enter. The Forms tab in Inserts has it too, if you would rather pick it from a list.

It goes at functions/api/contact.js. Cloudflare turns everything in functions/ into routes, so that path becomes /api/contact — which is what the form already posts to. The folder does not exist in a new project; make it.

export async function onRequestPost(context) {
  const { request, env } = context;
  …
}

onRequestPost runs on a POST and nothing else. context.env holds the environment — variables, secrets and bindings. There is no process.env here, and the key is only readable inside a request, which is why the code reads it where it does.

What to change in the endpoint

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

1. Who it sends from. Must be at the domain you verified in Resend at step 1. It does not need to be a real mailbox — nothing has to receive there.

from: env.CONTACT_FROM || 'Your Site <hello@example.com>',
//                     →  'Acme <hello@acme.com>'

2. Who it sends to. An address you actually read. It does not have to be on the same domain as the site.

to: [env.CONTACT_TO || 'you@example.com'],
//                  →  'chris@acme.co.uk'

Both have an env. in front of them. That is the override: set CONTACT_FROM and CONTACT_TO as ordinary variables in your host’s dashboard and those win, which is how you point a staging copy somewhere else without editing code. The text after || is what it falls back to, and that is the bit to change.

3. 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',

4. The two addresses it redirects to, if your pages are not at those URLs:

In the fileTimesWhat it is
'/thanks/'2where a visitor lands after sending
'/contact/?error=1'4back to the form, when something was wrong

Check the second one. It assumes your form is at /contact/. If it is at /get-in-touch/ or /, then every validation failure — a missing name, a bad email address — sends the visitor to a page that does not exist, and it will only happen to people who made a mistake, which is exactly who you will not hear from.

The email’s layout — the html: block — is ordinary HTML and yours to change.

And in the page

/static-contact has three:

  1. <title> and the <h1> — yours.
  2. <link rel="stylesheet" href="/styles.css" /> — point it at your own stylesheet, or delete it and keep the honeypot <style> block.
  3. action="/api/contact" — leave it. It already matches where step 4 puts the endpoint.

You will know it worked when the file sits at the path your host expects and the form’s action matches it. Nothing will send yet; the key comes next.

5. The honeypot

Both inserts include 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 function 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.

6. Add the key to your host

Add RESEND_API_KEY as a Secret, not a plain variable.

On Cloudflare that is → your Pages project → Settings → Variables and Secrets. 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 be ordinary variables. CONTACT_FROM must be at the domain you verified in step 1, and does not need to be a real mailbox.

Testing locally

For Cloudflare Pages, the key goes in a file called .dev.vars at the project root and you run npx wrangler pages dev.

Make it in FEWD: project panel → Files & Folders → File+, named .dev.vars, then open it in a text panel — the Add text/file panel button on the toolbar — and put one line in it:

RESEND_API_KEY=re_your_key_here

Then tell git to ignore it, before you commit anything.

.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 and never reaches GitHub. Look in the file tree first — most starters already include one, and dot-files show there like any other file.

  • If it is there, open it in a text panel and add .dev.vars on its own line.
  • If it is not, make one with File+, named exactly .gitignore, holding that one line.
.dev.vars

Check it with git status in FEWD’s terminal. You should see .gitignore as changed, and .dev.vars should not appear at all. If it does, check the spelling — gitignore or .gitignore.txt will not work.

A key that reaches GitHub has to be revoked in Resend and replaced, even from a private repository.

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

7. Test it

Send yourself one locally, then deploy and do it again in production. Test both — only the deployed path is the one your visitors use, and it is the one where the Secret matters.

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 static site with one function in it. The page 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 — with no JavaScript required in the browser at all.

Your key is in your host and nowhere else. The endpoint is in your repository; the credential never was.

The same pair 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

404 on submit. The function is not where the host expects it. Check the path table in step 4, and that the form’s action matches what it becomes.

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 6.

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


No server at all

GitHub Pages, S3, plain hosting — nothing runs, so there is nowhere to put step 4.

Use /hosted-contact instead: the same form with its action pointed at Formspree or Web3Forms. The form ID in the markup is public by design and is not a secret. Steps 1, 2, 4 and 6 all disappear.

You lose control of how the email looks and add a third party to your stack. For a small site that is often the right trade.