Contact form on a static site

Contact form on a static site6 min read

Add a contact form to a Cloudflare Pages site

Add a contact form to Cloudflare Pages without writing a Function: post to a form backend, set headers in _headers and use your own Turnstile keys.

Preet SutharFounder, Ikiform

Static assets on Cloudflare Pages answer a POST with 405, so either write a Pages Function or post the form to a form backend. With Ikiform you write no Function: set the form's action to your endpoint, add your-project.pages.dev to Allowed domains, which also covers every preview URL, and list Ikiform in your _headers Content-Security-Policy. If you already use Cloudflare, you can plug your own Turnstile keys into the form.

What's different on Cloudflare Pages

Pages serves your build output from Cloudflare's network. Static assets answer GET; a POST to one gets 405 with no body:

Response to a POST
HTTP/2 405
server: cloudflare
content-length: 0

Pages can run code, through Pages Functions in a functions/ folder. A functions/contact.ts could accept the post, but then you're writing a backend: parsing, validation, an email provider, spam filtering and storage. Cloudflare's own docs also say to start new projects on Workers rather than Pages. Posting to a form backend sidesteps both, and the same form works unchanged if you move to Workers static assets later.

Pages traitWhat it means for the form
Preview URLs are subdomains, such as abc123.your-project.pages.devOne allowed domain, your-project.pages.dev, covers production and every preview. Vercel and Netlify previews need their own entries
Headers come from a _headers file in the build outputUp to 100 rules, 2,000 characters per line. They don't apply to responses from Pages Functions
Turnstile is a Cloudflare productYou can use Ikiform's managed check with no keys, or your own widget from the Cloudflare dashboard
A contact form on Cloudflare Pages with your own Turnstile keyspage onpages.devservedwith_headersTurnstilewidgetyour sitekeyIkiformverifieswith yoursecretstoredandemailed403captcha_failedreset thewidgettokenPOST + tokenbad or used tokenA contact form on Cloudflare Pages with your own Turnstile keyspage on pages.devserved with _headersTurnstile widgetyour site keyIkiformverifies with yoursecretstored andemailed403captcha_failedreset thewidgettokenPOST + tokenbad or used token
  1. Pages serves the contact page, with the Content-Security-Policy from your _headers file.
  2. The Turnstile widget runs with your site key and puts a token in the form.
  3. The form posts its fields and the token to Ikiform.
  4. Ikiform verifies the token with your secret key and checks the token's hostname against your allowed domains.
  5. A valid post is stored and emailed. A missing, expired or reused token gets 403 captcha_failed, and the page resets the widget.
With Ikiform's managed check instead, there is no widget on your page; the HTML form needs no change at all.

The form

This version uses your own Turnstile widget, which makes sense when you already manage Turnstile in your Cloudflare account. In the form's settings, set Spam check to Your own Turnstile keys and paste the site key and secret. The widget adds a cf-turnstile-response field to the form, and the team select tells you who should answer:

public/contact.html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="team">Who should read this?</label>
  <select id="team" name="team">
    <option value="support">Support</option>
    <option value="sales">Sales</option>
    <option value="press">Press</option>
  </select>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" minlength="10" required></textarea>

  <div class="cf-turnstile" data-sitekey="0x4AAAAAAAxxxxxxxxxxxxxx"></div>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send</button>
  <p id="contact-status" role="status"></p>
</form>

<script src="/contact.js" defer></script>

The script keeps visitors on the page. A Turnstile token is single use, so after any failed send it resets the widget for the next attempt:

public/contact.js
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  status.textContent = "Sending";
  const response = await fetch(form.action, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  }).catch(() => null);
  const result = await response?.json().catch(() => null);

  if (result?.ok) {
    form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Sent. We'll reply by email." }));
    return;
  }

  window.turnstile?.reset();
  if (result?.error?.code === "captcha_failed") {
    status.textContent = "The human check expired. Complete it again, then send.";
    return;
  }
  const fields = result?.error?.fields ?? {};
  status.textContent = Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
});
  • In the Turnstile widget's settings in Cloudflare, list the hostnames the form runs on. Ikiform also checks the token's hostname against the form's Allowed domains.
  • Posts without a valid token are refused with 403 captcha_failed. That includes a plain HTML post sent before the widget finished.
  • Prefer no keys at all? Set Spam check to Ikiform, delete the widget and its script, and the HTML post runs the check on ikiform.com. The fetch version then needs challenge() from the SDK; see the human check.

Your keys or Ikiform's check

ItemIkiform's checkYour own Turnstile keys
Keys to manageNoneA site key and secret from your Cloudflare dashboard
Change to the HTML formNone. The check runs on an ikiform.com pageAdd the widget and its script
fetch versionCall challenge() from the SDKSend the widget's cf-turnstile-response
Content-Security-PolicyOnly form-action and connect-src for IkiformAlso challenges.cloudflare.com in script-src and frame-src
Turnstile analyticsNot in your accountIn your Cloudflare dashboard, next to your other widgets

Ikiform's check is less to set up. Your own keys make sense when you already watch Turnstile analytics, or want the widget's look and placement under your control.

Testing the widget locally

Cloudflare publishes testing site keys and secrets that always pass or always fail, such as the always-pass pair 1x00000000000000000000AA and 1x0000000000000000000000000000000AA. Production secrets reject tokens made with a testing key, so don't mix them on one form. Create a second Ikiform form for local work, give it the testing key pair, and switch the data-sitekey and form id together. Leave that form's Allowed domains empty: Ikiform only checks a token's hostname when the list is set, and Cloudflare doesn't document which hostname a testing token reports.

The _headers file

Put _headers in your static asset directory, such as public/, so it ends up in the build output. Turnstile needs script-src and frame-src for https://challenges.cloudflare.com, and Ikiform needs form-action and connect-src:

public/_headers
/*
  Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com

If you later add a Pages Function to the same project, its responses don't get these headers. Set them in the Function's code.

If you move to Workers

Moving the site to Workers static assets changes how it deploys, not how the form works. The form still posts to ikiform.com, so nothing on Cloudflare's side handles it. Check two things after the move: the new workers.dev host or route is under Allowed domains, and your headers still reach the page, since Workers has its own way of setting them.

Add your-project.pages.dev and your custom domain under Allowed domains. Never add pages.dev on its own: every Pages project would match.

Check it works

  1. Push a branch and open its preview URL. Submit, and confirm the message arrives and isn't marked spam: the preview is a subdomain of your project's host.
  2. Submit again on production, then press back and send the same form a second time without completing the widget. Expect the reset message, not a stored submission.
  3. Open Submissions in the dashboard. The new submission is there with every field you sent.
  4. Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an email field.
  5. Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.

Gotchas

ProblemWhat to do
405 with an empty bodyThe form posted to a Pages URL. Check the deployed action, and that no Function or redirect rule rewrites /contact.
The Turnstile widget never appearsThe Content-Security-Policy blocks challenges.cloudflare.com. Add it to script-src and frame-src, and check _headers is in the build output.
403 captcha_failed on every postThe site key and secret come from different widgets, the hostname isn't listed on the widget, or a test secret is paired with a real site key.
403 origin_not_allowed, or every post lands in spamThe page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.