Plainwork

Plainwork / Signup Shield Kit / Static site contact form with a Cloudflare Worker and Resend

A static site contact form with a Cloudflare Worker and Resend

A static site cannot send email by itself, and a mailto: link only opens the visitor's mail program. This page gives you a complete contact form that works on any static host: a form, a short browser script and a 60-line Cloudflare Worker that emails you through Resend. Both services have free plans; check their current limits before you rely on them.

How it fits together

  1. The visitor submits the form on your static page.
  2. A script sends the fields as JSON to your Worker, which lives on a workers.dev address or your own route.
  3. The Worker checks the request came from your site, drops obvious junk, and asks Resend to send you one email. The visitor's address goes in reply_to, so you answer by pressing Reply.

The Worker only ever emails you. It never sends anything to the address the visitor typed, so nobody can use the form to make your domain mail a stranger. That is the main reason to start with a contact form rather than a newsletter form.

Before you start

  • A Cloudflare account and Node 18 or newer, to deploy with npx wrangler.
  • A Resend account and an API key. To send from your own domain, verify the domain in Resend; check their documentation for what an unverified account may send.
  • Three values: the origin of your site (for example https://example.com, no trailing slash), the address that should receive messages, and the sender address on your verified domain.

1. The form

Every field has a real label, the email field has type="email" and autocomplete="email", and the status line is a live region so screen readers hear the result. The extra field named website is a honeypot: people never see it, many bots fill it in.

<form id="contact" novalidate>
  <p><label for="c-name">Name</label><br><input id="c-name" name="name" autocomplete="name"></p>
  <p><label for="c-email">Email</label><br><input id="c-email" name="email" type="email" autocomplete="email" required></p>
  <p><label for="c-msg">Message</label><br><textarea id="c-msg" name="message" rows="6" required></textarea></p>
  <p style="position:absolute;left:-9999px" aria-hidden="true"><label>Leave empty <input name="website" tabindex="-1" autocomplete="off"></label></p>
  <p id="c-status" role="status" aria-live="polite"></p>
  <button type="submit">Send</button>
</form>

2. The browser script

Put this after the form and replace the address with your Worker's. No library is needed.

document.getElementById("contact").addEventListener("submit", async (e) => {
  e.preventDefault();
  const f = e.target, status = document.getElementById("c-status");
  const fail = "Sorry, that did not send. Please email us instead.";
  status.textContent = "Sending...";
  try {
    const res = await fetch("https://contact.YOUR-NAME.workers.dev", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(Object.fromEntries(new FormData(f))),
    });
    status.textContent = res.ok ? "Thanks, your message was sent." : fail;
    if (res.ok) f.reset();
  } catch { status.textContent = fail; }
});

3. The Worker

Save as worker.js. It answers the browser's preflight request, refuses any request whose Origin is not your site, answers "success" to a filled honeypot without sending anything, validates the fields, strips line breaks from the subject so nobody can inject mail headers, and returns an error rather than pretending when Resend refuses.

// Minimal contact form Worker: Cloudflare Workers + Resend.
// Secrets: RESEND_API_KEY.  Variables: SITE_ORIGIN, TO_EMAIL, FROM_EMAIL.
const json = (body, status = 200, origin = "") =>
  new Response(JSON.stringify(body), {
    status,
    headers: {
      "content-type": "application/json",
      "access-control-allow-origin": origin,
      "vary": "Origin",
    },
  });

export default {
  async fetch(request, env) {
    const origin = request.headers.get("Origin") || "";
    if (request.method === "OPTIONS") {
      return new Response(null, {
        status: 204,
        headers: {
          "access-control-allow-origin": origin === env.SITE_ORIGIN ? origin : "",
          "access-control-allow-methods": "POST",
          "access-control-allow-headers": "content-type",
          "vary": "Origin",
        },
      });
    }
    if (request.method !== "POST") return json({ error: "method" }, 405);
    if (origin !== env.SITE_ORIGIN) return json({ error: "origin" }, 403);

    let data;
    try { data = await request.json(); } catch { return json({ error: "bad_json" }, 400, origin); }
    if (!data || typeof data !== "object") return json({ error: "bad_json" }, 400, origin);

    // Honeypot: pretend success so a bot learns nothing.
    if (data.website) return json({ ok: true }, 200, origin);

    const name = String(data.name || "").trim().slice(0, 100);
    const email = String(data.email || "").trim().slice(0, 254);
    const message = String(data.message || "").trim().slice(0, 5000);
    if (!/^[^\s@,;<>"']+@[^\s@,;<>"']+\.[^\s@,;<>"']+$/.test(email) || !message) {
      return json({ error: "invalid" }, 400, origin);
    }

    const res = await fetch("https://api.resend.com/emails", {
      method: "POST",
      headers: {
        "authorization": "Bearer " + env.RESEND_API_KEY,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        from: env.FROM_EMAIL,
        to: [env.TO_EMAIL],          // always you, never the visitor
        reply_to: email,
        subject: "Contact form: " + (name || email).replace(/[\r\n]+/g, " "),
        text: "From: " + name + " <" + email + ">\n\n" + message,
      }),
    });
    if (!res.ok) return json({ error: "send_failed" }, 502, origin);
    return json({ ok: true }, 200, origin);
  },
};

We ran this exact file against a mocked Resend call. A normal message produced one request; a name containing a line break produced a one-line subject; and a foreign origin, an invalid address, a non-JSON body and a filled honeypot each produced no email. We did not deploy this page's copy to Cloudflare, so test it once with your own account.

4. Deploy

Create wrangler.toml next to the file:

name = "contact"
main = "worker.js"
compatibility_date = "2025-01-01"

[vars]
SITE_ORIGIN = "https://example.com"
TO_EMAIL = "[email protected]"
FROM_EMAIL = "Site contact <[email protected]>"

Then store the key as a secret and deploy:

npx wrangler secret put RESEND_API_KEY
npx wrangler deploy

Wrangler prints the Worker's address. Put it in the browser script, publish the page, send yourself a message, and check the Resend dashboard if nothing arrives.

What this simple version does not do

  • No rate limit. A script that posts to your Worker 10,000 times sends you 10,000 emails and eats your free allowance. The origin check stops browsers on other sites, not a script that sets its own headers, and the honeypot stops only lazy bots.
  • No filtering of the message text. Links and pitches reach your inbox.
  • The reply address is not verified. reply_to may be mistyped or belong to someone else.
  • Nothing is stored. If Resend is down, the message is lost and the visitor sees the failure message, which is why it points to a plain email address.

The usual next steps are a per-address and per-IP limit (this needs somewhere to keep counters, such as a Durable Object), a proof-of-work or Turnstile check, and a daily ceiling. The free proof-of-work tester shows what a work-based check costs on your own device, the form checker reviews your markup, and the guide Stop subscription bombing on a static site's signup form explains the harm a newsletter form can do to strangers.

If you want the rest built and tested

Signup Shield Kit ($15, once) is this idea finished: a Worker with a contact form that carries proof of work and daily caps, per-address and per-IP limits, double opt-in for newsletter and waitlist forms, three sample sites, docs and an attack simulator. You can build it yourself from the pieces above; the kit is the version that is already done and tested.

More from the catalogue

Other small tools

See everything →