Cloudflare Pages contact form
Formgong team ·
A Cloudflare Pages contact form can post from the browser. Paste the HTML below and set the action to Formgong. You do not need a Pages Function or an email API. Telegram is instant. On the free plan, email is a daily digest the next morning at 08:00.
HTML form for a static Pages site
Put this form in the folder Cloudflare Pages publishes. A plain HTML site often uses public or the repo root.
The action posts straight to Formgong. Pages only serves the file. It does not receive the lead.
Each label wraps its field. Required and autocomplete stay on the inputs. The button is a submit button.
Leave botcheck empty. It is hidden from the page, but it is not display:none. A filled honeypot is stored as spam.
The access key is public. It can submit this form. It cannot read the inbox.
On the free plan, email is a daily digest to one address. It goes out the next morning at 08:00 in your time zone and covers the previous day.
Telegram is instant on every plan, including Free. Pro and Business email each lead. There is no auto-reply on Free.
<!-- public/index.html or contact.html. This form works if JavaScript is off. -->
<form action="https://formgong.com/submit" method="POST">
<input type="hidden" name="access_key" value="fk_your_access_key">
<input type="hidden" name="_lang" value="en">
<label for="name">Name
<input id="name" type="text" name="name" autocomplete="name" required>
</label>
<label for="email">Email
<input id="email" type="email" name="email" autocomplete="email" required>
</label>
<label for="message">Message
<textarea id="message" name="message" required></textarea>
</label>
<div aria-hidden="true" style="position:absolute;inset-inline-start:0;top:0;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)">
<label>Leave this empty
<input type="text" name="botcheck" tabindex="-1" autocomplete="off">
</label>
</div>
<button type="submit">Send</button>
<p role="status"></p>
</form>Stay on the page with fetch
<script>
const form = document.querySelector("form[action$='/submit']");
const status = form.querySelector("[role=status]");
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!form.reportValidity()) return;
const button = form.querySelector("button");
button.disabled = true;
status.textContent = "";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
const ok = response.ok && result.success;
status.textContent = result.message || (ok ? "Thanks. We have your message." : "Could not send. Try again.");
status.setAttribute("role", ok ? "status" : "alert");
if (ok) form.reset();
} catch {
status.textContent = "Could not send. Check your connection and try again.";
status.setAttribute("role", "alert");
} finally {
button.disabled = false;
}
});
</script>Thank-you page on your own site
<input type="hidden" name="_redirect" value="https://example.pages.dev/thanks.html">
Pages Function, Workers email, and MailChannels
A Pages Function is the path in the Cloudflare tutorial. The sample reads the form and returns JSON. It does not send email.
To send mail from that function you add an email API. Workers cannot open an SMTP port. The API key is a secret in the Pages project.
The From address has to be a domain you control, with the DNS records that provider asks for.
MailChannels ended the free email API for Cloudflare Workers on 30 June 2024. After that date the API stopped accepting those requests.
Domain Lockdown by Cloudflare Worker id is unsupported as of 31 August 2024. Old tutorials that skip an account still show that path.
The current MailChannels Email API is a separate product. Its free developer plan is 100 emails a day, reset at 00:00 UTC.
Until you add a payment method, that plan only sends to verified users on the MailChannels account. It is not the old keyless Workers integration.
Cloudflare Email Service, checked on 6 October 2026, does not send to arbitrary recipients on Workers Free.
Workers Paid includes 3,000 outbound emails a month, then 0.35 dollars per 1,000. The Workers Paid minimum is 5 dollars a month.
Sends to verified destination addresses are free on every plan, and they do not count toward that quota. You still send from a routing domain.
Pick a Function when the submit must change your database or call a private API. Pick the form above for a contact form.
| Your Pages Function | Formgong | |
|---|---|---|
| What you add | A file at functions/api/contact.js that exports onRequestPost, plus an email API. | One public access key in the HTML form. |
| Secrets and DNS | The API key stays in the Pages project. The From address needs DNS on a domain you own. | No secret and no DNS. Mail is sent from noreply@formgong.com. |
| MailChannels | The free Workers API stopped accepting requests on 30 June 2024. Worker-id lockdown ended on 31 August 2024. | Not used. You do not create a MailChannels account. |
| Email from your account | Cloudflare Email Service: arbitrary recipients need Workers Paid. Verified destinations are free. | Formgong sends the mail. Your Cloudflare account does not. |
| When email arrives | When your provider accepts it. You own bounces and the template. | Free email is a daily digest at 08:00 the next morning. Pro and Business email each submission. |
| Telegram | You build it. | Instant on every plan, including Free. |
| Workers quota | Each Function call counts. Free is 100,000 requests a day, with 10 ms of CPU. | The browser posts to Formgong. Your Pages Function does not run. |
| Spam | You build the check, or you add Turnstile and verify the token in the Function. | botcheck is always checked. Turnstile is optional. The secret stays in Formgong. |
| Where data sits | Wherever you store it: the email inbox, KV, or your own D1. | In the EU, on Cloudflare D1. Free retention is 30 days. |
Checked 6 October 2026. MailChannels: the termination blog and the Domain Lockdown docs, plus the Email API pricing page. Cloudflare: Email Service pricing, last updated 9 June 2026, and Workers pricing, last updated 2 October 2026. Formgong limits are the plan numbers in the product code.
Shape of a Pages Function, if you write one
// functions/api/contact.js
// Optional. The HTML form on this page does not use this file.
// This sketch does not send mail. Copying it will not deliver a lead.
export async function onRequestPost(context) {
const fields = await context.request.formData();
const email = String(fields.get("email") ?? "");
// Workers cannot open an SMTP socket.
// A real function calls an email API with a secret from context.env.
// You still need a From address on a domain you control.
return new Response("Add an email API before this can send. Saw " + email, { status: 501 });
}Static Forms plugin
The Static Forms plugin is a Pages Function. A form with data-static-form-name is intercepted on your project.
The official example, last updated 21 April 2026, replies with a hello message. It does not send email and it does not store an inbox.
You still write the respondWith function, and each submit counts as a Function request.
The HTML form on this page does not use that plugin. Pages does not have to run code for the lead.
Turnstile on the same Cloudflare account
Turnstile is optional. Add it only after you turn it on for the form, under Protection in Formgong.
Create the widget in the Cloudflare dashboard, on the same account as the Pages project if you like. Paste the site key into the form.
The secret key stays in Formgong. Do not put it in a Pages environment variable for this form.
The widget adds cf-turnstile-response. FormData sends that field. A JSON body must copy it yourself.
If Turnstile is on and the token is missing, the lead is refused. The error code is turnstile_missing.
Turnstile widget
<!-- Load once, in the page head. The site key is public. -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<!-- Inside the form, after the message field. -->
<div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY" data-language="en"></div>
<!-- Keep the fetch body as FormData. Do not set Content-Type.
The widget writes cf-turnstile-response, and FormData sends it. -->Astro on Cloudflare Pages
An Astro contact form on Pages can be the same HTML. Save it as a component and render it on the contact page.
Use a static build. Set the Pages output directory to dist. The browser still posts to Formgong.
Do not add a Cloudflare server adapter only for this form. An adapter is for a site that already runs on the server.
The same fields work in a React island if you want to stay on the page. The plain form is the path with JavaScript off.
ContactForm.astro
---
// src/components/ContactForm.astro
// A static build. No Cloudflare adapter and no Pages Function.
---
<form action="https://formgong.com/submit" method="POST">
<input type="hidden" name="access_key" value="fk_your_access_key">
<input type="hidden" name="_lang" value="en">
<label for="name">Name
<input id="name" type="text" name="name" autocomplete="name" required>
</label>
<label for="email">Email
<input id="email" type="email" name="email" autocomplete="email" required>
</label>
<label for="message">Message
<textarea id="message" name="message" required></textarea>
</label>
<div aria-hidden="true" style="position:absolute;inset-inline-start:0;top:0;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)">
<label>Leave this empty
<input type="text" name="botcheck" tabindex="-1" autocomplete="off">
</label>
</div>
<button type="submit">Send</button>
</form>astro.config.mjs for a static site
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
output: "static",
});
// Pages build settings: output directory dist.
// Do not add a server adapter only to host this form.Common errors
These are the failures that show up after the form looks finished.
The form posts to the page
With no action, the browser sends the fields to the page URL. Pages has no form inbox, so the lead is lost.
Set the action to the Formgong submit URL. Deploy again and send one test from the live address.
405 from a Pages Function
Export onRequestPost for a POST form. onRequest alone can answer 405 when the method does not match.
The HTML form on this page does not call a Function, so this error does not apply to it.
A Worker route takes the POST
A route on the whole domain can run before Pages serves the file. The form then works on pages.dev and fails on the custom domain.
Check Workers routes. Narrow the pattern, or let paths that are not your API fall through to the site.
Wrong build output
The Cloudflare forms tutorial publishes the public folder. Astro writes dist. Hugo writes public. A Next static export writes out.
If the build succeeds and the form URL is 404, the output directory in the Pages project does not match the folder you built.
Preview and Production variables
A Pages Function secret set only for Production is missing on a preview deploy, and the other way around.
The access key in the HTML form is not a secret. You do not put it in those variables.
An old MailChannels sample
Guides written before 30 June 2024 call the free Workers API with no account. That API no longer accepts the request.
A 401 from MailChannels is that shutdown, not a bad Pages deploy. Use an email API you hold an account for, or the form on this page.
Content-Type on FormData
If you set Content-Type yourself, the browser cannot add the multipart boundary. The server then cannot read the fields.
Send Accept: application/json and leave Content-Type unset. Use a JSON body only when you build the object yourself.
CSP form-action and connect-src
A plain form POST needs form-action to allow https://formgong.com when the page sets that directive.
fetch is blocked unless connect-src allows the same host. Turnstile also needs the Cloudflare challenges host in script-src and frame-src.
Allowed domains
The submit route allows any browser origin on Free, including localhost and a pages.dev host.
On Pro you can limit the form to your domains. If that list is on and the host is missing, the lead is refused. Add the pages.dev host and the custom domain.
Double submit
Each successful POST is a new lead. The fetch sample disables the button while the request runs.
A plain HTML form has no script, so a second click can send twice. That is the trade for a form that works with JavaScript off.
Thanks before the response
Show thanks only after response.ok and result.success are both true. A network error and a 400 are not a sent lead.
Read result.message for the reason. turnstile_missing means Turnstile is on and the token was not posted.
The thank-you URL is another site
A page-supplied redirect must stay on the same site as the form, or on a host you allowed. An off-site URL is dropped.
That stops the endpoint being used as an open redirect. Put the thank-you page on your pages.dev host or on your domain.
Honest limits
Free stores 300 submissions a month and keeps them for 30 days. One verified address gets the daily digest. There is no auto-reply on Free.
A lead scored as spam is stored and is not emailed and is not sent to Telegram. Open the inbox if a test seems missing, then check spam for noreply@formgong.com.
Cloudflare Pages Free, checked on 6 October 2026, allows 500 builds a month and one build at a time. A build times out after 20 minutes.
A form submit is not a build. A browser POST to Formgong is also not a Pages Function request, so it does not use your Workers quota.
A Function you write does count. Workers Free is 100,000 requests a day and 10 ms of CPU per invocation.
Formgong stores the lead in the EU on Cloudflare D1. Your Pages project does not need its own D1 table for this form.
File uploads are a separate setting. They need a verified email and Turnstile, and they are not part of the sample on this page.
The same job on any static host is a static website contact form. A longer comparison of the ways to collect a lead is form without a backend. The fetch pattern, including a JSON body, is on the JavaScript contact form. Quotas are on the pricing page.
How forms work in Cloudflare Pages
Cloudflare Pages serves static files. It does not scan your HTML for a form. A POST to the page URL has nowhere to go, so the lead is lost.
A file at functions/api/contact.js can read that POST. Sending mail still needs an email API, a secret, and DNS on a domain you own. The form below skips that work.
Set it up in Cloudflare Pages
- Create a form in Formgong and copy the access key. It starts with
fk_. - Paste the HTML into the folder Pages publishes, such as
public/index.htmlorcontact.html. Setactiontohttps://formgong.com/submit. - Replace
fk_your_access_key. Leavebotcheckempty. Add Turnstile only after you turn it on under Protection. - Deploy from Git, or run
wrangler pages deploy. Send a test from the livepages.devaddress. - On the free plan, email is a daily digest at 08:00 the next morning. Telegram is instant. Pro and Business email each submission.
Formgong or a Pages Function?
Write a Pages Function when the submit must change your own database or call a private API.
Use Formgong for a contact form. You skip the email secret, the DNS records, and a function to keep. The site stays a folder of static files.
Questions and answers
Does Cloudflare Pages include a contact form?
No. The official tutorial builds a Pages Function that echoes the fields as JSON. It does not send email or store an inbox. You still need a form backend, or your own function plus an email API.
Do I need a Pages Function or a Worker?
Not for this form. The browser posts to Formgong. Use a function when the submit must run your own code. The old free MailChannels path for Workers stopped on 30 June 2024.
Can the visitor stay on the page?
Yes. Call fetch on https://formgong.com/submit with Accept: application/json. Treat it as sent only when response.ok and result.success are both true. The HTML form still works if JavaScript is off.
How do I add Turnstile on the same account?
Create a Turnstile widget in the Cloudflare dashboard and paste the site key into the form. Put the secret in Formgong, under Protection, not in the Pages project. The widget posts cf-turnstile-response.
What does the free plan include?
Free stores 300 submissions a month. Telegram is instant. Email is one daily digest at 08:00 the next morning, in your time zone. Pro and Business email each submission.
Articles on this topic
Related pages
Guides
- Formgong for agencies
- Static website contact form
- Netlify Forms alternative
- GDPR-compliant contact form
- Formspree alternatives
- Web3Forms alternatives
- EmailJS alternatives
- Formspree vs Web3Forms
- Formspree vs EmailJS
- Best free form backends
- Free website templates
- Testimonial and rating widget
- Craftline: plumber website template
- Orrery: consulting website template
- Lattice: portfolio website template
- Lattice Estate: home builder website template
- Shoal: real estate agent website template
- Lattice Atelier: windows and doors website template
- Soglia: designer windows website template
- Halftone: architecture studio website template
- Ocra: shop and maker website template
- Arcwell: interior design studio website template
- AULA: premium interior design website template
- AI builder prompts
- Form checker
- Agent rules files
- Tools