❝ Micro-frontends extend the principles of microservices to the frontend world. Instead of a monolithic single-page application, you build your UI as a composition of loosely coupled, independently deployable modules.❞
In large organizations, frontend monoliths become bottlenecks: they slow down development, create dependency hell, and make incremental upgrades nearly impossible. Micro-frontends offer a way out—teams can own vertical slices of the product, choose their own tech stacks, and deploy autonomously. This article explores the core concepts, implementation strategies, real-world trade-offs, and provides code examples to get you started.
Classic SPAs (React, Angular, Vue) often grow into unmaintainable monoliths. A single repository with hundreds of components, tightly coupled business logic, and a single deployment pipeline creates friction. One small change can break the whole app, and coordination across dozens of developers becomes a nightmare.
Independent builds, isolated deployments, and team autonomy. Each micro-frontend can be developed, tested, and deployed separately. Teams can adopt new frameworks incrementally without rewriting the entire application. This aligns with modern DevOps and enables faster release cycles.
There are several architectural patterns to compose micro-frontends. The most common ones include:
Packages are published as libraries (npm) and installed by the host app. This is simple but loses runtime independence—updating a micro-frontend requires redeploying the shell.
Simple isolation but poor communication and UX (iframes are heavy). Rarely recommended for core applications.
Webpack 5's Module Federation allows a JavaScript application to dynamically load code from another application at runtime. It's the most popular approach for micro-frontends today.
Using custom elements to encapsulate each micro-frontend. Framework-agnostic, works natively, and can be composed by any shell.
Let's build a simple shell (container) that consumes a remote "products" micro-frontend built with React. Both apps use Webpack 5 Module Federation.
// products/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
mode: 'development',
devServer: { port: 3001 },
plugins: [
new ModuleFederationPlugin({
name: 'products',
filename: 'remoteEntry.js',
exposes: {
'./ProductsIndex': './src/index', // expose component
},
shared: {
react: { singleton: true, eager: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, eager: true },
},
}),
new HtmlWebpackPlugin({ template: './public/index.html' }),
],
};
// container/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
// ...
plugins: [
new ModuleFederationPlugin({
name: 'container',
remotes: {
products: 'products@http://localhost:3001/remoteEntry.js',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
],
};
// container/src/App.js
import React from 'react';
const ProductsApp = React.lazy(() => import('products/ProductsIndex'));
function App() {
return (
<div>
<h1>Shell Application</h1>
<React.Suspense fallback="Loading products...">
<ProductsApp />
</React.Suspense>
</div>
);
}
export default App;
This setup allows the products team to develop and deploy independently. The shell consumes the remote entry at runtime, fetching the latest version without rebuilding the entire application. Dependencies like React are shared to avoid duplication and maintain consistency.
If your teams use different frameworks (React, Vue, Svelte), Web Components provide a safe boundary. Each micro-frontend registers a custom element, and the host app simply mounts it.
// React micro-frontend wrapped as Web Component
import React from 'react';
import ReactDOM from 'react-dom/client';
import MyWidget from './MyWidget';
class MyWidgetElement extends HTMLElement {
connectedCallback() {
const root = ReactDOM.createRoot(this);
root.render(<MyWidget />);
}
}
customElements.define('my-widget', MyWidgetElement);
<!-- host app (any framework) -->
<my-widget></my-widget>
<script src="https://cdn.example.com/widget.js"></script>
This method ensures complete isolation, but communication between components requires custom events or shared state management (e.g., a global event bus).
Different micro-frontends may have conflicting CSS. Solutions: use CSS-in-JS, CSS Modules, Shadow DOM (with Web Components), or enforce BEM naming conventions across teams. The container can also apply a CSS reset.
Sharing state (e.g., user authentication) is tricky. Common patterns: custom event bus, a shared Redux store via Module Federation, or using a global object with versioning. The container usually owns the global state and passes it via props or context.
Loading multiple bundles increases initial payload. Use dependency sharing, lazy loading, and aggressive code splitting. Module Federation helps by avoiding duplicate libraries.
Independent pipelines require robust contract testing to ensure the shell works with new versions of remotes. Use tools like Pact or integration tests in staging environments.
Major companies like Spotify, IKEA, and Zalando have adopted micro-frontends to scale their frontend development. Spotify's desktop app uses micro-frontends to separate features like playlist, search, and settings—each owned by independent squads. They achieve seamless user experience while enabling rapid experimentation. IKEA's website is composed of over 20 micro-frontends, allowing different teams to deploy changes multiple times per day without coordination overhead.
A central "shell" application manages routing and decides which micro-frontend to render for a given URL. Two main strategies exist:
A popular tool for orchestration is Single-SPA, which provides a meta-framework for registering and mounting different micro-frontends based on routes or activities.
// Single-SPA registration example
import { registerApplication, start } from 'single-spa';
registerApplication({
name: '@myorg/products',
app: () => System.import('@myorg/products'),
activeWhen: ['/products'],
});
registerApplication({
name: '@myorg/checkout',
app: () => System.import('@myorg/checkout'),
activeWhen: ['/checkout'],
});
start();
Since micro-frontends are isolated, they need a way to exchange data. Common approaches:
// Event bus example (in shell)
window.addEventListener('cart-updated', (event) => {
console.log('Cart count:', event.detail.count);
});
// In a micro-frontend
const event = new CustomEvent('cart-updated', { detail: { count: 3 } });
window.dispatchEvent(event);
Whichever pattern you choose, ensure versioning and backward compatibility to avoid runtime crashes.
Micro-frontends are not a silver bullet. They introduce complexity in orchestration, communication, and infrastructure. However, for organizations that have outgrown monolithic frontends, they provide a path to sustainable scaling. Start with a pilot: extract a non-critical feature as a micro-frontend, set up Module Federation, and measure team velocity. Then gradually expand.
Remember that the ultimate goal is developer productivity and maintainability. If micro-frontends help your teams move faster with confidence, they are worth the investment. The ecosystem is maturing—with tools like Module Federation, Single-SPA, and Nx for monorepo management—making adoption smoother than ever.
In a world where frontend complexity grows exponentially, micro-frontends offer a way to keep things modular, resilient, and future-proof. Embrace the architecture that matches your organization’s evolution.