ccept runtime composition costs to unlock continuous delivery at scale.
Core Solution
Implementing a micro-frontend architecture requires deliberate separation of concerns, explicit contracts, and runtime composition. The industry standard for runtime integration is Webpack Module Federation, though Vite-based alternatives exist. This guide focuses on a production-ready TypeScript implementation using Module Federation, as it provides mature version negotiation, shared dependency resolution, and framework-agnostic composition.
Step 1: Define the Shell Application
The shell is the host application. It handles routing, layout, shared UI chrome (nav, footer), and remote resolution. It should contain minimal business logic.
// shell/src/app.routes.ts
import { Routes } from '@angular/router'; // or React Router / Vue Router equivalent
import { loadRemoteModule } from '@module-federation/enhanced/runtime';
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => loadRemoteModule({
remote: 'dashboardApp',
exposedModule: './Dashboard'
}).then(m => m.default)
},
{
path: 'settings',
loadComponent: () => loadRemoteModule({
remote: 'settingsApp',
exposedModule: './Settings'
}).then(m => m.default)
}
];
The shell declares which remotes it consumes and which shared dependencies it provides.
// shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
dashboardApp: 'dashboardApp@http://localhost:3001/remoteEntry.js',
settingsApp: 'settingsApp@http://localhost:3002/remoteEntry.js'
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
'@shared/ui': { singleton: true, requiredVersion: '^1.0.0' }
}
})
]
};
Each remote exposes specific entry points and declares its own shared dependencies. The exposes field defines the public API contract.
// dashboardApp/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'dashboardApp',
filename: 'remoteEntry.js',
exposes: {
'./Dashboard': './src/Dashboard.tsx'
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' }
}
})
]
};
Step 4: Implement Explicit Contracts
Micro-frontends must communicate through typed interfaces, not shared global state. Define contracts in a shared package or inline.
// contracts/src/dashboard.types.ts
export interface DashboardProps {
userId: string;
theme: 'light' | 'dark';
onNavigate: (path: string) => void;
}
export interface DashboardWidget {
id: string;
title: string;
component: React.ComponentType<{ data: unknown }>;
}
// dashboardApp/src/Dashboard.tsx
import React from 'react';
import type { DashboardProps, DashboardWidget } from '@contracts/dashboard.types';
export const Dashboard: React.FC<DashboardProps> = ({ userId, theme, onNavigate }) => {
// Implementation isolated to this remote
return (
<section data-remote="dashboardApp">
<h1>Dashboard</h1>
<button onClick={() => onNavigate('/settings')}>Go to Settings</button>
</section>
);
};
export default Dashboard;
Step 5: Handle Shared Dependencies & Version Negotiation
Module Federation resolves shared dependencies at runtime. If the shell provides react@18.2.0 and the remote requests ^18.0.0, the shell's version is used. Mismatched major versions trigger fallback loading. Always pin peer dependencies and use semantic version ranges in shared config.
Step 6: Implement CSS Isolation
Style leakage is the most common production failure. Use one of three strategies:
- CSS Modules / Scoped Styles: Automatic class name hashing.
- Shadow DOM: True encapsulation, but requires careful event retargeting.
- Namespace Prefixing:
data-remote="dashboardApp" selectors with strict specificity rules.
/* dashboardApp/styles.css */
[data-remote="dashboardApp"] .widget {
/* isolated styles */
}
Step 7: Error Boundaries & Fallbacks
Remote loading can fail. Wrap remote mounts in error boundaries.
import React, { Suspense, ErrorBoundary } from 'react';
export const RemoteWrapper: React.FC<{ fallback: React.ReactNode; children: React.ReactNode }> = ({ fallback, children }) => (
<ErrorBoundary fallback={<div>Module failed to load</div>}>
<Suspense fallback={fallback}>
{children}
</Suspense>
</ErrorBoundary>
);
Pitfall Guide
-
Over-sharing dependencies: Declaring every library as shared: true forces version alignment across teams, creating merge bottlenecks. Only share core runtime libraries (React, Vue, Angular, router, state manager). Let remotes bundle their own utilities.
-
Tight coupling via global state: Sharing Redux stores, Vuex, or NgRx across remotes defeats deployment independence. Use explicit prop passing, custom event buses, or lightweight cross-app communication via window.postMessage or WebSockets for cross-cutting concerns.
-
Ignoring CSS isolation: Without scoping, z-index conflicts, specificity wars, and reset style collisions break layouts. Enforce a naming convention or adopt Shadow DOM for critical UI boundaries. Audit styles in CI with stylelint rules targeting remote prefixes.
-
Neglecting error boundaries & fallbacks: A failed remote module can crash the entire shell. Always wrap loadRemoteModule calls in Suspense + ErrorBoundary. Implement retry logic with exponential backoff for network failures.
-
Treating it as a component library: Micro-frontends are deployable applications, not UI primitives. Components belong in shared design systems. Micro-frontends own routing, state, and business logic for their domain. Mixing the two creates architectural ambiguity.
-
Skipping versioning strategy: Breaking changes in exposed modules propagate instantly to all consumers. Implement contract versioning (./Dashboard/v1) and deprecation policies. Use semantic versioning in shared config and enforce it in CI.
-
Performance blindness: Multiple remote entries, waterfall requests, and redundant runtime loaders increase TTI. Implement route-based prefetching, bundle analysis per remote, and performance budgets. Cache remoteEntry.js aggressively with immutable headers.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup scaling from 2 to 5 teams | Build-time MFE (CI composition) | Lower runtime complexity, faster initial setup, easier debugging | Low infrastructure cost, moderate CI maintenance |
| Enterprise legacy migration with 8+ teams | Runtime MFE (Module Federation) | Enables incremental adoption, independent deploys, framework heterogeneity | Higher runtime overhead, requires contract governance |
| Multi-vendor ecosystem with external partners | Runtime MFE + CDN-hosted remotes | Strict isolation, version pinning, no shared build pipeline | CDN costs, strict SLA monitoring required |
Configuration Template
// webpack.config.js (Standard Production Setup)
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
module.exports = {
mode: 'production',
entry: './src/index.ts',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
publicPath: 'auto'
},
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
remoteApp: 'remoteApp@https://cdn.example.com/remoteApp/remoteEntry.js'
},
exposes: {
'./HostLayout': './src/layouts/HostLayout.tsx'
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
'react-router-dom': { singleton: true, requiredVersion: '^6.0.0' }
}
})
],
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/
},
{
test: /\.css$/,
use: ['style-loader', 'css-modules-loader']
}
]
}
};
Quick Start Guide
- Initialize shell and remote: Run
npx create-mf-app@latest shell and npx create-mf-app@latest remote (select TypeScript + React).
- Configure remotes: Update
shell/webpack.config.js to point to http://localhost:3001/remoteEntry.js and expose ./App in the remote config.
- Define contract: Create
shared/contracts.ts with interface RemoteProps { title: string; } and import in both shell and remote.
- Mount remote: In
shell/src/App.tsx, use loadRemoteModule inside a route or component wrapper with Suspense fallback.
- Run: Execute
npm run start in both directories. The shell loads the remote at runtime. Verify network tab shows single remoteEntry.js fetch and zero duplicate React bundles.