Implement micro-frontend patterns that let independent teams build, deploy, and own frontend features without coordinating monolithic releases. Covers Module Federation, single-spa, Web Components, shared design systems, and the organizational patterns that make micro-frontends succeed.
Micro-frontend architecture enables independent, team-owned frontend applications to coexist and compose into a unified user experience, particularly when multiple teams develop and deploy frontend code in parallel. It becomes essential when a monolithic frontend application grows too large to manage, deploy, or scale effectively across teams, and when teams need autonomy without sacrificing consistency, integration, or performance.
A micro-frontend architecture treats each feature or domain as a self-contained application that can be developed, tested, and deployed independently. These modules—often referred to as *sub-apps*—are stitched together at runtime to form a single, cohesive frontend. The composition happens via a *host application* that dynamically loads sub-apps, typically using a module federation pattern or a shell with dynamic imports.
The host application defines the global layout, navigation, and shared services. Sub-apps register themselves with the host, either via a manifest file or a runtime registry, and the host loads them on demand. The most common approach uses Webpack 5’s ModuleFederationPlugin, which enables code sharing and remote module loading across build outputs.
To enable module federation, configure Webpack in both host and remote applications:
// webpack.config.js (host)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
entry: './src/index.tsx',
output: {
publicPath: 'https://cdn.example.com/',
filename: 'host.[contenthash].js',
},
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
products: 'products@https://cdn.example.com/products/remoteEntry.js',
users: 'users@https://cdn.example.com/users/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
'styled-components': { singleton: true, eager: true },
},
}),
],
};
// webpack.config.js (products sub-app)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
entry: './src/index.tsx',
output: {
publicPath: 'https://cdn.example.com/',
filename: 'products.[contenthash].js',
},
plugins: [
new ModuleFederationPlugin({
name: 'products',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/components/ProductList',
'./ProductDetail': './src/components/ProductDetail',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
};
The shared configuration ensures that a single instance of each library (e.g., React) is shared across all sub-apps and the host. The singleton: true option guarantees that if two sub-apps load different versions of React, only one version is used in the browser, preventing double-rendering and memory bloat.
At runtime, the host application uses import() to dynamically load sub-apps. The host may load them on route navigation, on-demand, or during initial render.
// App.tsx (host)
import { lazy, Suspense } from 'react';
const ProductList = lazy(() => import('products/ProductList'));
const UserDashboard = lazy(() => import('users/UserDashboard'));
function App() {
return (
<div>
<header>Navigation</header>
<Suspense fallback={<Spinner />}>
<ProductList />
</Suspense>
<Suspense fallback={<Spinner />}>
<UserDashboard />
</Suspense>
</div>
);
}
The import('products/ProductList') call resolves to a dynamic import from the remoteEntry.js file hosted at https://cdn.example.com/products/remoteEntry.js. This file contains metadata about the remote module and its dependencies.
When multiple teams ship their own versions of shared libraries, conflicts arise. The singleton: true strategy in Module Federation avoids these, but it does not solve all versioning issues.
If a sub-app requires [email protected] but the host uses [email protected], and both are loaded, the singleton strategy ensures only one React instance exists. However, components from the sub-app may not be compatible with the host’s version if the host expects 18.2.0 but receives 18.3.0.
A common failure mode is *version mismatch in shared libraries*, where a component rendered by a sub-app fails to update when the host updates, due to separate instances of the same library. This occurs when singleton: true is forgotten or when shared is defined inconsistently.
To avoid this, teams must coordinate on requiredVersion and version in the shared configuration. The eager: true option in shared ensures that a shared library is loaded immediately, even if not used in the initial render.
When a sub-app includes a library not shared with the host, it may load a different version than expected.
For example, a users sub-app uses [email protected], but the host expects 2.30.0. If the sub-app registers its own date-fns via exposes, and the host does not share it, two instances of date-fns exist—leading to incorrect date comparisons and type mismatches.
To resolve this, the host must explicitly declare shared libraries:
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
'date-fns': { singleton: true, requiredVersion: '^2.30.0' },
}
If the users sub-app uses [email protected], the host loads 2.30.0 and injects it into the shared registry. The sub-app then uses this version, even if it was built with 2.29.3.
Each sub-app is independently deployable. A team can release a new version of the products app without touching the host or the users app. This requires a consistent deployment strategy across teams.
Each sub-app is built and deployed to a CDN or a public static file server. The build process outputs a remoteEntry.js, main.js, and asset files (.css, .js, .png) to a versioned directory.
A typical deployment command:
npm run build
cp -r dist/* /var/www/cdn.example.com/products/v1.2.3/
The host application expects https://cdn.example.com/products/remoteEntry.js to be available. If the host requests https://cdn.example.com/products/remoteEntry.js and receives a 404, the sub-app fails to load, and the host logs:
Error: Failed to load remote entry for products
at ModuleFederationPlugin.loadRemote (webpack://host/./src/index.tsx:12:15)
at App (webpack://host/./src/App.tsx:25:12)
A common silent failure is *remote entry mismatch*: the remoteEntry.js file exists but contains a different name or exposes than expected. The host expects products but receives product, leading to a failed registration.
Sub-apps may be versioned independently. The host must handle versioning via query parameters, routes, or a version registry.
A version registry can be a simple JSON file served at /api/subapps.json:
{
"products": {
"latest": "https://cdn.example.com/products/v1.3.0/remoteEntry.js",
"v1.2.3": "https://cdn.example.com/products/v1.2.3/remoteEntry.js"
},
"users": {
"latest": "https://cdn.example.com/users/v2.1.0/remoteEntry.js"
}
}
The host fetches this registry on startup and selects the appropriate remote entry based on version or feature flags.
Feature flags can control which sub-apps are active and which version to use. A featureFlags object in the host’s initial state determines which sub-apps to load.
// Initial state from server
{
featureFlags: {
productCatalog: true,
userDashboard: false,
analytics: true,
},
subAppVersions: {
products: 'v1.2.3',
users: 'latest'
}
}
On startup, the host conditionally loads sub-apps based on these flags. If userDashboard is false, the UserDashboard component is not imported or rendered.
When a sub-app fails to load, the host must provide a fallback experience. Common failure modes include:
remoteEntry.js not found or malformedreact version mismatch)The host application must wrap dynamic imports in try/catch and provide fallback UI:
function LazyLoad({ module, fallback }) {
const [Component, setComponent] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
import(module)
.then((mod) => setComponent(() => mod.default))
.catch((err) => setError(err));
}, [module]);
if (error) {
return (
<div className="error">
<p>Failed to load {module}</p>
<pre>{error.message}</pre>
</div>
);
}
if (!Component) {
return <div className="loading">{fallback}</div>;
}
return <Component />;
}
When a sub-app fails to initialize, it can emit an error to the host via window.postMessage:
// In the products sub-app
window.addEventListener('load', () => {
try {
// Initialize app
render(<App />, document.getElementById('root'));
} catch (err) {
window.parent.postMessage(
{
type: 'micro-frontend-error',
subApp: 'products',
error: {
message: err.message,
stack: err.stack,
timestamp: Date.now(),
},
},
'*'
);
}
});
The host listens for these messages and logs them to a central error tracking service or displays them in a UI overlay.
Sub-apps may need to communicate with the host and with each other. The host can expose APIs through a global window.microFrontend object:
window.microFrontend = {
navigateTo: (path) => {
window.location.href = path;
},
notify: (message, level = 'info') => {
console[level](message);
// Optional: send to logging service
},
getState: () => {
return window.appState;
},
setState: (state) => {
window.appState = { ...window.appState, ...state };
window.dispatchEvent(new CustomEvent('statechange', { detail: state }));
},
};
Sub-apps use this API to interact with the host:
// In users sub-app
window.microFrontend.navigateTo('/products');
window.microFrontend.notify('User profile updated', 'success');
window.addEventListener('statechange', (e) => {
console.log('State changed:', e.detail);
});
For complex communication, sub-apps can use postMessage to exchange data:
// Send data from products to users
window.parent.postMessage(
{
type: 'product:selected',
productId: 123,
productName: 'Wireless Headphones',
},
'*'
);
// Receive events in the users sub-app
window.addEventListener('message', (event) => {
if (event.data.type === 'product:selected') {
// Update user dashboard with selected product
renderProductDetails(event.data);
}
});
This pattern enables rich, cross-app interactions without tight coupling.
Each sub-app has its own CI/CD pipeline, often triggered by git push to main. A typical GitHub Actions workflow:
# .github/workflows/deploy.yml
name: Deploy Sub-App
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- run: npm run build
- run: |
mkdir -p /tmp/deploy
cp -r dist/* /tmp/deploy/
aws s3 sync /tmp/deploy/ s3://cdn.example.com/products/${{ github.sha }}/
- run: |
aws s3 cp /tmp/deploy/remoteEntry.js s3://cdn.example.com/products/remoteEntry.js
aws s3 cp /tmp/deploy/remoteEntry.js s3://cdn.example.com/products/latest/remoteEntry.js
The host application is updated less frequently, often with a new version every few weeks. When a new host version is released, it must be tested with all currently deployed sub-apps to ensure compatibility.
404 on remoteEntry.js is a silent killer. Use fetch in dev tools to confirm.react or styled-components coexist—check window.React and window.StyledComponents.import() or window.load are not logged in the host.remoteEntry.js while sub-app has new version.exposes in webpack.config.js: A component is not available in the remote module.product:selected, but host does not listen.A micro-frontend architecture succeeds when teams can ship independently, and the user sees a single app despite multiple deployments, builds, and lifecycles. The key is consistency in configuration, clear contracts between host and sub-apps, and robust error handling.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy