Guide

A free Formspree alternative: keep your HTML, change the endpoint

12 November 2026 · About a 8-minute read

If your static site already posts to Formspree, you do not need to redesign the form to leave. You change the action, keep the field names, and run a small Cloudflare Worker that understands the special fields Formspree taught everyone to use. This is the how-to. For a cost comparison of hosted plans versus a Worker, see Formspree vs running forms yourself.

The one-line HTML change

<!-- before -->
<form action="https://formspree.io/f/YOUR_FORM_ID" method="POST">

<!-- after -->
<form action="https://contact-form.YOUR-SUBDOMAIN.workers.dev/" method="POST">

Name, email, message, and any custom fields can stay as they are. If you used Formspree’s _replyto, _subject, _next, or _gotcha, the Worker below honours them, so existing markup keeps working.

You will lose Formspree’s built-in reCAPTCHA the moment you switch the URL. Add a Turnstile widget in the same change, and verify the token on the server. A honeypot alone is not a replacement.

What those special fields do

  • email or _replyto. The visitor’s address. Use it as Reply-To on the notification you send yourself, after you have validated the format.
  • _subject or subject. The notification subject. Treat it as untrusted text: strip newlines and cap the length. Prefer a hidden field or a <select> over a free-text subject.
  • _next. Where to send the browser after a successful post. This is useful and also a classic open redirect if you honour any URL the client sends.
  • _gotcha. Formspree’s honeypot. If it has a value, pretend to succeed and drop the submission. Keep accepting this name so older forms stay protected, but a custom field name is harder for bots to skip.

Never trust _next blindly

Allow only URLs on origins you already trust. Anything else is a gift to whoever wants to bounce victims through your endpoint:

function thanksUrl(form, env) {
  const fallback = env.THANKS_URL;
  const next = String(form.get("_next") || "").trim();
  if (!next) return fallback;
  try {
    const url = new URL(next, env.ALLOWED_ORIGIN);
    const allowed = new URL(env.ALLOWED_ORIGIN);
    if (url.origin === allowed.origin) return url.href;
  } catch (_) {
    /* ignore malformed values */
  }
  return fallback;
}

A drop-in Worker

This handler accepts a typical Formspree-shaped POST, stores the message in D1, and redirects. Pair it with the schema and Turnstile check from the Turnstile tutorial, and with the email guide if you want a notification in your inbox.

// Special-field helpers, used after you have already
// verified Turnstile and rejected empty or huge fields.
function visitorEmail(form) {
  return String(form.get("email") || form.get("_replyto") || "")
    .replace(/[\r\n]+/g, "")
    .trim();
}

function subjectOf(form, name) {
  const raw = String(form.get("_subject") || form.get("subject") || "").trim();
  const cleaned = raw.replace(/[\r\n]+/g, " ").slice(0, 120);
  return cleaned || ("New message from " + name);
}

export default {
  async fetch(request, env, ctx) {
    if (request.method === "OPTIONS") {
      return new Response(null, {
        headers: {
          "Access-Control-Allow-Origin": env.ALLOWED_ORIGIN,
          "Access-Control-Allow-Headers": "Accept, Content-Type",
          "Access-Control-Allow-Methods": "POST, OPTIONS",
          Vary: "Origin",
        },
      });
    }
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    const origin = request.headers.get("Origin");
    // Origin is missing on a simple same-page form POST in some cases;
    // still require it to match when the browser sends it.
    if (origin && origin !== env.ALLOWED_ORIGIN) {
      return new Response("Forbidden", { status: 403 });
    }

    const form = await request.formData();
    const wantsJson = (request.headers.get("Accept") || "").includes("application/json");
    const headers = {
      "Access-Control-Allow-Origin": env.ALLOWED_ORIGIN,
      Vary: "Origin",
    };
    const ok = (url) =>
      wantsJson
        ? Response.json({ ok: true }, { headers })
        : new Response(null, { status: 303, headers: { ...headers, Location: url } });

    // Formspree honeypot: pretend success.
    if (form.get("_gotcha") || form.get("website")) {
      return ok(thanksUrl(form, env));
    }

    // ...Turnstile Siteverify and field validation go here...

    const name = String(form.get("name") ?? "").trim();
    const email = visitorEmail(form);
    const message = String(form.get("message") ?? form.get("msg") ?? "").trim();

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

    ctx.waitUntil(
      notifyMe(env, { name, email, message, subject: subjectOf(form, name) })
        .catch((err) => console.error("notify failed", err))
    );

    return ok(thanksUrl(form, env));
  },
};

The JSON body { ok: true } matches what a lot of Formspree fetch snippets check for. If your frontend already looks at data.ok, you should not have to touch the JavaScript, only the URL.

If you used @formspree/react or another official client, point it at your Worker only if you are prepared to keep that client’s request shape working. For a plain HTML form, the action change is enough.

When you should stay on Formspree

Stay if you have one low-volume form, no appetite for Wrangler, or you rely on Formspree plugins (HubSpot, Zapier, help-desk tickets) that you do not want to rebuild. A hosted inbox is a reasonable product. Moving makes sense when you already run on Cloudflare, you are about to pay for a plan, or you want the submissions in a database you can export and delete yourself.

The ready-made version

Formharbor is a $19 one-time kit that already behaves like this endpoint: you paste a form, point the action at your Worker, and get Turnstile, a honeypot, rate limits, email and webhook alerts, and a dashboard with CSV export. It also ships referral waitlists and R2 uploads on the same Worker, and it runs on your Cloudflare free tier.

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