Routing to Multiple Origins with Worker Path Rules
After working through this guide you will be able to put a single hostname in front of five independent backends — a marketing site, an application shell, a REST API, a documentation build, and a legacy monolith — using one Cloudflare Worker whose routing table is plain data rather than a tree of if statements. You will handle longest-prefix resolution, per-backend path rewriting, host and cookie scoping, and a staged migration that retires the monolith one prefix at a time.
Consolidating onto one hostname is mostly a people decision that becomes an engineering problem. Marketing wants example.com, not www-marketing.example.com; the app team wants their own deploy cadence; the API team wants a different origin entirely. A path-routing Worker gives everyone what they want by making the hostname a namespace that the edge partitions, so each backend keeps its own deploy pipeline while the browser only ever sees one origin. The general mechanics of route patterns and the fetch handler are covered in Cloudflare Workers Routing; this guide is about what happens once several backends have to share one URL space without colliding.
Key implementation objectives:
- Express the path-to-origin mapping as a sorted data structure that can be edited, tested, and diffed independently of the routing logic.
- Resolve requests by longest matching prefix on segment boundaries, so
/apidocsnever lands on the/apibackend. - Rewrite or preserve the path per backend, and set the host, forwarded headers, and per-origin credentials correctly so backends generate valid absolute URLs.
- Scope cookies and cache entries across backends, return a deliberate 404 for unmapped paths, and migrate off a legacy origin one prefix per deploy.
Prerequisites and environment setup
You need Node 18 or newer, Wrangler 3.60+, a zone whose hostname is proxied through Cloudflare (a grey-clouded record means the Worker never runs), and network reachability from Cloudflare to every backend. Backends can be public hostnames, Cloudflare Tunnel endpoints, or R2/Pages projects — the Worker calls them with fetch, so anything addressable over HTTPS works.
node --version # v18.x or later
npx wrangler --version # 3.60.0 or later
npx wrangler whoami # confirms the account the routes will bind to
# Every backend must answer before you point traffic at it
for h in app api docs monolith; do
printf '%s ' "$h"
curl -s -o /dev/null -w '%{http_code}\n' "https://$h.internal.example.net/healthz"
done
Expected output is four 200s. Any backend that answers 403 or times out here will answer the same way through the Worker, and you will waste an afternoon debugging routing logic that is working perfectly.
Step-by-step procedure
Step 1 — Put the routing table in a data file
Routing logic that grows as nested conditionals becomes untestable within about four backends, because every new prefix has to be mentally diffed against every existing branch. Keep the table as an exported array instead. Adding a backend then becomes a data edit that a reviewer can read in one glance.
// src/routes.js — the entire routing policy, as data.
export const ROUTES = [
{ prefix: "/api/v2", origin: "https://api-v2.internal.example.net", strip: true, auth: "API_V2_TOKEN" },
{ prefix: "/api", origin: "https://api-legacy.internal.example.net", strip: true, auth: "API_LEGACY_TOKEN" },
{ prefix: "/app", origin: "https://app.internal.example.net", strip: false },
{ prefix: "/docs", origin: "https://docs.internal.example.net", strip: true },
{ prefix: "/legacy", origin: "https://monolith.internal.example.net", strip: false },
{ prefix: "/", origin: "https://marketing.internal.example.net", strip: false },
];
Two fields carry the interesting semantics. strip decides whether the backend sees the public path or a rewritten one: an API that was built to serve /v2/orders needs /api/v2 removed, whereas an app shell that generates its own links under /app needs the prefix preserved or every asset URL it emits will 404. auth names an environment secret rather than holding a value, so credentials stay out of the repository and each backend gets its own.
The side effect worth noting is testability. Because ROUTES is a plain module export, you can unit-test resolution with no Worker runtime at all — feed it a list of paths and assert the origin, which is the cheapest possible guard against the prefix bugs in the troubleshooting section.
Step 2 — Resolve by longest prefix, on segment boundaries
Sort once at module scope so the sort cost is paid at isolate startup, not per request, then take the first match. Two paths that both match are resolved by length, which is why /api/v2 must be able to win over /api no matter how the array is written.
// Longest prefix first; ties are impossible because prefixes are unique.
const TABLE = [...ROUTES].sort((a, b) => b.prefix.length - a.prefix.length);
// A prefix matches only on a segment boundary: "/api" matches "/api" and
// "/api/orders", but never "/apidocs".
function matches(pathname, prefix) {
if (prefix === "/") return true;
return pathname === prefix || pathname.startsWith(prefix + "/");
}
export function resolve(pathname) {
return TABLE.find((route) => matches(pathname, route.prefix)) ?? null;
}
The segment-boundary check is not decoration. A naive pathname.startsWith(prefix) sends /apidocs/index.html to the API backend, which returns a JSON 404 where a documentation page should be, and the failure is invisible until someone links to it publicly.
Step 3 — Rewrite the path and set the host correctly
The request the backend receives should look like a request that backend was designed for. That means rewriting the path when strip is set, forwarding the public hostname so the backend can build absolute URLs, and attaching per-origin credentials.
import { resolve } from "./routes.js";
export default {
async fetch(request, env) {
const url = new URL(request.url);
const route = resolve(url.pathname);
if (!route) return new Response("No route for this path\n", { status: 404 });
const target = new URL(route.origin);
target.pathname = route.strip
? url.pathname.slice(route.prefix.length) || "/"
: url.pathname;
target.search = url.search;
const headers = new Headers(request.headers);
// The backend builds canonical links from the PUBLIC host, not its own.
headers.set("X-Forwarded-Host", url.host);
headers.set("X-Forwarded-Proto", "https");
headers.set("X-Edge-Route", route.prefix);
if (route.auth) headers.set("Authorization", `Bearer ${env[route.auth]}`);
const upstream = new Request(target.toString(), {
method: request.method,
headers,
body: request.body,
redirect: "manual", // never let the runtime follow a backend redirect
});
return rewriteResponse(await fetch(upstream), url, route);
},
};
Note what you do not do: setting Host by hand on a Worker subrequest has no effect, because the runtime derives it from the URL you fetch. Backends that need the public hostname must read X-Forwarded-Host, and any backend that builds canonical links, sitemaps, or OAuth redirect URIs from Host needs a configuration change before you route to it. This is the same class of header discipline described in Modifying Request Headers at the CDN Edge Layer, applied per backend instead of zone-wide.
Step 4 — Repair redirects and scope cookies on the way back
A backend that redirects or sets a cookie is speaking about its own URL space. Both need translating back into the public one, or the browser will either leave your hostname or store a cookie it never sends again.
function rewriteResponse(res, publicUrl, route) {
const out = new Response(res.body, res);
const originHost = new URL(route.origin).host;
// 1. A redirect to the backend's own host becomes a redirect on the public host,
// with the stripped prefix put back on the front.
const location = out.headers.get("location");
if (location) {
const abs = new URL(location, route.origin);
if (abs.host === originHost) {
abs.protocol = "https:";
abs.host = publicUrl.host;
if (route.strip && !abs.pathname.startsWith(route.prefix)) {
abs.pathname = route.prefix + abs.pathname;
}
out.headers.set("location", abs.toString());
}
}
// 2. Cookies are re-scoped to the public host and pinned to this backend's prefix,
// so two backends cannot overwrite each other's session cookie.
const cookies = res.headers.getSetCookie();
if (cookies.length) {
out.headers.delete("set-cookie");
for (const cookie of cookies) {
out.headers.append("set-cookie", scopeCookie(cookie, publicUrl.host, route));
}
}
return out;
}
function scopeCookie(cookie, publicHost, route) {
const parts = cookie
.split(";")
.map((p) => p.trim())
.filter((p) => !/^domain=/i.test(p) && !/^path=/i.test(p));
parts.push(`Domain=${publicHost}`);
parts.push(`Path=${route.prefix === "/" ? "/" : route.prefix}`);
return parts.join("; ");
}
Pinning Path to the route prefix is the fix for the most annoying multi-backend bug there is: two backends that both issue a cookie called session on Path=/, each silently clobbering the other, producing logouts that only reproduce when a user visits both areas in the same tab. If backends genuinely need to share a session, give the cookie one canonical issuer and have the others read it — do not let two systems write the same name. Sticky-session considerations across replicas of a single backend are a separate concern, covered in Session Affinity and Sticky Routing at the Edge.
The ?? null in resolve combined with the explicit 404 gives you deliberate fallthrough. Because the table ends with a / entry, that 404 only fires if you remove the catch-all — which is exactly what you want in a staging environment, where an unmapped path should fail loudly instead of quietly rendering the marketing home page.
Step 5 — Bind the routes and deploy
The Worker needs a route pattern covering the whole hostname, plus one secret per authenticated backend. Keep environments separate so a staging table can point at staging backends.
{
"name": "origin-splitter",
"main": "src/index.js",
"compatibility_date": "2024-09-23",
"routes": [
{ "pattern": "shop.example.com/*", "zone_name": "example.com" }
],
"observability": { "enabled": true },
"env": {
"staging": {
"routes": [
{ "pattern": "shop-staging.example.com/*", "zone_name": "example.com" }
]
}
}
}
npx wrangler secret put API_V2_TOKEN
npx wrangler secret put API_LEGACY_TOKEN
npx wrangler deploy --env staging
npx wrangler deploy
A single /* pattern is correct here: the Worker itself is the router, so splitting the hostname across several Worker routes would just recreate the prefix problem in a place with no tests. Deploys are versioned, so npx wrangler rollback reverses a bad table edit in seconds.
Step 6 — Migrate off the legacy origin one prefix at a time
The reason to build this layer is usually that a monolith currently serves everything and needs to stop. The routing table makes that a sequence of one-line changes: each wave moves one prefix from the legacy origin to a new backend, and each wave is independently revertible.
Run each wave as: add the new prefix entry above the /legacy entry, deploy, watch error rates and latency for that prefix only, then either keep it or wrangler rollback. Because the entries are ordered by length rather than position, moving /docs cannot disturb /api, and a wave that goes wrong affects one prefix rather than the whole hostname. Keep the legacy origin running until the final wave has held for a week — the cost of an idle VM is trivially less than the cost of discovering an unmapped path after decommissioning.
Verification
Prove every route independently, including the ones designed to fail. The matrix below is the minimum set; run it against staging before production and keep it as a script.
| Request path | Expected backend | Upstream path | What it proves |
|---|---|---|---|
/ |
marketing | / |
Catch-all still serves the home page |
/pricing |
marketing | /pricing |
Unprefixed paths do not leak to other backends |
/app/settings |
app | /app/settings |
Prefix preserved when strip is false |
/api/v2/orders |
api-v2 | /orders |
Longest prefix wins and is stripped |
/api/orders |
api-legacy | /orders |
Shorter prefix still resolves |
/apidocs |
marketing | /apidocs |
Segment boundary stops a greedy match |
/docs/setup |
docs | /setup |
Docs build receives its own path space |
/legacy/report |
monolith | /legacy/report |
Untouched path for the old system |
HOST="https://shop.example.com"
for p in / /pricing /app/settings /api/v2/orders /api/orders /apidocs /docs/setup /legacy/report; do
printf '%-18s %s\n' "$p" \
"$(curl -s -o /dev/null -w '%{http_code} route=%header{x-edge-route} ray=%header{cf-ray}' "$HOST$p")"
done
Expected output — the x-edge-route value echoes the prefix the table selected, which is the fastest possible confirmation that resolution matched your intent:
/ 200 route=/ ray=8e2a1c4f7b9d0a12-IAD
/pricing 200 route=/ ray=8e2a1c4f7b9d0a34-IAD
/app/settings 200 route=/app ray=8e2a1c4f7b9d0a56-IAD
/api/v2/orders 200 route=/api/v2 ray=8e2a1c4f7b9d0a78-IAD
/api/orders 200 route=/api ray=8e2a1c4f7b9d0a9a-IAD
/apidocs 404 route=/ ray=8e2a1c4f7b9d0abc-IAD
/docs/setup 200 route=/docs ray=8e2a1c4f7b9d0ade-IAD
/legacy/report 200 route=/legacy ray=8e2a1c4f7b9d0af0-IAD
Confirm redirect translation separately, because it is the check most often skipped:
curl -sI "$HOST/app" | grep -i '^location' # must stay on shop.example.com
npx wrangler tail --format pretty # watch live resolution while you probe
Troubleshooting
A greedy prefix swallows another route
Symptom: /apidocs, /application-form, or /apiary returns JSON from the API backend. The cause is prefix matching without a segment boundary, and it survives review because the table looks correct — it is the comparison that is wrong. Diagnose by calling resolve() directly in a unit test over a list of real paths pulled from your access logs. Fix with the pathname === prefix || pathname.startsWith(prefix + "/") form shown in Step 2, and add the offending path to the verification matrix so it can never regress.
Redirect loops after a backend rewrites the host
Symptom: curl -L bounces between the public hostname and itself until it gives up, or a browser reports too many redirects. This happens when the backend issues a redirect to its own hostname, the Worker rewrites it back to the public host, and the backend then redirects again because it is matching on Host rather than X-Forwarded-Host — commonly a framework’s “force canonical domain” or “force SSL” middleware. Diagnose with curl -sI against the backend directly and against the public host, comparing the two location values. Fix by configuring the backend’s canonical host to the public hostname, or by disabling its redirect middleware entirely and handling canonicalization at the edge, where you already own the rules. Setting redirect: "manual" on the subrequest, as in Step 3, keeps the loop visible instead of letting the runtime chase it.
Cache collisions between backends sharing a URL space
Symptom: a documentation page appears under an app URL, or a logged-in response is served to an anonymous visitor. The Cloudflare cache keys on the public URL, not on the backend you resolved, so if two backends can ever produce a response for the same public URL — during a migration wave, or through a fallback — one can poison the other’s entry. Diagnose with cf-cache-status and the x-edge-route header on the same URL across several requests: a HIT whose x-edge-route disagrees with the current table is a stale entry from the previous backend. Fix by purging the affected prefix on every cutover, keeping the path spaces genuinely disjoint, and adding the resolved route to the cache key for any URL that could be served by more than one origin — the mechanics are covered in Customizing Cache Keys to Improve Hit Ratio. Also set Cache-Control: private, no-store on authenticated prefixes before the first wave, not after.
CORS errors appear after the split
Symptom: browser console errors about a blocked cross-origin request, on a page that worked before the consolidation. The surprising direction is that consolidation usually removes CORS — everything is same-origin now — so an error afterwards means something is still pointing at a backend’s real hostname: a hard-coded API base URL in the frontend bundle, a <link rel="preconnect">, or a Location header you did not rewrite. Diagnose in the network panel by finding the request whose origin is not the public host, then trace where the URL came from. Fix by making the frontend use relative URLs so the routing table decides the destination, and remove any Access-Control-Allow-Origin headers the backends were emitting for the old topology — leaving a stale wildcard behind is a real risk, and the header policy you actually want belongs with the rest of your security headers injected at the edge.
Frequently Asked Questions
Why not use several Worker routes instead of one router Worker?
Route patterns are matched by Cloudflare’s specificity rules, which you cannot unit-test, extend with per-backend headers, or diff meaningfully in review. One /* route plus a table in code gives you the same partitioning with tests, ordered resolution you control, and a single place to add auth and cookie handling.
Should the prefix be stripped before the request reaches the backend? Strip it when the backend was written to serve its own root — most APIs and static builds. Preserve it when the backend generates links, assets, or redirects containing the prefix, because a stripped path means every URL it emits is wrong by exactly one segment. When in doubt, preserve, and configure a base path on the backend instead.
How do I keep credentials for each backend separate?
Store one secret per backend with wrangler secret put and reference it by name in the table entry, as the auth field does. The Worker then attaches only that backend’s credential, so a compromised token cannot be replayed against a different origin, and rotation touches one entry.
What should an unmapped path return?
A deliberate 404 from the Worker, not a silent fall-through to the marketing site. In production the catch-all / entry usually makes this moot, but in staging you want unmapped paths to fail loudly so a missing table entry is caught before release rather than after.
Does this add measurable latency compared with going straight to one origin? The routing work itself is sub-millisecond — a sort at startup and a linear scan per request. What you gain is that the Worker runs in the point of presence nearest the user, so backend selection happens before the long haul rather than after; the path-rewriting mechanics are the same ones described in Rewriting URLs and Paths at the Edge.
Related
- Deploying Cloudflare Workers for Dynamic Request Routing
- Rewriting URLs and Paths at the Edge
- Modifying Request Headers at the CDN Edge Layer
- Session Affinity and Sticky Routing at the Edge
Back to Cloudflare Workers Routing