How to Build a Contact Form on Cloudflare
// Published On: Aug 28, 2026
The contact section on this site used to be a single mailto: link. Simple, but it put my email address in plain text in the page source, permanently scrapable by anything crawling the site. I wanted a real form instead, and I wanted it free. Here’s how it’s built: a Cloudflare Pages Function, Cloudflare Turnstile for spam protection, and Cloudflare’s own email sending for delivery, no third-party form or email service, no cost.
The pieces
- A Cloudflare Pages Function at
functions/api/contact.ts, handlingPOST /api/contact. Pages picks up anything underfunctions/automatically and deploys it alongside the static site. - Cloudflare Turnstile for spam protection, plus a honeypot field as a second, invisible layer.
- Cloudflare Email Service’s REST API for actually sending the message, a plain
fetchcall, no binding required.
Cloudflare does ship an official TypeScript SDK (cloudflare on npm) that wraps this same endpoint as client.emailSending.send(). I skipped it here: it bundles the entire Cloudflare API surface, zones, DNS, KV, all of it, into one package, which is a lot of extra weight for a Pages Function that only ever needs to hit one endpoint. A plain fetch call is a few lines and has zero dependencies to keep updated.
There’s no route config anywhere for the /api/contact path. Pages Functions use file-based routing, the same idea as Next.js API routes: a file’s location under functions/ becomes its route, .ts extension dropped, so functions/api/contact.ts is automatically served at /api/contact. The exported function name picks the HTTP method it handles, onRequestPost for POST, onRequestGet for GET, and so on. That functions/ directory has to sit at the project root rather than inside src/, since Wrangler looks for it there separately from Astro’s own build output in dist/.
Setting up email sending
Cloudflare Email Service needs a domain onboarded before it’ll send anything on that domain’s behalf:
- Dashboard → Compute → Email Service → Email Sending → Onboard Domain, pick the domain. Cloudflare adds the MX, SPF, and DKIM records it needs automatically.
- Clear out any old records standing in the way. When I moved this site to Cloudflare, I deliberately locked the domain down against email spoofing since it wasn’t sending mail at the time: an
SPFTXT record ofv=spf1 -all(authorizes zero senders) and aDMARCpolicy ofp=rejectwith strict alignment. Now that the domain actually sends mail, those records had to go, they’d otherwise cause every message from the contact form to fail SPF and get rejected outright. Onboarding overwrites the SPF record with one that authorizes Cloudflare’s sending infrastructure, but I had to delete the old hardenedDMARCTXT record myself and let Cloudflare’s onboarding add its own. - My Profile → API Tokens → create a token scoped to Email Sending: Edit.
- Add
CF_ACCOUNT_ID,CF_EMAIL_API_TOKEN,CONTACT_SENDER_ADDRESS, andCONTACT_RECIPIENT_ADDRESSas environment variables on the Pages project (Settings → Environment variables), scoped to Production. Keeping the two addresses as env vars rather than hardcoding them means they never end up committed to source, useful if the repo is public.
The function
// functions/api/contact.ts
interface Env {
CF_ACCOUNT_ID: string;
CF_EMAIL_API_TOKEN: string;
TURNSTILE_SECRET_KEY: string;
CONTACT_SENDER_ADDRESS: string;
CONTACT_RECIPIENT_ADDRESS: string;
}
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
const { name, email, message, turnstileToken } = await request.json();
// honeypot check happens here too, see below
if (!name || !email || !message) {
return json({ error: "Name, email, and message are required." }, 400);
}
const verified = await verifyTurnstile(
turnstileToken,
request.headers.get("CF-Connecting-IP"),
env.TURNSTILE_SECRET_KEY,
);
if (!verified) return json({ error: "Verification failed." }, 400);
await sendEmail(env, {
from: env.CONTACT_SENDER_ADDRESS,
to: env.CONTACT_RECIPIENT_ADDRESS,
reply_to: email,
subject: `[rupin.dev contact form] ${name}`,
text: `From: ${name} <${email}>\n\n${message}`,
});
return json({ ok: true }, 200);
};
async function sendEmail(env: Env, params: Record<string, string>) {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/email/sending/send`,
{
method: "POST",
headers: {
Authorization: `Bearer ${env.CF_EMAIL_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params),
},
);
if (!response.ok) throw new Error(`Email Service API returned ${response.status}`);
}
async function verifyTurnstile(token: string, ip: string | null, secret: string) {
const formData = new FormData();
formData.append("secret", secret);
formData.append("response", token);
if (ip) formData.append("remoteip", ip);
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: formData,
});
const outcome = (await response.json()) as { success: boolean };
return outcome.success;
}
function json(data: unknown, status: number) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
(Trimmed for readability, the real version adds field length limits and stricter validation.)
The frontend
The form itself is plain HTML, submitted with fetch instead of a full page reload. Turnstile’s widget renders into a placeholder div and hands back a token through its callback:
const widgetId = window.turnstile.render("#contact-turnstile", {
sitekey: turnstileSiteKey,
callback: (token) => { turnstileToken = token; },
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, message, turnstileToken }),
});
// show success or error based on the response
});
The Turnstile site key is a public value, so it’s read from a PUBLIC_TURNSTILE_SITE_KEY env var at build time and gets baked straight into the HTML. The secret key stays server-side only, read by the function from TURNSTILE_SECRET_KEY.
/api/contact is a public URL, nothing stops someone from curl-ing it directly instead of going through the form. What stops that from being useful is the turnstileToken check. That token only exists after Turnstile’s widget runs its challenge in a real browser against the site key for this exact domain, and it’s short-lived and single-use. The function hands whatever token it receives to Cloudflare’s siteverify endpoint along with the secret key, and only Cloudflare’s servers can confirm a token is genuine, current, and not already spent. A direct POST with no token, an expired one, or a made-up one fails that check immediately and never reaches the part of the function that sends mail. Scripting around this means solving Turnstile’s challenge itself at scale, which is a materially harder problem than filling out a form.
The honeypot
Alongside Turnstile, the form has a second, much simpler layer: a honeypot field. It’s an extra input that’s part of the form but invisible to a real visitor, so nobody typing into the actual form ever touches it. It costs nothing to run and catches a category of low-effort bots that Turnstile alone might not.
Is this actually free, at any volume
Cloudflare’s Email Service quota is built around one distinction that matters a lot for a contact form specifically: sending to an arbitrary recipient versus sending to a verified destination address on your account.
- Sending to arbitrary recipients needs the Workers Paid plan, with 3,000 emails included per month and $0.35 per 1,000 after that.
- Sending to a verified destination address is free on every plan, including Free, and doesn’t count against that monthly quota or any daily sending limit at all.
A contact form only ever sends to one place: whatever address you set as CONTACT_RECIPIENT_ADDRESS, which you verify once during onboarding. That means it falls entirely under the second case. There’s no volume cap to worry about for this specific use, no plan upgrade needed, and no per-message cost, so long as the recipient stays a single verified address rather than something dynamic.
Per-message limits still apply regardless of plan: 50 recipients max, a 5 MiB total message size (25 MiB when sending only to verified addresses), and a 998-character subject line, none of which a contact form is likely to hit.
End result
Fill out the form, Turnstile verifies invisibly in the background, the function checks the honeypot and validates the fields, then sends the message through Cloudflare’s Email Service. No email address printed anywhere on the page, no third-party form service, and it runs entirely on Cloudflare’s free tier.
