In a traditional monolithic front-end, coordinating releases across multiple teams introduces significant organisational friction. Teams often find themselves bottlenecked by shared deployment pipelines, where a single bug in one feature can block unrelated updates from shipping to production. Additionally, managing massive merge conflicts, coordinating deployment windows, and aligning testing cycles across disparate product areas frequently lead to slower release cadences and diluted domain ownership. Micro-frontends address these challenges by decoupling the frontend into autonomous modules, enabling each team to deploy updates on their own schedule without requiring cross-organisational synchronisation.
However, traditional micro-frontend setups often introduce latency, complex Cross-Origin Resource Sharing (CORS) configurations, and complicated deployment pipelines.
By combining Module Federation (@module-federation/vite) with Cloudflare Workers—utilizing Service Bindings, @cloudflare/vite-plugin, and Wrangler—you can create a scalable, performant micro-frontend monorepo.
This approach resolves remote micro-frontends (MFEs) via a same-origin proxy mechanism directly on Cloudflare’s edge network, completely avoiding CORS issues in production while preserving a native development experience locally.
Architecture Overview
In this example of monorepo architecture, we split our system into a single Host Shell and multiple Remote micro-frontends (remote-a and remote-b):
├── apps/
│ └── host/ # Host shell (Vite + Cloudflare Worker)
└── mfes/
├── remote-a/ # Exposed Micro-Frontend A (Assets-only Worker)
└── remote-b/ # Exposed Micro-Frontend B (Assets-only Worker)
The Production Same-Origin Proxy Pattern
In production, the host worker acts as an API gateway and static asset router. When a user requests /remote-a/remoteEntry.js, the host worker intercepts the route and proxies the request to the remote-a Worker via Cloudflare Service Bindings.
This offers several operational, performance, and cost advantages:
- Zero CORS Issues: Because all micro-frontends are served under the host’s primary domain, the browser views them as same-origin. This removes the need for complex, fragile, and potentially insecure cross-origin HTTP header management.
- Minimal Latency: Cloudflare Service Bindings allow Workers to execute within the same thread on the same physical edge server [1.2.2]. This allows them to pass requests internally with near-zero network overhead, routing traffic instantly without going back out to the public internet.
-
No Request Duplication Costs (Free Subrequests): On Cloudflare Workers, you only pay for the initial user request hitting the Host Worker. The subsequent internal calls to the remote Workers via
env.REMOTE_Aorenv.REMOTE_Bare treated as internal subrequests, which carry zero additional request fees. This allows you to split your application into as many micro-frontends as required without multiplying your Cloudflare request charges. - Elimination of Gateway Infrastructure Overhead: Historically, avoiding CORS in a micro-frontend architecture required provisioning, patching, and maintaining a cluster of dedicated reverse-proxy servers (e.g., NGINX running on virtual machines) or configuring expensive cloud load balancers. Cloudflare Workers scale to zero, eliminating the maintenance effort and baseline idle-VM costs associated with dedicated routing infrastructure.
-
Robust Environment Isolation: The host can map service bindings dynamically across environments (e.g., routing
/remote-a/*toremote-a-qain the staging environment andremote-a-prodin production). This permits deployment pipelines to deliver changes safely without running separate physical infrastructure stacks for each target environment.
1. Configuring the Remote Micro-Frontends
Each remote MFE functions as an assets-only Worker. It builds its static files—including the Module Federation container entrypoint (remoteEntry.js)—and serves them directly.
Vite Configuration (mfes/remote-a/vite.config.ts)
The remote uses the @module-federation/vite plugin to bundle components and expose them. It also declares shared dependencies like react and react-dom as singletons to prevent multiple React runtimes from loading in the browser.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { federation } from '@module-federation/vite'
export default defineConfig({
plugins: [
react(),
federation({
dts: false,
name: 'remoteA',
filename: 'remoteEntry.js', // Emitted container entrypoint
exposes: {
'./Widget': './src/Widget.tsx', // Exposed remote component
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
build: {
// Module Federation relies on top-level await; esnext target is required
target: 'esnext',
},
server: {
port: 5174,
strictPort: true,
cors: { origin: '*' }, // Required for local cross-origin loading in dev
},
})
Wrangler Configuration (mfes/remote-a/wrangler.jsonc)
For the remote micro-frontends, we configure them as assets-only deployments. A crucial setting is "not_found_handling": "none". Unlike single-page applications (SPAs) which fallback to index.html on missing assets, MFE chunk directories must strictly return a 404 Not Found if a chunk is missing to prevent the federated runtime from swallowing errors.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-react-remote-a",
"compatibility_date": "2026-06-16",
"assets": {
"directory": "./dist",
"not_found_handling": "none" // Crucial: return 404s for missing asset chunks
},
"workers_dev": true,
"env": {
"qa": {},
"prod": {}
}
}
2. Configuring the Host Worker
The host application consists of a React SPA built with Vite, run inside the Workers environment locally and in production via the official @cloudflare/vite-plugin.
Vite Configuration (apps/host/vite.config.ts)
The host references the @cloudflare/vite-plugin to run worker-level code locally in a workerd environment.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { cloudflare } from '@cloudflare/vite-plugin'
import { federation } from '@module-federation/vite'
export default defineConfig({
plugins: [
react(),
cloudflare(),
federation({
dts: false,
name: 'host',
remotes: {}, // Configured dynamically at runtime instead of statically
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
shareStrategy: 'loaded-first',
}),
],
build: {
target: 'esnext',
},
})
Wrangler Configuration (apps/host/wrangler.jsonc)
The host wrangler config binds the remote Workers using services and sets up run_worker_first routing. The run_worker_first configuration routes the matching path prefixes to the worker code instead of attempting to look them up inside the host’s static asset bucket.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-react",
"main": "worker/index.ts",
"compatibility_date": "2026-06-16",
"assets": {
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": [
"/remote-a/*",
"/remote-b/*",
"/api/*"
]
},
"services": [
{
"binding": "REMOTE_A",
"service": "cloudflare-react-remote-a"
},
{
"binding": "REMOTE_B",
"service": "cloudflare-react-remote-b"
}
],
"env": {
"qa": {
"services": [
{ "binding": "REMOTE_A", "service": "cloudflare-react-remote-a-qa" },
{ "binding": "REMOTE_B", "service": "cloudflare-react-remote-b-qa" }
]
},
"prod": {
"services": [
{ "binding": "REMOTE_A", "service": "cloudflare-react-remote-a-prod" },
{ "binding": "REMOTE_B", "service": "cloudflare-react-remote-b-prod" }
]
}
}
}
3. Implementing the Host Worker Entrypoint
The host worker’s router handles routing for local assets, remote proxy routing, and server-side logic APIs. When a route begins with /remote-a/* or /remote-b/*, the request is modified and forwarded via Service Bindings to the correct remote worker.
// apps/host/worker/index.ts
export interface Env {
ASSETS: Fetcher;
REMOTE_A: Fetcher;
REMOTE_B: Fetcher;
}
function withAssetCache(res: Response, pathname: string): Response {
const out = new Response(res.body, res);
// Immutable caching for content-hashed production asset bundles
out.headers.set(
"Cache-Control",
pathname.startsWith("/assets/")
? "public, max-age=31536000, immutable"
: "no-cache",
);
return out;
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
// Dynamic routing maps to remote service bindings
const remoteRoutes = [
{ prefix: "/remote-a", service: env.REMOTE_A },
{ prefix: "/remote-b", service: env.REMOTE_B },
];
for (const { prefix, service } of remoteRoutes) {
if (url.pathname === prefix || url.pathname.startsWith(prefix + "/")) {
const target = new URL(request.url);
// Strip the path prefix so the remote worker receives its root path
target.pathname = url.pathname.slice(prefix.length) || "/";
return service.fetch(new Request(target, request));
}
}
if (url.pathname.startsWith("/api/")) {
return Response.json({ name: "Cloudflare Workers Edge API" });
}
// Default: Serve static front-end assets for the host SPA
return withAssetCache(await env.ASSETS.fetch(request), url.pathname);
},
} satisfies ExportedHandler<Env>;
4. Resolving Micro-Frontends Dynamically at Runtime
Statically defining remote URLs in vite.config.ts forces a rebundling step for every distinct environment deployment. To achieve a single-build deployment model, register your remotes programmatically using @module-federation/runtime.
Endpoint Mapping (apps/host/src/moduleFederation/mfeUrls.ts)
The remote resolution strategy shifts based on whether the code executes in local development or a production Worker. Vite's standard import.meta.env.DEV variable determines the target origin.
const devRemoteEntry = (envOverride: string | undefined, fallback: string) => {
const origin = (envOverride ?? fallback).replace(/\/$/, '');
return `${origin}/remoteEntry.js`;
}
export const mfeUrls: Record<string, string> = {
remoteA: import.meta.env.DEV
? devRemoteEntry(import.meta.env.VITE_REMOTE_A_URL, 'http://localhost:5174')
: '/remote-a/remoteEntry.js',
remoteB: import.meta.env.DEV
? devRemoteEntry(import.meta.env.VITE_REMOTE_B_URL, 'http://localhost:5175')
: '/remote-b/remoteEntry.js',
};
Initializing the Runtime (apps/host/src/moduleFederation/moduleFederationRuntime.ts)
Before importing any remote React code, initialize the federation container.
import { registerRemotes, loadRemote } from '@module-federation/runtime'
import { mfeUrls } from './mfeUrls'
let initialized = false;
export const initializeModuleFederationRuntime = (): void => {
if (initialized) return;
registerRemotes(
Object.entries(mfeUrls).map(([name, entry]) => ({
name,
type: 'module',
entry,
})),
{ force: true }
);
initialized = true;
};
export const loadRemoteComponent = async <TProps = Record<string, never>>(
remoteId: string
): Promise<{ default: React.ComponentType<TProps> }> => {
initializeModuleFederationRuntime();
const remoteModule = await loadRemote<{ default: React.ComponentType<TProps> }>(remoteId);
if (!remoteModule) {
throw new Error(`Remote "${remoteId}" failed to resolve.`);
}
return remoteModule;
};
5. Integrating Remote Components in React
To handle potential loading failures gracefully (such as network drops or remote MFE redeployments), load the federated components dynamically using React.lazy and enclose them in a React Error Boundary.
// apps/host/src/moduleFederation/RemoteWidget.tsx
import { Component, lazy, Suspense, ReactNode } from 'react'
import { loadRemoteComponent } from './moduleFederationRuntime'
type WidgetProps = { greeting?: string }
const lazyByRemoteId = new Map<string, any>()
const getRemoteWidget = (remoteId: string) => {
let component = lazyByRemoteId.get(remoteId);
if (!component) {
component = lazy(() => loadRemoteComponent<WidgetProps>(remoteId));
lazyByRemoteId.set(remoteId, component);
}
return component;
};
class RemoteErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
if (this.state.error) {
return (
<div className="error-card">
<h4>Failed to load Remote Component</h4>
<p>{this.state.error.message}</p>
</div>
);
}
return this.props.children;
}
}
export function RemoteWidget({ remoteId, greeting }: { remoteId: string; greeting?: string }) {
const Widget = getRemoteWidget(remoteId);
return (
<RemoteErrorBoundary>
<Suspense fallback={<div>Loading remote MFE components...</div>}>
<Widget greeting={greeting} />
</Suspense>
</RemoteErrorBoundary>
);
}
Conclusion
By deploying your monorepo micro-frontends with Module Federation, @cloudflare/vite-plugin, and Wrangler, you combine the agility of modern UI composition with the performance profile of Cloudflare's edge network. Using same-origin proxying via Service Bindings avoids complex client CORS setups entirely. When combined with dynamic runtime federation entry resolution, this setup simplifies local development workflows and provides robust routing capabilities in production environments.
Special thanks to Sultanyaron for your amazing example https://github.com/Sultanyaron/vite-module-federation-cloudflare-deployment. You inspired this article.
Top comments (0)