The short answer
Cloudflare Pages serves static assets and has no built-in form handling, so you either write a Pages Function (a Worker) to receive the POST or send it to an external endpoint. A Pages Function gives you full control but leaves you owning code, storage and deployment for a contact form. RG Forms is the no-code path: your page POSTs to a Google Apps Script web app in your own Google account and submissions land in your own Google Sheet — and because RG Forms verifies Cloudflare Turnstile tokens server-side, you can pair it with the free Turnstile widget you already have access to.
Option 1: a Pages Function
Drop a file at functions/api/contact.ts and Cloudflare routes POST /api/contact to it. From there you can do anything — validate, rate-limit, write to D1 or KV, call an email API, fan out to a CRM.
The catch is that you now own a small application. You need somewhere to put the data (D1, KV, or an external store), a way to send email (Workers can’t send mail on their own — you’ll be calling a third-party email API with its own key and quota), and a plan for spam. Plus the bindings, the deploy config, and the runtime upgrades.
If your form feeds a real workflow, that investment is correct. If it emails you three times a week, it isn’t.
Option 2: an endpoint you don’t maintain
The alternative is to leave your Pages project purely static and POST from the browser to an endpoint that already handles storage, notification and spam. Your functions/ directory stays empty, your deploy stays a pure asset upload, and there’s no Worker to keep an eye on.
With RG Forms that endpoint is an Apps Script web app inside your own Google account — so “an endpoint you don’t maintain” doesn’t mean “an endpoint someone else owns.”
The integration
Framework-agnostic — works with an Astro, Hugo, SvelteKit-static or plain HTML Pages project.
<form id="contact-form">
<label>Name <input name="name" required /></label>
<label>Email <input type="email" name="email" required /></label>
<label>Message <textarea name="message" required></textarea></label>
<!-- Honeypot: humans never see it, bots fill it in -->
<input type="text" name="_hp" tabindex="-1" autocomplete="off"
style="position:absolute;left:-9999px" aria-hidden="true" />
<button type="submit">Send</button>
<p id="form-status" role="status"></p>
</form>
<script>
const ENDPOINT = "https://script.google.com/macros/s/AKfycb.../exec";
const form = document.getElementById("contact-form");
const status = document.getElementById("form-status");
form.addEventListener("submit", async (event) => {
event.preventDefault();
status.textContent = "Sending…";
const res = await fetch(ENDPOINT, {
method: "POST",
// text/plain avoids the CORS preflight Apps Script can't answer
headers: { "Content-Type": "text/plain" },
body: JSON.stringify({
tab: "contact",
fields: Object.fromEntries(new FormData(form)),
}),
});
const data = await res.json();
status.textContent =
data.result === "success" ? "Thanks — we'll be in touch." : "Something went wrong.";
if (data.result === "success") form.reset();
});
</script>Choosing between them
| If you need… | Go with |
|---|---|
| A contact form that emails you and keeps a record | An external endpoint — there’s nothing here worth a Worker |
| Custom validation, CRM writes, payment logic, rate limiting | A Pages Function |
| Submissions in a spreadsheet your team can filter and annotate | An external endpoint writing to Google Sheets |
| Data that must stay inside Cloudflare (D1/KV/R2) | A Pages Function |
| The same form code across several sites on different hosts | An external endpoint |
Verify the endpoint first
curl -L -X POST "https://script.google.com/macros/s/AKfycb.../exec" \
-H "Content-Type: text/plain" \
-d '{"tab":"contact","fields":{"name":"Ada","email":"ada@example.com","message":"Hello"}}'
# → {"result":"success"} and a new row appears in your Google SheetCommon questions
Does Cloudflare Pages have built-in forms like Netlify?
No. Pages serves static assets; anything dynamic goes through Pages Functions, which you write yourself.
Will an external POST be blocked by Cloudflare’s proxy or WAF?
No. The request originates in the visitor’s browser and goes directly to the endpoint’s domain — it never passes through your Cloudflare zone.
Can I use Turnstile without a Worker?
Yes — that’s exactly what this setup does. The widget runs in the browser and the token is verified inside your Apps Script, so no Worker is involved at any point.
What about Cloudflare Web Analytics on the form page?
Unaffected. Analytics is a separate script and doesn’t interact with the form submission at all.
More answers in the full FAQ.