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

Published 2026-10-25 · https://ikiform.com/guides/contact-form-static-site/cloudflare-pages

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
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 trait | What it means for the form |
| --- | --- |
| Preview URLs are subdomains, such as `abc123.your-project.pages.dev` | One 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 output | Up to 100 rules, 2,000 characters per line. They don't apply to responses from Pages Functions |
| Turnstile is a Cloudflare product | You can use Ikiform's managed check with no keys, or your own widget from the Cloudflare dashboard |

Diagram: A contact form on Cloudflare Pages with your own Turnstile keys

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`:

```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`:

```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](https://ikiform.com/docs/sdk.md); see [the human check](https://ikiform.com/docs/spam.md#human-check).

## Your keys or Ikiform's check

|   | Ikiform's check | Your own Turnstile keys |
| --- | --- | --- |
| Keys to manage | None | A site key and secret from your Cloudflare dashboard |
| Change to the HTML form | None. The check runs on an ikiform.com page | Add the widget and its script |
| `fetch` version | Call `challenge()` from the SDK | Send the widget's `cf-turnstile-response` |
| Content-Security-Policy | Only `form-action` and `connect-src` for Ikiform | Also `challenges.cloudflare.com` in `script-src` and `frame-src` |
| Turnstile analytics | Not in your account | In 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`:

```text
/*
  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

| Problem | What to do |
| --- | --- |
| `405` with an empty body | The form posted to a Pages URL. Check the deployed `action`, and that no Function or redirect rule rewrites `/contact`. |
| The Turnstile widget never appears | The 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 post | The 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 spam | The 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

### Do I need Pages Functions for a contact form on Cloudflare Pages?

No. A Function is one way to accept the post, but you then write the validation, email sending and spam filtering yourself. Posting to a form backend keeps the project fully static.

### Do preview deployments need their own allowed domain?

No. Pages preview URLs are subdomains of your-project.pages.dev, so allowing that host covers them all.

### Can I use my existing Turnstile widget with Ikiform?

Yes. Set the form's spam check to your own Turnstile keys and paste the site key and secret. Ikiform verifies each token and checks its hostname against your allowed domains.

### Why does my form get 405 on Cloudflare Pages?

It posted to your own Pages URL, which serves static assets that only answer GET requests. Point the form's action at a form backend, or add a Pages Function for that path.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Contact form on a static site](https://ikiform.com/guides/contact-form-static-site.md): Add a working contact form to a static site with no server: where the POST goes, spam, replies by email, and code for every host and framework.
- [Vercel](https://ikiform.com/guides/contact-form-static-site/vercel.md): A static page on Vercel answers a form POST with 405 Method Not Allowed; here is why, and how to send the form to a backend instead.
- [Netlify](https://ikiform.com/guides/contact-form-static-site/netlify.md): Run a contact form on a Netlify site without Netlify Forms: no build-time detection, JSON posts from React, and a separate form for deploy previews.
- [GitHub Pages](https://ikiform.com/guides/contact-form-static-site/github-pages.md): GitHub Pages can't run server code, so a form POST gets 405 Not Allowed; post to a form backend instead and get project-site redirects right.
- [Amazon S3](https://ikiform.com/guides/contact-form-static-site/aws-s3.md): S3 website endpoints accept only GET and HEAD over plain HTTP, so post the contact form to a form backend and get redirects right with CloudFront.
- [Portfolio website](https://ikiform.com/guides/contact-form-static-site/portfolio-website.md): A portfolio contact form that asks the questions you'd ask on a first call: project, budget, timing and a link, with no server and replies from your inbox.
