Guide

Netlify Forms alternative: keep your forms when you move to Cloudflare Pages

12 November 2026 · About a 8-minute read

First, a fair point. If your site stays on Netlify, you probably don’t need an alternative. Since April 2026, Netlify’s docs say forms are free and unlimited on credit-based plans. (Accounts created before September 2025 on legacy plans are still metered per site.) Adding data-netlify="true" is still one of the easiest ways to get a working form.

The catch is that Netlify Forms only works on Netlify. It works by scanning your deployed HTML at build time, so when you move the site to Cloudflare Pages, GitHub Pages, or a VPS, the forms stop working with no error message. The page still looks fine and every submission disappears. This guide is for that move.

What you are replacing

A typical Netlify form looks like this:

<form name="contact" method="POST" data-netlify="true" netlify-honeypot="bot-field">
  <input type="hidden" name="form-name" value="contact">
  <p hidden><input name="bot-field"></p>
  ...
</form>

Netlify handles four jobs for you: it receives the POST, filters spam (Akismet plus the honeypot), stores the submission in its UI, and sends notifications. A replacement needs to cover all four, or you’ll notice the gap the first week.

Your options, honestly

  • A hosted form backend (Formspree, Basin, Formspark, Web3Forms, and many others). Change the form’s action to their endpoint and you’re done. Most have a small free tier and a monthly plan above it. This is the fastest option, and the right one if nobody on the team wants to touch code.
  • A Cloudflare Pages Function on the same project as your site. Free on the Workers free plan (100,000 requests a day), no new vendor, and the data sits in your own D1 database. You write and maintain about 40 lines of code.
  • A separate Worker that several sites post to. The same idea as a Pages Function, but one endpoint serves many sites. This fits agencies or anyone running more than a couple of sites.

The rest of this article covers the Pages Function route, since it’s the closest match to “forms come with the host.”

Step 1: Change the form

Remove the Netlify attributes and the hidden form-name input, point action at your own path, and swap the honeypot for a field hidden off-screen. Turnstile replaces Akismet as the spam filter.

<form method="POST" action="/api/contact">
  <div style="position:absolute;left:-10000px" aria-hidden="true">
    <input name="bot-field" tabindex="-1" autocomplete="off">
  </div>
  <label>Name <input name="name" required maxlength="80"></label>
  <label>Email <input name="email" type="email" required maxlength="254"></label>
  <label>Message <textarea name="message" required maxlength="5000"></textarea></label>
  <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>

Step 2: Add a Pages Function

Any file under functions/ in a Pages project becomes a route. This one handles POST /api/contact:

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

  if (form.get("bot-field")) {
    return Response.redirect(new URL("/thanks/", request.url), 303);
  }

  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: form.get("cf-turnstile-response"),
        remoteip: request.headers.get("CF-Connecting-IP"),
      }),
    }
  );
  if (!(await verify.json()).success) {
    return new Response("Verification failed. Please try again.", { status: 400 });
  }

  const name = String(form.get("name") ?? "").trim().slice(0, 80);
  const email = String(form.get("email") ?? "").trim().slice(0, 254);
  const message = String(form.get("message") ?? "").trim().slice(0, 5000);
  if (!name || !email.includes("@") || !message) {
    return new Response("Please fill in every field.", { status: 400 });
  }

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

  return Response.redirect(new URL("/thanks/", request.url), 303);
}

The form column lets one table hold every form on the site, much like Netlify’s per-form list. Create the database and table:

CREATE TABLE messages (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  form TEXT NOT NULL,
  name TEXT, email TEXT, message TEXT
);

Run npx wrangler d1 create site-forms, apply the schema with npx wrangler d1 execute site-forms --remote --file=schema.sql, then in the Pages project settings bind the database as DB and add TURNSTILE_SECRET as an encrypted variable. Redeploy so the function picks up the bindings.

Step 3: Bring your old submissions with you

Before you change DNS, export each form’s submissions as CSV from the Netlify Forms UI. Netlify doesn’t keep them indefinitely once the site is gone, and they’re usually your best leads. For later exports from D1:

npx wrangler d1 execute site-forms --remote --json \
  --command "SELECT * FROM messages WHERE form = 'contact' ORDER BY id DESC" > contact.json

Step 4: Notifications

Netlify emailed you on every submission. Your function needs to do the same, or you’ll find out three weeks later that a client wrote to you. The simplest options are a Discord or Slack incoming webhook (one fetch with a JSON body) or a transactional email API such as Resend. Send the notification after the database insert, so a failed email never loses a message.

A migration checklist

  1. Export old submissions from Netlify.
  2. Search your templates for data-netlify and netlify attributes. Every form needs a new action.
  3. Check that file-upload fields have somewhere to go. Netlify accepted them, but the function above doesn’t. See file uploads to R2.
  4. Submit each form on the new host before you switch DNS, and confirm the row appears and the notification arrives.
  5. Keep a custom thanks page at /thanks/. Netlify’s default success page goes away with Netlify.

Or skip the glue

The function above handles one contact form. A real migration usually needs a few more pieces: rate limits, notification email, a page to read and export submissions, sometimes a waitlist or an upload field. Formharbor is a $19 one-time kit that includes all of that as a single Worker on your Cloudflare account: contact, waitlist, and upload forms, a full spam stack, Resend and webhook alerts, and a dashboard with CSV export. It’s meant for exactly this situation, leaving Netlify without adding a monthly form bill. If you’d rather compare hosted options first, see Formspree vs running forms yourself.

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