splitforms.com
All articles/ TUTORIALS9 MIN READPublished May 11, 2026

How to Add a Contact Form to Cloudflare Pages 2026

Add a working contact form to a Cloudflare Pages site in 2026 — Workers vs hosted form backend, paste-in HTML, custom CSS, and reliable email delivery.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

Cloudflare Pages has two paths for forms

Cloudflare Pages is a static host with an optional serverless layer. When you need a contact form on it, you pick one of two paths and the rest of the setup follows from that choice. Most tutorials lump them together and that's why people get stuck.

The static path: your index.html contains a <form> whose action points at an external URL. The browser POSTs the form data straight to that URL — Cloudflare Pages is uninvolved. Email delivery, spam filtering, storage, retries — all handled by whoever owns the action URL. With splitforms, that URL is https://splitforms.com/api/submit and the whole pipeline is done.

The Functions path: you write a functions/api/contact.ts file. Cloudflare Pages turns that into a Worker on deploy. The form POSTs to /api/contact on your own domain. The function reads the body, calls an email API (MailChannels was the popular pick before deprecation; now Resend, SendGrid, or AWS SES), validates spam, returns a 200. You own the code, you debug the code, you fix the code when it breaks.

Both work. They're not equivalent in effort. The rest of this guide explains which to pick and how to ship it.

One thing worth knowing up front: the static path is also what most production marketing sites quietly use, even ones with elaborate Worker infrastructure. The reason is boring — separation of concerns. Your CDN serves HTML. Your forms vendor handles email delivery, retries, and bounces. Mixing the two creates a tight coupling that hurts you the first time the email provider has an outage and your Pages deploy starts failing health checks.

Why hosted form backend beats writing a Worker for most sites

I've shipped both. Here's the honest tradeoff most posts skip.

Writing a Pages Function for a contact form is roughly 80 lines of TypeScript if you do it well: parse the body, validate fields, hit a honeypot check, call an email API, format the HTML, handle errors, return CORS headers. Then you need to set environment variables for the email API key, configure SPF/DKIM records for the sender domain, monitor bounces, and re-deploy every time you tweak the email template. You're building a mini-product to send yourself an email.

A hosted backend collapses that whole stack into one HTML attribute. The form's action URL is the integration. There's no function to deploy, no API key in a Worker secret, no SPF record to set up, no email template to maintain in code. When the email template changes, you change it in the splitforms dashboard. When you want to add a webhook to Slack, you paste the URL — no redeploy.

The Worker route makes sense in three cases: (1) you need to inspect the submission before forwarding (custom validation, signature checks); (2) you have a strict CSP that disallows form action to third-party origins; (3) you want all your traffic logged on the same edge. For everything else, the hosted path is the faster, cheaper, and more reliable choice.

Cost comparison: a self-hosted Worker setup using Resend (free 100/day) plus your own infrastructure work runs ~$0/month plus maybe 4 hours of your time the first time it breaks. splitforms is $0/month for the first 1,000 submissions and a flat $5/month for 5,000 after that. If you outgrow that, the $59 for 4 years plan averages out to about $1.23/month and covers higher volumes. If your time is worth more than $1/hour, hosted wins.

The deliverability question deserves a short note. When you send mail from a Worker, the From: address has to belong to a domain whose SPF and DKIM records you control, and the receiving inbox has to trust your sender reputation. splitforms uses your own configured SMTP if you want — bring a Gmail App Password, AWS SES key, or your own server — so the mail looks like it came from you, with the SPF and DKIM already aligned. That's the same deliverability outcome you'd build by hand on a Worker, minus the setup and minus the ongoing maintenance.

Step 1: Get a splitforms access key (60 seconds)

  1. Visit splitforms.com/login
  2. Enter your email, paste the 6-digit code we mail you
  3. Copy the access key at the top of the dashboard

That key is what authenticates the form. No credit card, no plan selection — the free tier is 1,000 submissions/month forever. For comparison, that's twenty times what Formspree gives away (50/month) and you can migrate from Formspree in five minutes if you're moving from there.

Bookmark the key somewhere safe but don't treat it like a database password. It's a public-by-design identifier; if someone copies it out of your HTML they can only send you spam, which the AI filter catches anyway. The real protection is the per-domain allowlist you can set in the dashboard.

Step 2: Paste the form HTML into your Pages project

Open the HTML file you want the form on — could be index.html, contact.html, or a component in whatever framework you use on Pages (Astro, SvelteKit, Hugo, 11ty, React, vanilla). Drop in:

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
  <input type="text"  name="name"    required />
  <input type="email" name="email"   required />
  <textarea           name="message" required></textarea>
  <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
  <button type="submit">Send</button>
</form>

Replace YOUR_ACCESS_KEY with the value from the dashboard. That's the entire integration. The botcheck hidden checkbox is a honeypot — bots tick every input and get rejected, humans never see it. No JS, no library, no build step.

If you want a confirmation redirect, add a redirect field:

<input type="hidden" name="redirect" value="https://example.com/thanks" />

To customise the email subject line:

<input type="hidden" name="subject" value="New lead from cloudflare site" />

The full list of supported fields is in the API reference. The form contract is the same across Next.js, React, Astro, Svelte, and plain HTML — only the wrapper component changes.

Step 3: Style the form with Tailwind or plain CSS

The form is just HTML, so any styling system works. Two examples — pick whichever matches your Pages project.

Tailwind CSS

<form action="https://splitforms.com/api/submit" method="POST"
      class="max-w-md mx-auto space-y-4">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
  <input name="name" required placeholder="Your name"
         class="w-full rounded-lg border px-4 py-3 focus:ring-2 focus:ring-black" />
  <input name="email" type="email" required placeholder="you@example.com"
         class="w-full rounded-lg border px-4 py-3 focus:ring-2 focus:ring-black" />
  <textarea name="message" required rows="5" placeholder="Your message"
            class="w-full rounded-lg border px-4 py-3 focus:ring-2 focus:ring-black"></textarea>
  <input type="checkbox" name="botcheck" class="hidden" tabindex="-1" />
  <button class="w-full rounded-lg bg-black px-6 py-3 font-semibold text-white">
    Send message
  </button>
</form>

Plain CSS

.contact-form { display: grid; gap: 12px; max-width: 480px; }
.contact-form input,
.contact-form textarea {
  padding: 12px 14px;
  border: 1px solid #d4d4d8;
  border-radius: 8px;
  font: inherit;
}
.contact-form button {
  padding: 12px 20px;
  background: #0a0a0a;
  color: #fff;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
}

If you want the form pre-styled and ready to drop in, the free HTML contact form page has copy-paste templates with sensible defaults. Pair them with Tailwind form validation if you want inline error states.

Alternative: Pages Functions proxy pattern

If you need the form to POST to your own domain (CSP, branding, custom logging), you can keep splitforms as the actual sender and use a Pages Function as a thin proxy. Create functions/api/contact.ts:

// functions/api/contact.ts
export const onRequestPost: PagesFunction<{ SPLITFORMS_KEY: string }> = async ({ request, env }) => {
  const body = await request.formData();
  body.set("access_key", env.SPLITFORMS_KEY);

  const res = await fetch("https://splitforms.com/api/submit", {
    method: "POST",
    body,
  });

  if (!res.ok) {
    return new Response("Failed", { status: 502 });
  }
  return Response.redirect(new URL("/thanks", request.url).toString(), 303);
};

Then point the form at /api/contact on your own site:

<form action="/api/contact" method="POST">
  <input name="name" required />
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

Set the SPLITFORMS_KEY in the Pages project under Settings → Environment Variables → Add variable (mark it as encrypted). This pattern keeps the access key out of your HTML, lets you add server-side logging, and lets you run any extra validation you want before forwarding. The downside: now you have a function to maintain, and a Worker invocation per submission against your free 100k/day request limit. For most sites the direct-POST pattern is still simpler.

If you really want to bypass splitforms entirely and roll your own with a Pages Function, the rough shape is: read the body, run a honeypot check, call an email API (fetch("https://api.resend.com/emails", ...) or AWS SES via signed request), and return a redirect. You'll spend most of the time getting the From address verified at the email provider and tuning the HTML email template. Compared to changing one line in your <form> tag, it's a lot of code to maintain for the same end result — an email in your inbox.

Step 4: Connect a custom domain

Cloudflare Pages gives every project a <project>.pages.dev URL by default. Custom domains take 60 seconds.

  1. Pages dashboard → your project → Custom domains
  2. Click Set up a custom domain
  3. Enter example.com or www.example.com
  4. If the domain is already on Cloudflare DNS, the CNAME is wired automatically
  5. If it's on another registrar, add a CNAME at your DNS host pointing at <project>.pages.dev

SSL certificates provision automatically through Cloudflare's Universal SSL — no Let's Encrypt config, no renewal. Your form keeps working without any change because the action URL points at splitforms.com, not at your domain. DNS swaps don't affect submission delivery.

Step 5: Deploy via Git or wrangler

Two deploy paths. Both are first-class on Cloudflare Pages.

Git integration (recommended)

  1. Push your project to GitHub or GitLab
  2. Pages dashboard → Create projectConnect to Git
  3. Pick the repo, set the build command (often blank for static HTML, or npm run build for Astro/Next), set the output dir
  4. Save and deploy

Every push to main deploys to production. Every PR gets its own preview deployment with a unique <hash>.<project>.pages.dev URL — useful for testing the form against a real public URL without affecting prod.

wrangler CLI

npm install -g wrangler
wrangler login
wrangler pages deploy ./dist --project-name my-site

This is the path for CI runners that don't commit the build output, or for one-shot deploys. The Pages Function in functions/ gets bundled and deployed automatically — no separate wrangler deploy step for Workers.

Step 6: Test the form before going live

  1. Open your preview URL or production site
  2. Submit a real-looking test message
  3. Check your inbox — the email should arrive within 5 seconds
  4. Check the splitforms dashboard — the submission should be logged
  5. Test the honeypot: open devtools, set the botcheck checkbox to checked, submit — splitforms should reject it silently

If you want to test without sending real emails, the test form submissions without real emails guide covers staging environments and per-submission test mode.

Troubleshooting Cloudflare-specific gotchas

  • CORS error on fetch(). Native HTML form submits don't trigger CORS preflight. If you switched to fetch() with JSON, send to splitforms.com/api/submit and we return the right ACAO header. Full breakdown at CORS error form submission fix.
  • Worker route eating the POST. If you have a Worker bound to example.com/*, it intercepts everything including your form POST. Narrow the route to example.com/api/* or proxy non-matching paths to env.ASSETS.fetch(request).
  • Pages Function returning 405. You exported onRequest when you needed onRequestPost. Cloudflare matches on method-specific exports first. Use the suffixed name or guard with request.method !== "POST".
  • KV namespace not bound. KV bindings must be added to the Pages project under Settings → Functions → KV namespace bindings. Setting them in wrangler.toml alone doesn't apply to Pages — that file is for standalone Workers.
  • Environment variable shows undefined in production. Pages has separate variable sets for Production and Preview. If you only set the key for one environment, the other deploy will see undefined. Set it on both.
  • Build succeeds but form 404s. Your build output directory doesn't match the Pages config. Astro outputs dist/, Next.js static export uses out/, Hugo uses public/. Set the right one under Build settings.
  • Form works on pages.dev but not custom domain. Domain isn't fully propagated, or you have a Worker route on the custom domain that doesn't exist on pages.dev. Check Workers → Routes for any pattern matching your domain.
  • splitforms returns 401. Wrong access key, or you turned on Allowed Domains in the dashboard and forgot to include your Pages URL. Add both <project>.pages.dev and your custom domain.

Next steps

FAQ

Do I need Pages Functions or a Worker to handle the form?

No. If you use a hosted form backend like splitforms, the form submits directly from the browser to splitforms.com and the email lands in your inbox — Cloudflare Pages just serves the static HTML. You only need Pages Functions if you want to proxy the submission through your own /api route (for custom logging, signed requests, or strict CSP). For 95% of marketing sites, the static path is faster to ship and easier to debug.

Can I deploy the form via Git or do I need wrangler?

Both work. The Pages dashboard lets you connect a GitHub or GitLab repo and auto-deploy on push — that is the recommended path because preview deployments come free with every PR. wrangler pages deploy is for CI pipelines or when you want to deploy a built folder without committing it. Either way the form HTML is just a static file, so no special build config is needed.

Why does my form return a CORS error on Cloudflare Pages?

Almost always because the form is using fetch() with JSON instead of a native HTML POST. Native form submits do not trigger CORS preflight. If you must use fetch, send the request to splitforms.com/api/submit with the access key in the body — splitforms responds with the right Access-Control-Allow-Origin header. If you proxy through a Pages Function, return the same headers from your function response.

How do I add a custom domain to my Cloudflare Pages form?

Open the Pages project, go to Custom domains, click Set up a custom domain, and enter your apex or subdomain. If the domain is already on Cloudflare DNS, the CNAME is added automatically. If it is somewhere else, point a CNAME at your <project>.pages.dev. The form will keep working — the action URL points at splitforms.com, not at your domain, so DNS changes don't affect it.

Will the form work on the free Cloudflare Pages plan?

Yes. Cloudflare Pages free plan gives 500 builds/month and unlimited bandwidth, which is more than enough for a contact form. The form submissions don't count against any Cloudflare quota because they go to splitforms.com directly. You stay on the free tier on both sides — 1,000 submissions/month free with splitforms, free hosting with Cloudflare Pages.

What about KV, D1, or R2 for storing submissions?

You don't need them. splitforms stores every submission in its dashboard and emails you a copy — that is the durable store. If you want submissions in your own KV namespace for some reason, write a Pages Function that proxies the POST, calls env.KV.put(), then forwards to splitforms. But for almost every site this is over-engineering. The dashboard + email is the audit trail.

How do I stop spam without reCAPTCHA killing my Lighthouse score?

Use the honeypot field that ships with splitforms — a hidden checkbox named botcheck. Bots check every box and get rejected; humans don't see it. splitforms also runs AI spam classification on every submission for free. reCAPTCHA adds 400KB of JavaScript and hurts Core Web Vitals; the honeypot adds zero bytes. Read the breakdown at /blog/honeypot-vs-recaptcha if you want the data.

My Worker route is intercepting the form POST. How do I fix it?

Workers routes are matched before Pages serves the static file. If you have a route like example.com/* assigned to a Worker, your form POST hits the Worker first and never reaches the Pages Function. Either narrow the route pattern (example.com/api/*) or call event.passThroughOnException() and return env.ASSETS.fetch(request) for non-API paths. Easiest fix is just to scope routes to /api/* from the start.

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open