Same Path, Different Plumbing: How Cloudflare Workers Routes Work
I had no idea Cloudflare had this feature. I found it the way I find most things: something broke in production that worked perfectly on my laptop, and I went digging. Let me walk you through the two cases where it ended up saving me, because the second one only made sense after the first.
Case one: the proxy that vanished
I was building a small frontend app that needed data from a backend I also owned. The frontend was a static build deployed to Cloudflare Pages. The backend was a Worker.
In development, the frontend just called relative URLs. fetch('/api/items'), that kind of thing. No base URL, no environment variable, nothing. It worked because the dev server had a proxy configured: any request to /api/* got forwarded to the backend running locally on another port.
browser ──/api/items──▶ dev server (localhost:5173)
│ opens a NEW request
▼
backend (localhost:8787)
│
browser ◀──── relayed response ────┘ The dev server sits in the middle. It receives the request, makes a second request to the backend, and relays the bytes back. Two hops. And because the browser only ever talked to localhost:5173, there’s no cross-origin request and no CORS to think about.
Then I deployed. Every price request came back as… the HTML of my index page.
It took me an embarrassingly long moment to understand why. pnpm build compiles the app into a folder of static files and exits. The dev proxy is a feature of the dev server, and the dev server does not exist in production. There is nothing running on Pages that could forward /api/* anywhere. So when the browser asked for /api/items, the static host did what static hosts do with unknown paths: it fell back to index.html.
The frontend code was fine. The backend was fine. The link between them lived entirely in dev tooling config, and that config just wasn’t there anymore.
The fix I stumbled into
The usual fix is to give the frontend a base URL. Point it at https://api.example.com in production, add a CORS allow-list on the backend, done. Plenty of apps work this way, and I was about to do the same.
But while poking around the Cloudflare dashboard I found something that looked easier: Workers Routes.
The idea is simple once it clicks. Your domain is already on Cloudflare, which means every request to it already passes through Cloudflare’s edge before it reaches whatever serves it. A route is a rule you attach to a Worker that says: “for requests matching this URL pattern on this domain, run me instead.”
// wrangler.jsonc, on the backend Worker
"routes": [
{ "pattern": "example.com/api/*", "zone_name": "example.com" }
] That’s it. Deploy the backend Worker with that config, and now:
browser ──/api/items──▶ Cloudflare edge
│ pattern matches → run the Worker HERE
▼
backend Worker executes at the edge
│
browser ◀──── response ────────────┘ Nothing is forwarded. There’s no second request. The Worker is the origin for that path. example.com/api/* goes to the Worker, example.com/ and everything else keeps going to Pages, and the Worker’s own hostname keeps working too. Routes are additive.
So it’s a proxy? Not really.
This is the part I want to be precise about, because I kept calling it a proxy in my head and that framing is slightly wrong.
A proxy is a middleman. It receives a request and makes a new one on your behalf. That’s what the dev server does.
A route is closer to a router. If you’ve used any backend framework, you’ve written something like “send /users to this handler and /orders to that one.” A Workers route is the same thing, applied to a hostname’s URL space, running at the edge. There’s no forwarding because there’s nothing to forward to. The thing that answers is the thing the route points at.
dev: browser → dev server → (new request) → backend → back (proxy)
prod: browser → edge ── runs the Worker in place ── → back (route) Same path from the browser’s point of view. Completely different plumbing underneath. And the whole point is that the frontend code cannot tell the difference. It writes /api/... and something answers, in both environments.
A few things fell out of this that I didn’t expect:
The frontend has no API base URL anywhere. Not in code, not in an env file. Dev resolves relative URLs through the proxy, prod resolves them through the route.
The backend’s CORS middleware became dead code for this flow. The browser never crosses an origin, so there’s nothing to allow. It only matters if some other domain calls the API.
And the flip side, which bit me: the FE-to-BE link now lives in two config files (dev server, Cloudflare) and zero lines of application code. When it’s missing, nothing throws. The static host just quietly answers with the wrong thing.
Case two: what else can I point a route at?
A few weeks later I was working on a different site with a different problem. It was a server-rendered site on Pages, and its sitemap.xml was a static file with three URLs in it. The blog posts and event pages that actually mattered for search were all served by a backend, dynamically, and the static sitemap had no idea they existed.
The catch with sitemaps: search engines only accept a sitemap for URLs on the same host it’s served from. You can’t serve the sitemap from api.example.com and list example.com/blog/... in it. Redirecting /sitemap.xml to the API host doesn’t count either. The XML has to genuinely come from the frontend’s hostname.
The “normal” answer would be a server route on the frontend that queries the database and renders XML. But that codebase had already been through a production outage caused by runtime differences that never reproduced locally, and adding new server code to it felt like exactly the same category of risk.
Then I remembered the route thing. What if the backend Worker, which already talks to the database, generated the XML, and a route made it appear at example.com/sitemap.xml?
GET example.com/sitemap.xml → route → backend Worker
GET example.com/sitemap/blog.xml → route → backend Worker (queries the DB)
GET example.com/sitemap/events.xml → route → backend Worker (walks an upstream API)
GET example.com/blog, /events, ... → Pages, unchanged It worked exactly as I hoped. Zero lines of frontend code changed. I deleted the static sitemap.xml and the backend Worker started answering that path on the frontend’s domain, from the edge, with fresh XML built from the database.
The mental model was identical to case one. I just swapped “the browser asks for JSON” with “a crawler asks for XML.” The route doesn’t care what’s on the other end. It’s a path on a hostname, and you decide who owns it.
That’s it, really
This is just an interceptor sitting at the edge. A request comes in for a path on your domain, and you decide who answers it. In dev a proxy imitates that with forwarding. In prod the edge runs the right code in place.
Same path, different plumbing, and the code on either side never has to know.