A production-ready Cloudflare Workers router that enables vertical microfrontend architecture by routing requests to separate Worker services based on path expressions, while handling all the complex URL rewriting needed to make multiple apps appear as a single unified application.
This router acts as a reverse proxy that:
- Routes requests to different Cloudflare Workers based on configurable path patterns (e.g.,
/docs,/dashboard,/:tenant/app) - Strips mount prefixes before forwarding to upstream services (so
/docs/aboutbecomes/aboutfor the docs service) - Rewrites HTML/CSS asset URLs to maintain correct paths (so
/assets/logo.pngbecomes/docs/assets/logo.pngwhen served from the docs mount) - Handles redirects and cookies by rewriting
Locationheaders andSet-Cookiepaths to maintain proper scoping - Optionally preloads routes for faster navigation between microfrontends (uses Speculation Rules API for Chromium browsers, fallback script for others)
- Optionally injects smooth view transition CSS for native browser page transitions
- Request arrives at the router Worker (e.g.,
https://example.com/docs/getting-started) - Route matching finds the best matching route configuration based on the pathname
- Mount prefix is stripped (e.g.,
/docs/getting-started→/getting-started) - Request is forwarded to the corresponding service binding Worker
- Response is transformed:
- HTML: Asset URLs in attributes (
href,src, etc.) are rewritten - CSS:
url()references to assets are rewritten - Redirects:
Locationheaders are rewritten to include mount prefix - Cookies:
Set-Cookiepaths are rewritten to include mount prefix
- HTML: Asset URLs in attributes (
- Response is returned to the client
Routes are matched using a prefix mount strategy:
/docsmatches/docs,/docs/,/docs/about,/docs/guides/intro, etc.- Routes with parameters like
/:tenant/dashboardmatch dynamically - The longest matching prefix wins (most specific route is chosen)
- Route specificity is used for deterministic tie-breaking
The router rewrites URLs to maintain proper asset resolution across microfrontends:
Before rewriting:
<!-- Served from /docs mount -->
<img src="/assets/logo.png" />
<link rel="stylesheet" href="/static/app.css" />After rewriting:
<img src="/docs/assets/logo.png" />
<link rel="stylesheet" href="/docs/static/app.css" />This ensures assets load correctly even though each microfrontend is deployed separately.
npm installIn your wrangler.jsonc (or wrangler.toml), configure service bindings to your Worker services:
Set the ROUTES environment variable as a JSON string. You can configure it in wrangler.jsonc:
{
"vars": {
"ROUTES": "{\"smoothTransitions\":true, \"routes\":[{\"binding\": \"APP1\", \"path\": \"/docs\", \"preload\":true}, {\"binding\": \"APP2\", \"path\": \"/dashboard\"}]}",
"ASSET_PREFIXES": "[\"/cf-assets/\", \"/media/\"]",
},
}ROUTESis required - defines your route mappingsASSET_PREFIXESis optional - adds custom asset prefixes (see Custom Asset Prefixes)
You can also set these via the Cloudflare Dashboard or Wrangler secrets.
The ROUTES variable can be either:
Simple array format:
[
{ "binding": "APP1", "path": "/docs" },
{ "binding": "APP2", "path": "/dashboard" }
]Object format with options:
{
"smoothTransitions": true,
"routes": [
{ "binding": "APP1", "path": "/docs", "preload": true },
{ "binding": "APP2", "path": "/dashboard" }
]
}| Option | Type | Description |
|---|---|---|
binding |
string |
Required. The service binding name (must match a binding in wrangler.jsonc) |
path |
string |
Required. Path expression (e.g., /docs, /:tenant/app, /api/:path*) |
preload |
boolean |
If true, preloads this route after DOM loads (only works for static mounts) |
smoothTransitions |
boolean |
If true, injects CSS for smooth view transitions (applies to all routes) |
Simple literal paths match as prefix mounts:
{"binding": "APP1", "path": "/docs"} // Matches /docs, /docs/, /docs/anything
{"binding": "APP2", "path": "/dashboard"} // Matches /dashboard, /dashboard/, /dashboard/userUse :name for dynamic path segments:
{"binding": "APP1", "path": "/:tenant"} // Matches /acme, /acme/dashboard
{"binding": "APP2", "path": "/:tenant/dashboard"} // Matches /acme/dashboard, /acme/dashboard/settingsUse * for zero or more segments, + for one or more:
{"binding": "APP1", "path": "/api/:path*"} // Matches /api, /api/users, /api/users/123
{"binding": "APP2", "path": "/app/:path+"} // Matches /app/users, /app/users/123 (but NOT /app)Use parentheses to add constraints:
{"binding": "APP1", "path": "/:tenant(a|b|c)"} // Only matches /a, /b, or /c
{"binding": "APP2", "path": "/:id(\\d+)"} // Only matches numeric IDsUse backslash to escape special characters:
{ "binding": "APP1", "path": "/\\(special\\)" } // Matches literal "/(special)"Use / to handle the root path:
{ "binding": "APP1", "path": "/" } // Matches / and serves as fallback for unmatched asset requestsThe router automatically rewrites URLs for these default asset path prefixes:
/assets//static//build//_astro//fonts/
You can add custom asset prefixes via the ASSET_PREFIXES environment variable in your wrangler.jsonc:
{
"vars": {
"ASSET_PREFIXES": "[\"/cf-assets/\", \"/media/\", \"/public/\"]",
"ROUTES": "[...]",
},
}Custom prefixes are merged with the default prefixes, so you get both. Duplicates are automatically removed.
Format: ASSET_PREFIXES must be a JSON array of strings. Each prefix:
- Should start with
/and end with/(will be normalized automatically) - Will be matched exactly as specified (case-sensitive)
- Can include any characters, but regex special characters are escaped
Example:
{
"vars": {
"ASSET_PREFIXES": "[\"/cf-assets/\", \"/my-custom-folder/\"]",
},
}This will result in URLs from /assets/, /static/, /build/, /_astro/, /fonts/, /cf-assets/, and /my-custom-folder/ all being rewritten.
The following HTML attributes are automatically rewritten when they contain absolute paths starting with asset prefixes:
href,src,poster,content,action,cite,formactionmanifest,ping,archive,code,codebase,data,urlsrcset(parsed and rewritten per URL)data-*attributes:data-src,data-href,data-url,data-srcset,data-background, etc.- Framework-specific:
component-url,astro-component-url,sveltekit-url,renderer-url background,xlink:href
CSS url() references to asset paths are automatically rewritten:
/* Before: */
background-image: url("/assets/bg.png");
/* After (when served from /docs mount): */
background-image: url("/docs/assets/bg.png");Favicon links are always rewritten, even if they don't match asset prefixes:
<!-- Before: -->
<link rel="icon" href="/favicon.ico" />
<!-- After (when served from /docs mount): -->
<link rel="icon" href="/docs/favicon.ico" />When an upstream service returns a redirect (3xx status), the router:
- Rewrites the
Locationheader to include the mount prefix - Rewrites
Set-Cookiepaths to scope cookies to the mount
Example:
Upstream redirects to /login → Router rewrites to /docs/login (if mount is /docs)
Cookies set with Path=/ are automatically rewritten to Path=/mount/ to prevent cookie collisions between microfrontends:
Before:
Set-Cookie: session=abc123; Path=/
After (when mount is /docs):
Set-Cookie: session=abc123; Path=/docs/
When preload: true is set on a static mount route, the router automatically preloads those routes to enable faster navigation. The router uses browser-specific optimization to provide the best performance for each browser:
For Chromium-based browsers, the router uses the Speculation Rules API - a modern, browser-native prefetching mechanism:
- Injects
<script type="speculationrules">into the<head>element - Browser handles prefetching automatically with optimal priority management
- Respects user preferences (battery saver, data saver modes)
- Uses per-document in-memory cache for faster access
- Not blocked by Cache-Control headers
- More efficient than JavaScript-based fetching
Example injected speculation rules:
{
"prefetch": [
{
"urls": ["/app1", "/app2", "/dashboard"]
}
]
}For browsers that don't yet support the Speculation Rules API, the router falls back to a JavaScript-based preload script:
- Injects
<script src="/mount/__mf-preload.js" defer></script>into the<body>element - Script fetches preload routes after DOM loads
- Uses external script (CSP-friendly) instead of inline JavaScript
- Uses
GETrequests withcredentials: "same-origin"andcache: "default"
Browser Detection:
The router automatically detects the browser from the User-Agent header and injects the appropriate preload mechanism. No configuration needed!
Limitations:
- Only works for static mounts (no dynamic parameters)
- Only preloads routes that are not the current route
- Static mounts must have
preload: truein their route configuration
Example Configuration:
{
"routes": [
{ "binding": "APP1", "path": "/app1", "preload": true },
{ "binding": "APP2", "path": "/app2", "preload": true },
{ "binding": "APP3", "path": "/:tenant/dashboard" } // Cannot preload (dynamic parameter)
]
}When a user visits /app1, the router will automatically preload /app2 (but not /app1 since that's the current route).
When smoothTransitions: true is enabled, the router injects CSS for smooth browser-native view transitions:
@supports (view-transition-name: none) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
animation-timing-function: ease-in-out;
}
main {
view-transition-name: main-content;
}
nav {
view-transition-name: navigation;
}
}This enables seamless transitions when navigating between microfrontends using the View Transition API.
wrangler.jsonc:
{
"services": [
{ "binding": "DOCS", "service": "docs-worker" },
{ "binding": "DASH", "service": "dashboard-worker" },
],
"vars": {
"ROUTES": "[{\"binding\": \"DOCS\", \"path\": \"/docs\"}, {\"binding\": \"DASH\", \"path\": \"/dashboard\"}]",
},
}wrangler.jsonc:
{
"services": [{ "binding": "TENANT_APP", "service": "tenant-worker" }],
"vars": {
"ROUTES": "[{\"binding\": \"TENANT_APP\", \"path\": \"/:tenant/app\"}]",
},
}Matches /acme/app, /corp/app, etc.
wrangler.jsonc:
{
"services": [
{ "binding": "APP1", "service": "app1-worker" },
{ "binding": "APP2", "service": "app2-worker" },
{ "binding": "ROOT", "service": "root-worker" },
],
"vars": {
"ROUTES": "{\"smoothTransitions\":true, \"routes\":[{\"binding\": \"ROOT\", \"path\": \"/\"}, {\"binding\": \"APP1\", \"path\": \"/app1\", \"preload\":true}, {\"binding\": \"APP2\", \"path\": \"/app2\", \"preload\":true}]}",
},
}# Start local development server
npm run dev
# Type check
npm run types
# Deploy to Cloudflare
npm run deployPrefix mounting allows each microfrontend to be developed independently without knowing its mount path. The router handles all the URL rewriting, so your apps can use absolute paths like /assets/logo.png and they'll work correctly regardless of mount.
The router uses progressive enhancement for route preloading:
- Chromium browsers get the modern Speculation Rules API, which is more efficient, respects user preferences, and provides better performance
- Other browsers get a JavaScript-based fallback that works reliably across all browsers
- This approach ensures optimal performance where supported, with graceful degradation elsewhere
The external preload script (/mount/__mf-preload.js) used for non-Chromium browsers is CSP-friendly and easier to debug than inline JavaScript. It also allows browsers to cache the preload logic.
Service bindings allow direct Worker-to-Worker communication without going through HTTP, reducing latency and enabling better integration with Cloudflare's edge network.
- Asset Prefixes: Default asset prefixes are always included. Custom prefixes can be added via the
ASSET_PREFIXESenvironment variable (see Custom Asset Prefixes above). - Preloading: Only works for static mounts (routes without parameters).
- Root Mount: The root route (
/) requires special handling and may not strip prefixes correctly for all asset requests. - CSS Parsing: CSS
url()rewriting uses regex, so complex CSS with nested functions may not be rewritten correctly. - HTML Parsing: Uses Cloudflare's HTMLRewriter, which may not handle all edge cases with malformed HTML.
- Check that your asset paths start with one of the supported prefixes (
/assets/,/static/, etc.) - Verify the mount prefix is being correctly prepended in the rewritten HTML
- Check browser console for 404 errors on asset requests
- Ensure your path expressions are correct (check escaping of special characters)
- Remember that routes match as prefixes, so
/docsmatches/docs/anything - Check route specificity - more specific routes win
- Verify that
Locationheaders are being rewritten correctly - Check that redirects from upstream services use absolute paths (not relative)
- Ensure cookies use
Path=/in the upstream service (the router will rewrite them) - Check that
Set-Cookieheaders are being processed correctly
Contributions are welcome! Please open an issue or pull request.
MIT
{ "services": [ { "binding": "APP1", // Binding name used in ROUTES config "service": "my-docs-worker", // Name of your Worker service }, { "binding": "APP2", "service": "my-dashboard-worker", }, ], }