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.
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:
HTTP/2 405
server: cloudflare
content-length: 0Pages 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 |
- Pages serves the contact page, with the Content-Security-Policy from your _headers file.
- The Turnstile widget runs with your site key and puts a token in the form.
- The form posts its fields and the token to Ikiform.
- Ikiform verifies the token with your secret key and checks the token's hostname against your allowed domains.
- A valid post is stored and emailed. A missing, expired or reused token gets 403 captcha_failed, and the page resets the widget.
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:
<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:
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
fetchversion then needschallenge()from the SDK; see the human check.
Your keys or Ikiform's check
| Item | 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:
/*
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.comIf 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.
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
- 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.
- 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.
- Open Submissions in the dashboard. The new submission is there with every field you sent.
- 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
emailfield. - 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
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.
No. Pages preview URLs are subdomains of your-project.pages.dev, so allowing that host covers them all.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.