Micro-frontends extend the microservices concept to frontend development. By breaking down monolithic frontend applications into smaller, semi-independent pieces, teams can develop, deploy, and maintain features autonomously. This comprehensive guide covers everything you need to build production-ready micro-frontend architectures.
Understanding Micro-Frontends
Micro-frontends architecture decomposes a frontend application into vertical slices—each owned by different teams. Each micro-frontend implements a specific business capability and can be developed, tested, and deployed independently.
Core Principles
- 1Technology Agnostic: Each team can choose their own technology stack
- 2Isolated Team Development: Teams work independently without blocking others
- 3Independent Deployment: Deploy micro-frontends without redeploying the entire app
- 4Efficient Communication: Establish clear contracts between teams
- 5Resilience: Failure in one micro-frontend doesn't break the entire application
When to Use Micro-Frontends
Micro-frontends shine in these scenarios:
- Large applications with multiple teams
- Organizations with different technology preferences
- Applications requiring independent release cycles
- Migrations from legacy systems to modern frameworks
- Complex domains requiring specialized expertise
Architecture Patterns
Several patterns implement micro-frontends, each with distinct advantages.
Pattern 1: Runtime Integration
Micro-frontends integrate at runtime in the browser:
// app-shell.tsx
import { loadRemoteModule } from '@angular-architects/module-federation';
import { defineMicroFrontends } from './micro-frontends.config';
const microFrontends = defineMicroFrontends({
products: {
remoteEntry: 'http://localhost:3001/remoteEntry.js',
exposedModule: './ProductModule',
route: 'products'
},
checkout: {
remoteEntry: 'http://localhost:3002/remoteEntry.js',
exposedModule: './CheckoutModule',
route: 'checkout'
}
});
function AppShell() {
return (
<Layout>
<Header />
<main>
<Routes>
<Route path="/products/*" element={
lazy(() => loadRemoteModule(microFrontends.products))
} />
<Route path="/checkout/*" element={
lazy(() => loadRemoteModule(microFrontends.checkout))
} />
</Routes>
</main>
<Footer />
</Layout>
);
}Pattern 2: Build-Time Integration
Combine micro-frontends during the build process:
// webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
products: 'products@http://localhost:3001/remoteEntry.js',
checkout: 'checkout@http://localhost:3002/remoteEntry.js'
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
};Pattern 3: Server-Side Composition
Compose micro-frontends on the server:
// server.ts
import express from 'express';
import fetch from 'node-fetch';
const app = express();
app.get('/fragments/:name', async (req, res) => {
const { name } = req.params;
const response = await fetch(`http://${name}-service/render`);
const html = await response.text();
res.send(html);
});
app.get('/', async (req, res) => {
const [header, products, footer] = await Promise.all([
fetch('http://header-service/render').then(r => r.text()),
fetch('http://products-service/render').then(r => r.text()),
fetch('http://footer-service/render').then(r => r.text())
]);
res.send(`
<!DOCTYPE html>
<html>
<body>
${header}
<main>${products}</main>
${footer}
</body>
</html>
`);
});Module Federation
Webpack Module Federation is the most popular micro-frontend implementation.
Host Configuration
// host/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
mfe1: 'mfe1@http://localhost:3001/remoteEntry.js',
mfe2: 'mfe2@http://localhost:3002/remoteEntry.js'
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
'react-router-dom': { singleton: true }
}
})
]
};Remote Configuration
// remote/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'mfe1',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App',
'./Button': './src/components/Button'
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
};Consuming Remote Modules
// host/src/App.tsx
import React, { lazy, Suspense, useState } from 'react';
const RemoteApp = lazy(() => import('mfe1/App'));
const RemoteButton = lazy(() => import('mfe1/Button'));
function App() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Host Application</h1>
<Suspense fallback={<div>Loading...</div>}>
<RemoteApp title="Remote Micro-Frontend" />
<RemoteButton onClick={() => setCount(c => c + 1)}>
Count: {count}
</RemoteButton>
</Suspense>
</div>
);
}Communication Patterns
Micro-frontends need to communicate while maintaining isolation.
Event Bus Pattern
// event-bus.ts
type EventHandler = (data: any) => void;
class EventBus {
private listeners: Map<string, Set<EventHandler>> = new Map();
on(event: string, handler: EventHandler) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler);
}
off(event: string, handler: EventHandler) {
this.listeners.get(event)?.delete(handler);
}
emit(event: string, data?: any) {
this.listeners.get(event)?.forEach(handler => handler(data));
}
}
export const eventBus = new EventBus();
// Usage in MFE 1
eventBus.emit('product:added', { id: 123, name: 'Product' });
// Usage in MFE 2
eventBus.on('product:added', (product) => {
console.log('Product added:', product);
});Shared State with RxJS
// state-manager.ts
import { BehaviorSubject } fromrxjs';
const cartSubject = new BehaviorSubject<CartItem[]>([]);
export const cartState = {
items$: cartSubject.asObservable(),
addItem: (item: CartItem) => {
const current = cartSubject.value;
cartSubject.next([...current, item]);
},
removeItem: (id: string) => {
const current = cartSubject.value;
cartSubject.next(current.filter(item => item.id !== id));
}
};
// MFE 1: Add to cart
import { cartState } from './state-manager';
cartState.addItem({ id: '1', name: 'Product', price: 99 });
// MFE 2: Display cart
import { cartState } from './state-manager';
cartState.items$.subscribe(items => {
updateCartDisplay(items);
});Custom Properties
// global.d.ts
declare global {
interface Window {
microFrontends: {
cart: {
addItem: (item: CartItem) => void;
getItems: () => CartItem[];
};
user: {
getCurrentUser: () => User;
};
};
}
}
// Cart MFE
window.microFrontends = {
cart: {
addItem: (item) => { /* implementation */ },
getItems: () => { /* implementation */ }
}
};
// Checkout MFE
const items = window.microFrontends.cart.getItems();Styling Strategies
Managing styles across micro-frontends requires careful planning.
CSS Modules
/* mfe1/Button.module.css */
.button {
padding: 10px 20px;
background: blue;
color: white;
border: none;
border-radius: 4px;
}// mfe1/Button.tsx
import styles from './Button.module.css';
export function Button({ children, onClick }) {
return (
<button className={styles.button} onClick={onClick}>
{children}
</button>
);
}Shadow DOM
class MicroFrontendElement extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.container {
padding: 20px;
background: white;
}
</style>
<div class="container">
<slot></slot>
</div>
`;
}
}
customElements.define('micro-frontend', MicroFrontendElement);Web Components
// product-card.ts
export class ProductCard extends HTMLElement {
static get observedAttributes() {
return ['name', 'price', 'image'];
}
attributeChangedCallback() {
this.render();
}
render() {
const name = this.getAttribute('name') || '';
const price = this.getAttribute('price') || '';
const image = this.getAttribute('image') || '';
this.innerHTML = `
<div class="card">
<img src="${image}" alt="${name}" />
<h3>${name}</h3>
<p>$${price}</p>
</div>
`;
}
}
customElements.define('product-card', ProductCard);Routing Strategies
Handle routing across micro-frontends seamlessly.
Client-Side Routing
// host/src/App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products/*" element={
lazy(() => import('products/App'))
} />
<Route path="/checkout/*" element={
lazy(() => import('checkout/App'))
} />
</Routes>
</BrowserRouter>
);
}Server-Side Routing
// server.ts
app.get('/products/*', (req, res) => {
res.sendFile('products/index.html');
});
app.get('/checkout/*', (req, res) => {
res.sendFile('checkout/index.html');
});Dynamic Route Registration
// route-registry.ts
interface RouteConfig {
path: string;
component: React.ComponentType;
exact?: boolean;
}
class RouteRegistry {
private routes: Map<string, RouteConfig> = new Map();
register(key: string, config: RouteConfig) {
this.routes.set(key, config);
}
getRoutes(): RouteConfig[] {
return Array.from(this.routes.values());
}
}
export const routeRegistry = new RouteRegistry();
// MFE 1 registers routes
routeRegistry.register('products', {
path: '/products',
component: ProductList
});
// Host consumes routes
function App() {
const routes = routeRegistry.getRoutes();
return (
<BrowserRouter>
<Routes>
{routes.map(route => (
<Route
key={route.path}
path={route.path}
element={<route.component />}
/>
))}
</Routes>
</BrowserRouter>
);
}Testing Strategies
Test micro-frontends independently and together.
Unit Testing
// mfe1/Button.test.tsx
import { render, screen } from '@testing-library/react';
import { Button } from './Button';
test('renders button with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});Integration Testing
// integration/cart-flow.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { App } from './App';
test('add product to cart across MFEs', async () => {
render(<App />);
// Products MFE
const addButton = await screen.findByText('Add to Cart');
fireEvent.click(addButton);
// Cart MFE
const cartCount = await screen.findByText('Cart (1)');
expect(cartCount).toBeInTheDocument();
});End-to-End Testing
// e2e/add-to-cart.spec.ts
import { test, expect } from '@playwright/test';
test('complete purchase flow', async ({ page }) => {
await page.goto('http://localhost:3000');
// Products MFE
await page.click('text=Products');
await page.click('text=Add to Cart', { first: true });
// Cart MFE
await page.click('text=Cart');
await expect(page.locator('text=Cart (1)')).toBeVisible();
// Checkout MFE
await page.click('text=Checkout');
await page.fill('input[name="email"]', 'test@example.com');
await page.click('text=Place Order');
await expect(page.locator('text=Order Placed')).toBeVisible();
});Performance Optimization
Optimize micro-frontend performance for production.
Code Splitting
// MFE level code splitting
const ProductList = lazy(() =>
import('./ProductList').then(module => ({
default: module.ProductList
}))
);Shared Dependencies
// webpack.config.js
new ModuleFederationPlugin({
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
eager: true
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0'
},
lodash: {
singleton: false // Allow multiple versions
}
}
});Lazy Loading
function App() {
const [mfeLoaded, setMfeLoaded] = useState(false);
useEffect(() => {
// Load MFE only when needed
const loadMFE = async () => {
const module = await import('mfe1/App');
setMfeLoaded(true);
};
if (shouldLoadMFE) {
loadMFE();
}
}, [shouldLoadMFE]);
return (
<div>
{mfeLoaded && <RemoteMFE />}
</div>
);
}Deployment Strategies
Deploy micro-frontends independently.
Independent Deployments
# .github/workflows/deploy-mfe1.yml
name: Deploy MFE 1
on:
push:
branches: [main]
paths:
- 'packages/mfe1/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build
run: npm run build --workspace=packages/mfe1
- name: Deploy
run: npm run deploy --workspace=packages/mfe1Versioned Dependencies
// version-registry.ts
interface MFEVersion {
name: string;
version: string;
url: string;
}
class VersionRegistry {
private versions: Map<string, MFEVersion[]> = new Map();
register(mfe: MFEVersion) {
const versions = this.versions.get(mfe.name) || [];
versions.push(mfe);
this.versions.set(mfe.name, versions);
}
getLatest(name: string): MFEVersion | null {
const versions = this.versions.get(name);
return versions?.[versions.length - 1] || null;
}
getVersion(name: string, version: string): MFEVersion | null {
const versions = this.versions.get(name);
return versions?.find(v => v.version === version) || null;
}
}
export const versionRegistry = new VersionRegistry();Conclusion
Micro-frontends enable teams to build scalable, maintainable frontend applications. By following the patterns and best practices in this guide, you can successfully decompose your monolithic frontend into independently deployable pieces.
Remember that micro-frontends introduce complexity—only use them when the benefits outweigh the costs. Start small, measure carefully, and iterate based on real needs.