Decompose monolithic frontends into independently deployable units. Covers composition patterns, module federation, shared state management, and the patterns that let multiple teams ship frontend features independently.
Micro-frontend architecture is a strategy for structuring large frontend applications by decomposing them into independently deliverable, loosely coupled modules. It matters when teams grow beyond a single monolithic codebase, when different teams need autonomy over their features, and when deploying individual parts of a web app without blocking on others.
A micro-frontend system partitions a single web application into independent units—each managed by a dedicated team, built with its own tools, and deployed independently. The host application, or "shell," orchestrates these units at runtime. The key is that each micro-frontend is a self-contained module with its own code, build process, and deployment lifecycle.
The shell defines the layout and navigation, and loads micro-frontends dynamically. It typically renders a <div> or <section> where each micro-frontend mounts itself. The shell may also provide shared dependencies such as routing, state management, or theming.
A typical shell uses document.createElement('div') to create a mount point, then loads a micro-frontend via import() or fetch():
// shell.js
import { loadModule } from 'micro-frontend-loader';
const mountPoints = {
header: document.querySelector('#header'),
sidebar: document.querySelector('#sidebar'),
main: document.querySelector('#main'),
footer: document.querySelector('#footer'),
};
// Load micro-frontend for user profile
loadModule('profile', 'https://profile.example.com/micro-frontend.js')
.then(module => module.render({ target: mountPoints.profile }));
// Load multiple micro-frontends in parallel
Promise.all([
loadModule('cart', 'https://cart.example.com/micro-frontend.js'),
loadModule('notifications', 'https://notifications.example.com/micro-frontend.js'),
])
.then(([cart, notifications]) => {
cart.render({ target: mountPoints.main });
notifications.render({ target: mountPoints.sidebar });
});
Each micro-frontend exposes a render function that accepts a target DOM node and config. The render function may be a simple ReactDOM.render() call or a full framework bootstrapping process.
When multiple micro-frontends use the same library, they may each ship their own copy. This leads to duplication and version mismatches. To avoid this, the shell can provide shared dependencies.
The shell maintains a registry of shared modules. It checks for available versions of libraries and loads them only once. For example, React may be provided by the shell, and each micro-frontend uses the same instance.
// shell.js
const shared = {
react: {
version: '18.2.0',
factory: () => window.React,
},
'react-dom': {
version: '18.2.0',
factory: () => window.ReactDOM,
},
'lodash': {
version: '4.17.21',
factory: () => window._,
},
};
// During module loading, the loader checks if a shared dependency is available
const loadModule = (name, url) => {
const module = await import(url);
// Check for shared dependencies
const dependencies = module.dependencies || [];
const missing = dependencies.filter(dep => !shared[dep]);
// Load missing shared dependencies
await Promise.all(missing.map(dep => loadShared(dep)));
// Apply shared modules to the module
module.exports = {
...module.exports,
React: shared.react.factory(),
ReactDOM: shared['react-dom'].factory(),
_ : shared.lodash.factory(),
};
return module;
};
Shared dependencies must be versioned. A micro-frontend may declare it requires react@^18.0.0, and the shell resolves the exact version from its registry. The shell can also pre-load shared modules during boot, or lazy-load them on-demand.
Failure mode: a micro-frontend expects React v18.1.0, but the shell ships v18.2.0. If the micro-frontend uses React.useContext and the version is mismatched, the hook may fail silently—undefined context values, or useContext not being a function.
Micro-frontends are loaded on-demand. The shell determines when to load each module based on user action, route, or viewport.
The most common pattern is route-based loading. When a user navigates to /products, the shell checks if the products micro-frontend is loaded. If not, it fetches the module and renders it.
// router.js
const routes = {
'/': () => loadModule('home', '/static/home.js'),
'/products': () => loadModule('products', '/static/products.js'),
'/products/:id': (params) => {
const module = loadModule('product-detail', '/static/product-detail.js');
return module.then(m => m.render({ productId: params.id }));
},
'/cart': () => loadModule('cart', '/static/cart.js'),
};
// Handle route change
const handleRouteChange = (path) => {
const route = routes[path];
if (route) {
return route();
}
return Promise.reject(new Error(`Route not found: ${path}`));
};
// Example: navigate to /products/123
handleRouteChange('/products/123')
.then(() => console.log('Product page rendered'))
.catch(err => console.error('Failed to render product:', err));
A micro-frontend may also declare routes it handles, allowing the shell to register them dynamically.
// product-detail.js
export const routes = [
'/products/:id',
'/p/:id',
];
export function render({ productId, target }) {
const el = document.createElement('div');
el.textContent = `Product ID: ${productId}`;
target.appendChild(el);
}
export function mount(target) {
// Called when module is first loaded
render({ productId: 123, target });
}
export function unmount() {
// Called when module is unloaded
console.log('Product detail unmounted');
}
The shell can now discover and register routes without hardcoding them.
Micro-frontends must communicate. The most common mechanism is a shared event bus.
The shell provides a global event emitter, accessible via window.EventBus.
// In a micro-frontend: cart.js
import { EventBus } from 'event-bus';
// Publish an event
EventBus.emit('cart:item-added', { id: 456, name: 'Laptop' });
// Subscribe to an event
EventBus.on('user:logged-in', (data) => {
console.log('User logged in:', data);
document.querySelector('#cart-status').textContent = 'User is logged in';
});
// Register a lifecycle hook
EventBus.on('module:mounted', (module) => {
console.log('Module mounted:', module.name);
});
Events are simple key-value payloads. The event bus is a global Map of event names to listeners.
A micro-frontend may also expose a public API via window or import.meta.
// In profile.js
export function openModal(title, content) {
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `<h2>${title}</h2><p>${content}</p>`;
document.body.appendChild(modal);
// Add close button
const button = document.createElement('button');
button.textContent = 'Close';
button.onclick = () => modal.remove();
modal.appendChild(button);
return modal;
}
export function updateProfile(userData) {
// Update profile UI
document.querySelector('#profile-name').textContent = userData.name;
document.querySelector('#profile-email').textContent = userData.email;
}
// Expose to global scope
window.ProfileAPI = {
openModal,
updateProfile,
};
Other micro-frontends can call these functions directly:
// In cart.js
window.ProfileAPI.updateProfile({ name: 'Alice', email: '[email protected]' });
const modal = window.ProfileAPI.openModal('Welcome', 'You’ve signed up!');
The event bus and global API are the primary mechanisms for inter-module communication. They are simple, reliable, and scale well.
Each micro-frontend is deployed independently. A team can release a new version of the cart module without affecting the profile module.
The shell must resolve the correct version of each module. This is typically done via a manifest.json file.
// manifest.json
{
"version": "1.2.0",
"modules": {
"home": {
"url": "https://example.com/static/home.js",
"version": "1.1.0"
},
"cart": {
"url": "https://example.com/static/cart.js",
"version": "2.3.1"
},
"profile": {
"url": "https://example.com/static/profile.js",
"version": "1.0.0"
}
}
}
The shell fetches this manifest on boot and uses it to load and version modules.
Deployment workflows are typically CI/CD pipelines. A cart module may have:
name: Deploy Cart Micro-Frontend
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm install
- run: npm run build
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm install
- run: npm run deploy
env:
DEPLOY_URL: https://cart.example.com
The npm run deploy command builds the micro-frontend and uploads it to a CDN or static file server. The resulting cart.js file is served at https://cart.example.com/micro-frontend.js.
The shell can cache modules using HTTP ETag and Last-Modified headers. It can also pre-load frequently used modules during shell boot.
render function: A micro-frontend exports a module but forgets to define a render function. The shell calls module.render() and gets TypeError: module.render is not a function. This happens when the micro-frontend is built with a framework that doesn't export a render function.lodash@^4.17.0 but the shell provides [email protected]. If the micro-frontend uses _.debounce, the function may be undefined if lodash was built with a different version.import(), but the module throws an error during evaluation. The error is logged but not propagated to the shell. The micro-frontend may not appear at all.routes but the shell does not recognize the routes array. The shell may only handle route strings and not arrays.react and react-dom, but a subsequent micro-frontend loads react again. The two instances of React are not the same, leading to React.isValidElement returning false across boundaries.mount and unmount hooks: A micro-frontend assumes it is mounted, but the shell does not call mount() or unmount(). This leads to unrendered content or uncleaned state.A micro-frontend architecture enables teams to own and deploy their parts of a large web application independently. The core pattern is a shell that loads and mounts micro-frontends dynamically, sharing dependencies and providing a communication layer. The system relies on well-defined contracts: render, mount, unmount, event buses, and shared APIs. The success of the system depends on careful versioning, consistent deployment, and attention to common failure modes.
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