- 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
| Option | How | Problem |
|---|---|---|
| A. Pages Function per site | functions/api/contact.ts in each repo | N copies that drift; impossible for non-Pages sites |
B. One Worker on forms.example.com | Every site posts cross-origin | CORS preflight everywhere; forms. hosts get caught by ad-blockers |
| C. One Worker, reached through routes | Same Worker attached as <site>/api/forms/* on each zone | One 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.devURL 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
wwwwith 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:
| respond | growth | |
|---|---|---|
| Purpose | Answer exactly what was asked | Also follow up with offers |
| Examples | multibodysystemsdynamics.com, fossengineer.com | www.jalcocertech.com |
| Marketing consent | Ignored even if sent | Stored 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.tsscript submits any form posting to/api/forms/submitand 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:
- Deploy the Worker on the new path,
/api/forms/*, next to the old/api/contactFunction. - Switch the site’s forms to the new path and verify submissions and alerts in production.
- 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.