Guide

Add a contact form with Cloudflare Pages Functions

12 November 2026 · About a 8-minute read

Every other guide on this site points the form at a standalone Worker URL. That is the right shape when several sites share one inbox, or when the site is not hosted on Cloudflare. If the site already lives on Cloudflare Pages, you can keep the endpoint on the same origin instead: a file in functions/ becomes a route on your domain, and the browser never has to make a cross-origin request.

This is a Pages Function. It is still a Worker. The difference is how you deploy it and where the URL lives.

When a Function is the better fit

  • Same origin. The form can use action="/api/contact". No CORS headers, no preflight surprises, no ALLOWED_ORIGIN typo that only shows up in production.
  • One deploy. A git push ships the static site and the handler together.
  • Simpler mental model if this is the only site that will ever post to the inbox.

Choose a standalone Worker instead when the same form should work on GitHub Pages, Netlify, and Pages, or when you want to redeploy the site without touching the inbox. You can start with a Function and move the handler later; the HTML change is one attribute.

Project layout

Keep static files in an output folder and the Function at the repo root, not inside that folder:

public/                 # your built HTML, CSS, JS
  index.html
  thanks/index.html
functions/
  api/
    contact.js          # → POST /api/contact
wrangler.toml
schema.sql
# wrangler.toml
name = "example-site"
pages_build_output_dir = "public"
compatibility_date = "2026-11-01"

[vars]
THANKS_URL = "/thanks/"

[[d1_databases]]
binding = "DB"
database_name = "contact-form"
database_id = "PASTE_ID_FROM_d1_create"

If you already build into dist or _site, point pages_build_output_dir at that folder instead. Do not set it to . if the repo also contains node_modules or this config file — you will upload things you do not mean to publish.

The form

Same markup as the other tutorials, but the action is a relative path:

<form id="contact" class="contact" method="post" action="/api/contact">
  <label>Name <input name="name" required maxlength="80" autocomplete="name"></label>
  <label>Email <input name="email" type="email" required maxlength="254" autocomplete="email"></label>
  <label>Message <textarea name="message" required minlength="10" maxlength="5000"></textarea></label>
  <div style="position:absolute;left:-10000px" aria-hidden="true">
    <input name="website" tabindex="-1" autocomplete="off">
  </div>
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
  <button type="submit">Send</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Add the site’s hostname (and localhost) to the Turnstile widget. The secret key is a Pages secret, not a build variable: it must not end up in the static HTML.

The Function

Pages looks at the export name. onRequestPost handles POST. A GET to /api/contact will not hit this file unless you also export onRequest or onRequestGet.

// functions/api/contact.js
export async function onRequestPost(context) {
  const { request, env } = context;
  const form = await request.formData();

  if (form.get("website")) {
    return Response.redirect(new URL(env.THANKS_URL, request.url), 303);
  }

  const token = form.get("cf-turnstile-response");
  const ip = request.headers.get("CF-Connecting-IP");
  const verify = await fetch(
    "https://challenges.cloudflare.com/turnstile/v0/siteverify",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        secret: env.TURNSTILE_SECRET,
        response: token,
        remoteip: ip,
      }),
    }
  );
  const outcome = await verify.json();
  if (!outcome.success) {
    return new Response("Verification failed. Go back and try again.", {
      status: 400,
    });
  }

  const name = String(form.get("name") ?? "").trim();
  const email = String(form.get("email") ?? "").trim();
  const message = String(form.get("message") ?? "").trim();
  const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  if (!name || name.length > 80 || !emailOk || email.length > 254 ||
      message.length < 10 || message.length > 5000) {
    return new Response("Please check the form fields.", { status: 400 });
  }

  await env.DB.prepare(
    "INSERT INTO messages (name, email, message) VALUES (?, ?, ?)"
  ).bind(name, email, message).run();

  context.waitUntil(
    notifyMe(env, { name, email, message }).catch((err) => {
      console.error("notify failed", err);
    })
  );

  return Response.redirect(new URL(env.THANKS_URL, request.url), 303);
}

The D1 and Turnstile pieces are the same as in the standalone Worker tutorial. Notifications are optional; hook them up with the email guide. The one Pages-specific detail: waitUntil lives on context, not as a third argument to fetch.

Use a 303 redirect so a refresh on the thanks page does not resubmit. Resolving THANKS_URL against request.url means a relative path like /thanks/ works on both production and *.pages.dev preview URLs.

Create the database, set the secret, deploy

npx wrangler d1 create contact-form
npx wrangler d1 execute contact-form --remote --file=./schema.sql

Paste the database_id into wrangler.toml. Put TURNSTILE_SECRET in the dashboard under the Pages project: Settings → Variables and Secrets, as a secret, for Production (and Preview if you test there). Bind the D1 database on the same screen if you deploy through git instead of Wrangler — git-connected projects need the binding in the dashboard as well as, or instead of, the config file.

Then either connect the repo in the Pages dashboard, or deploy a folder:

npx wrangler pages deploy public --project-name=example-site

For local work:

npx wrangler pages dev public

Wrangler will pick up functions/ and the D1 binding from wrangler.toml. Use Turnstile’s test keys, or add localhost to the widget.

What this version leaves out

Add a rate limit and a timing check before you rely on it; the architecture guide covers both. If you later outgrow a single-site Function, move the same handler into a standalone Worker and change the form action to that URL. The validation and the D1 schema do not have to change.

The ready-made version

Formharbor ships as a standalone Worker you point any form at — including a form on Cloudflare Pages — so the inbox is not tied to one Pages project. For $19 once, you get Turnstile Siteverify, honeypot and rate limits, email and webhook alerts, a password-protected dashboard with CSV export, plus a referral waitlist and R2 uploads, all on your Cloudflare free tier. If you would rather keep everything in functions/, the code above is a complete starting point.

More guides

Want this already built?

Formharbor is a $19 one-time kit: contact forms, referral waitlists, and file uploads on your Cloudflare free tier.

Get Formharbor