Module Federation in Practice
What Module Federation Actually Does
Module Federation lets separately built and deployed JavaScript applications share code at runtime. No npm publishing. No build-time integration. Application A loads a component from Application B's deployed bundle as if it were a local import.
This is the technology that makes client-side micro-frontends practical. Before Module Federation, sharing code between separately deployed apps required UMD scripts on a CDN, iframe embedding, or custom loaders. Module Federation makes it a webpack/rspack config option.
Think of a franchise restaurant chain. Each location (remote app) prepares its own menu items independently. The delivery service (Module Federation) picks up dishes from any location and delivers them to any customer (host app). The customer does not know or care which kitchen made the dish -- they just ordered it from the app. Each kitchen can update its recipes independently without coordinating with other locations.
Core Concepts
Host: The application that consumes remote modules. This is typically the shell app that provides routing and layout.
Remote: An application that exposes modules for other applications to consume. Each micro-frontend is a remote.
Shared: Dependencies that should be loaded once and shared across all host and remote applications. React is the most common shared dependency.
Container: The runtime manifest that tells the host where to find a remote's exposed modules and what shared dependencies it needs.
Host/Remote Setup
Remote Configuration (Checkout App)
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "checkout",
filename: "remoteEntry.js",
exposes: {
"./CheckoutFlow": "./src/components/CheckoutFlow",
"./CartSummary": "./src/components/CartSummary",
},
shared: {
react: { singleton: true, requiredVersion: "^19.0.0" },
"react-dom": { singleton: true, requiredVersion: "^19.0.0" },
},
}),
],
};
exposes declares which modules the checkout app makes available. filename: "remoteEntry.js" is the manifest file the host will load.
Host Configuration (Shell App)
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
checkout: "checkout@https://checkout.example.com/remoteEntry.js",
dashboard: "dashboard@https://dashboard.example.com/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^19.0.0" },
"react-dom": { singleton: true, requiredVersion: "^19.0.0" },
},
}),
],
};
Now the host can import from remotes as if they were local:
import { lazy, Suspense } from "react";
const CheckoutFlow = lazy(() => import("checkout/CheckoutFlow"));
const CartSummary = lazy(() => import("checkout/CartSummary"));
function CheckoutPage() {
return (
<Suspense fallback={<CheckoutSkeleton />}>
<CartSummary />
<CheckoutFlow />
</Suspense>
);
}
At runtime, import("checkout/CheckoutFlow") fetches the remote entry from the checkout app's deployed URL, resolves shared dependencies, and loads the component.
Shared Dependency Negotiation
This is where Module Federation gets sophisticated. When the host and a remote both declare React as shared, Module Federation performs version negotiation:
- Same version range → share a single copy
- Compatible version ranges (e.g.,
^19.0.0and^19.1.0) → use the higher version - Incompatible versions → load both (unless
singleton: true, which forces one copy and warns)
shared: {
react: {
singleton: true,
requiredVersion: "^19.0.0",
strictVersion: false,
eager: false,
},
"react-dom": {
singleton: true,
requiredVersion: "^19.0.0",
},
"@tanstack/react-query": {
singleton: true,
requiredVersion: "^5.0.0",
},
}
Setting eager: true on shared modules bundles them into the entry chunk instead of loading them asynchronously. This breaks the sharing mechanism because the eager copy loads before Module Federation can negotiate. Only use eager: true for the shell app's bootstrap file, never for shared dependencies.
The Bootstrap Pattern
Module Federation requires an async boundary before shared modules are used. The standard pattern:
// index.ts (entry point)
import("./bootstrap");
// bootstrap.tsx (actual app)
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(<App />);
The dynamic import("./bootstrap") creates the async boundary that Module Federation needs to negotiate shared dependencies before the app renders.
Dynamic Remote Loading
Static remotes are defined in webpack config. But what if you want to load remotes dynamically -- for example, feature flags that determine which micro-frontends are active?
async function loadRemoteModule(
scope: string,
module: string,
url: string
): Promise<{ default: React.ComponentType }> {
await loadRemoteEntry(url);
const container = (window as Record<string, unknown>)[scope] as {
init: (shareScope: unknown) => Promise<void>;
get: (module: string) => Promise<() => { default: React.ComponentType }>;
};
await container.init(__webpack_share_scopes__.default);
const factory = await container.get(module);
return factory();
}
async function loadRemoteEntry(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = url;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load remote: ${url}`));
document.head.appendChild(script);
});
}
Usage:
function DynamicMicroFrontend({ name, url, module }: MicroFrontendConfig) {
const [Component, setComponent] = useState<React.ComponentType | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
loadRemoteModule(name, module, url)
.then((mod) => setComponent(() => mod.default))
.catch(setError);
}, [name, url, module]);
if (error) return <MicroFrontendError name={name} error={error} />;
if (!Component) return <MicroFrontendSkeleton />;
return <Component />;
}
This enables scenarios like:
- A/B testing different micro-frontend versions
- Feature flags that enable/disable micro-frontends
- Multi-tenant apps where each tenant gets different micro-frontends
Module Federation 2.0
Module Federation 2.0 (available via @module-federation/enhanced) introduces a runtime API that works with any bundler (webpack, rspack, Vite) and adds capabilities beyond the original webpack plugin.
Key improvements:
- Runtime API: Load and manage remotes programmatically, not just via webpack config
- Type generation: Automatic TypeScript type generation for remote modules
- Manifest protocol: A standardized manifest format replacing
remoteEntry.js - Preloading: Prefetch remote modules before they are needed
- Snapshot: Version pinning for predictable deployments
import { init, loadRemote } from "@module-federation/enhanced/runtime";
init({
name: "shell",
remotes: [
{
name: "checkout",
entry: "https://checkout.example.com/mf-manifest.json",
},
{
name: "dashboard",
entry: "https://dashboard.example.com/mf-manifest.json",
},
],
shared: {
react: { version: "19.0.0", scope: "default", lib: () => React, shareConfig: { singleton: true } },
"react-dom": { version: "19.0.0", scope: "default", lib: () => ReactDOM, shareConfig: { singleton: true } },
},
});
const CheckoutFlow = lazy(() =>
loadRemote("checkout/CheckoutFlow") as Promise<{ default: React.ComponentType }>
);
Module Federation 2.0 type generation creates .d.ts files for remote modules, giving you autocomplete and type checking across micro-frontend boundaries. But these types are generated at build time of the remote and may become stale if the remote deploys a breaking change. Treat generated types as a development aid, not a runtime guarantee. Always handle the case where a remote module's interface does not match expectations.
State Sharing Between Federated Modules
Federated modules need to share some state (authenticated user, theme, locale) without tight coupling.
Shared Context via Host
The host provides shared contexts. Remotes consume them:
// Host: provides the shared context
function App() {
return (
<AuthProvider>
<ThemeProvider>
<Router>
<Suspense fallback={<ShellSkeleton />}>
<Routes />
</Suspense>
</Router>
</ThemeProvider>
</AuthProvider>
);
}
// Remote: consumes the context
function CheckoutFlow() {
const { user } = useAuth();
const { theme } = useTheme();
return <div data-theme={theme}>Welcome, {user.name}</div>;
}
This works because the remote shares the same React instance as the host (via the singleton shared config). React Context traverses the component tree regardless of which bundle rendered the component.
React Context relies on React's internal fiber tree. If the host and remote use different React instances (because sharing is not configured or versions are incompatible), Context will not propagate across the boundary. The remote will see null from useContext and throw an error.
Event-based State for Loose Coupling
When you cannot guarantee shared React instances (different frameworks, iframe isolation):
type SharedStateEvent = {
type: "AUTH_CHANGED";
payload: { user: User | null };
} | {
type: "THEME_CHANGED";
payload: { theme: "light" | "dark" };
} | {
type: "LOCALE_CHANGED";
payload: { locale: string };
};
function createEventBus() {
const listeners = new Map<string, Set<(payload: unknown) => void>>();
return {
emit(event: SharedStateEvent) {
const handlers = listeners.get(event.type);
handlers?.forEach((handler) => handler(event.payload));
},
on<T extends SharedStateEvent["type"]>(
type: T,
handler: (payload: Extract<SharedStateEvent, { type: T }>["payload"]) => void
) {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type)!.add(handler as (payload: unknown) => void);
return () => listeners.get(type)?.delete(handler as (payload: unknown) => void);
},
};
}
Testing Strategies
Testing micro-frontends has three levels:
For integration testing, run remotes locally with a test configuration:
// test.config.ts
const testRemotes = {
checkout: `checkout@http://localhost:3001/remoteEntry.js`,
dashboard: `dashboard@http://localhost:3002/remoteEntry.js`,
};
- 1Always use the async bootstrap pattern -- static imports before Module Federation negotiation break sharing
- 2Mark React and ReactDOM as singleton shared dependencies with compatible version ranges
- 3Use Module Federation 2.0 runtime API for dynamic remote loading and type generation
- 4Test at three levels: unit (each remote), integration (host + one remote), E2E (full system)
- 5Keep shared state minimal -- auth, theme, locale via host Context; feature state stays in remotes
| What developers do | What they should do |
|---|---|
| Setting eager: true on shared React to avoid the async bootstrap pattern Eager loading bundles React into the entry chunk before Module Federation can negotiate versions. This means the first app to load forces its React version on everyone else, even if a remote has a more compatible version. | Using the async bootstrap pattern (dynamic import of bootstrap.tsx) |
| Each micro-frontend defines its own shared dependency versions without coordination Uncoordinated shared versions cause unpredictable behavior. Module Federation's negotiation is deterministic but only if versions are compatible. A shared config ensures all remotes agree on acceptable version ranges. | A shared configuration package or convention that aligns shared dependency versions |
| No error boundaries around remote module loading Remote modules load over the network and can fail (deploy in progress, CDN outage, version mismatch). Without error boundaries, one failed micro-frontend crashes the entire app. | Suspense + error boundaries wrapping every remote module mount point |