• Aug 4, 2026
  • 6 min read

One Forms Worker for Every Site: Same-Origin Routes over Cloudflare Pages

I had five properties collecting leads in five different ways: a Pages Function writing to KV, formsubmit.co, Formbricks with Cal.com and a chatbot, Formbricks alone, and an Express app with PocketBase. Two contact addresses, inconsistent consent handling, and no single list of who had written in.

The one setup that worked well was the Pages Function on multibodysystemsdynamics.com: same-origin, writing to Cloudflare KV, then pinging my phone through ntfy (I described it in contact forms with Workers KV and ntfy). The question was how to give every site that setup without copying it five times.

Three ways to wire forms to KV

OptionHowProblem
A. Pages Function per sitefunctions/api/contact.ts in each repoN copies that drift; impossible for non-Pages sites
B. One Worker on forms.example.comEvery site posts cross-originCORS preflight everywhere; forms. hosts get caught by ad-blockers
C. One Worker, reached through routesSame Worker attached as <site>/api/forms/* on each zoneOne bug affects all sites → needs tests

I chose C. The key fact that makes it work: a zone route takes precedence over Pages for its path. With example.com/api/forms/* attached to the Worker, that path is served by the Worker while Pages keeps serving every other path on the same hostname. The site’s deploy doesn’t change at all.

I verified it on the first domain before relying on it, then again on the second zone. Same result both times: the route won, the rest of the site stayed on Pages.

The routes

name = "leads-forms"
main = "src/index.ts"

# Only reachable through the routes below (no *.workers.dev endpoint).
workers_dev = false
preview_urls = false

routes = [
  { pattern = "multibodysystemsdynamics.com/api/forms/*", zone_name = "multibodysystemsdynamics.com" },
  { pattern = "fossengineer.com/api/forms/*", zone_name = "fossengineer.com" },
  # jalcocertech's canonical host is www (apex 301s to it).
  { pattern = "www.jalcocertech.com/api/forms/*", zone_name = "jalcocertech.com" },
]

Two details matter:

  • workers_dev = false. A public *.workers.dev URL would be a second entry point nobody watches. The routes are the only way in.
  • Route the canonical host only. On jalcocertech the apex redirects to www with a 301, so an apex route would never see a form post.

A site registry in code

The Worker has to know which sites may submit and what each accepts. I kept that in a TypeScript file rather than in KV, so changes are reviewed in git and covered by tests:

export const SITES: readonly Site[] = [
  {
    id: "fossengineer.com",
    profile: "respond",
    kinds: ["enquiry", "newsletter"],
    hosts: ["fossengineer.com", "www.fossengineer.com"],
    enquiryRequiresName: false,
    consentTextVersion: "2026-10-04",
    // ...
  },
  // ...
];

A new site is one entry here, one route in wrangler.toml and a form. If sites ever change often, the registry can move to KV; for three sites a deploy per change is fine.

respond vs growth

Each site has a profile, and it decides what may be stored:

respondgrowth
PurposeAnswer exactly what was askedAlso follow up with offers
Examplesmultibodysystemsdynamics.com, fossengineer.comwww.jalcocertech.com
Marketing consentIgnored even if sentStored only when the optional box is ticked

A respond site that receives consent_marketing: true simply drops it — there is a test for that. Consent is stored as a record, not a boolean: {newsletter?, marketing?, text_version, at}.

Origin checks without CORS

Same-origin means the browser sends no preflight, but the Worker still checks where a request comes from. The submitting site is resolved from the request host; a browser Origin, when present, must belong to the same site:

const site = siteForHost(url.hostname);
// Same-origin route: a browser Origin, when present, must belong to the same site.
if (site && originHost && siteForHost(originHost)?.id !== site.id) {
  return { crossOrigin: false, originHost }; // → 403
}

Requests without Origin (curl, server-to-server tests) are accepted on a site route. A foreign Origin gets a 403 and nothing is stored. For sites hosted elsewhere there is a cross-origin mode on hosts listed in FORMS_HOSTS, with CORS limited to registered sites — but none of my Cloudflare sites need it.

Forms that work without JavaScript

Every form is a real form:

<form method="post" action="/api/forms/submit">
  <input type="hidden" name="kind" value="enquiry">
  ...
</form>

Without JavaScript it posts form-encoded and the Worker answers 303 back to the page with ?form=sent (or ?form=error), which a small script turns into a message. With JavaScript the page sends JSON plus attribution — the page URL and utm_* read from location, because Referer can be stripped — and shows the result inline.

The no-JS path found a real bug on one site: the contact form had inputs without name attributes and no action. It looked fine and sent nothing; the browser just reloaded the page with the fields in the URL. Audit forms by what they POST, not by how they look.

Integrating Astro and Hugo

The Worker doesn’t care about the stack:

  • Astro: a shared lead-form.ts script submits any form posting to /api/forms/submit and shows the message.
  • Hugo: one shortcode plus one static script, with the assets included once per page through .Page.Scratch. Theme CSS variables keep the form consistent in light and dark mode.

One Hugo gotcha: the theme’s .post-content p margins beat the form’s own styles and opened a large gap. Scoped !important on the form’s p and label fixed it — I only noticed from a screenshot, not from the build.

Retiring the old Pages Function

I migrated with a rollback path:

  1. Deploy the Worker on the new path, /api/forms/*, next to the old /api/contact Function.
  2. Switch the site’s forms to the new path and verify submissions and alerts in production.
  3. Only then delete the Function. The old path now returns 405.

Two things bit me along the way. Pages secrets aren’t visible to Workers, so the Worker needed its own ntfy secrets (Worker secrets apply immediately; Pages secrets only after a redeploy). And the make leads helper read the KV namespace ID from the Pages wrangler.toml binding I was about to delete — it now has its own setting.

Rollback, had I needed it, was a single action: detach the route.

Tests before rollout

One Worker for every site means one bug breaks every form, so it has a test suite (node --test --experimental-strip-types, no extra framework). Beyond the happy path it covers the failure modes: KV down returns 502 and sends no alert, ntfy down still returns 202, the honeypot returns 202 with nothing stored, and a foreign Origin returns 403.

Takeaways

  • A zone route takes precedence over Pages for its path — that is what makes same-origin forms possible without touching the site’s deploy.
  • Same-origin beats CORS for your own sites: no preflight, no ad-blocker exposure, and Origin checks still work.
  • Disable workers_dev; route only canonical hosts.
  • Keep the site registry in code until it changes often enough to justify KV.
  • Make the no-JS path real, and migrate with the old path still alive.

Earlier in the series: shipping a Python library as a PWA and where to put a lead-magnet app. Next: retention as a KV TTL.