Micro Frontends
9 min read- Micro Frontends
- Overview
- Core Concepts
- What Are Micro Frontends?
- When to Use Micro Frontends
- Implementation Approaches
- 1. Build-Time Integration
- 2. Runtime Integration via iframes
- 3. Runtime Integration via JavaScript
- 4. Web Components
- 5. Module Federation (Webpack 5+)
- Communication Between Micro Frontends
- 1. Custom Events
- 2. Shared Event Bus
- 3. Shared State (Redux, Zustand)
- 4. Props/Callbacks (Parent-Child)
- Routing in Micro Frontends
- Centralized Routing (Shell Controls)
- Decentralized Routing (MFEs Control Own Routes)
- Styling Strategies
- 1. CSS Modules / Scoped Styles
- 2. CSS-in-JS with Unique Class Names
- 3. Shadow DOM (Web Components)
- 4. Design System with CSS Variables
- Best Practices
- 1. Define Clear Boundaries
- 2. Shared Dependencies
- 3. Versioning Strategy
- 4. Error Boundaries
- 5. Performance Monitoring
- Testing Strategies
- Unit Testing
- Integration Testing
- E2E Testing
- Common Pitfalls and Solutions
- 1. Version Conflicts
- 2. Performance Issues
- 3. Styling Conflicts
- 4. State Management Complexity
- Tools and Frameworks
- Single-SPA
- Module Federation
- Nx
- Bit
- Real-World Examples
- Summary
Micro Frontends
Overview
Micro frontends extend the concept of microservices to the frontend, allowing teams to build, deploy, and maintain independent features or applications that compose into a unified user experience.
Core Concepts
What Are Micro Frontends?
- Independent Deployment: Each micro frontend can be deployed independently
- Technology Agnostic: Teams can choose different frameworks (React, Angular, Vue, etc.)
- Team Ownership: Each team owns a specific business domain end-to-end
- Isolated Development: Teams work in isolation without coordination overhead
- Incremental Upgrades: Update parts of the application without rewriting everything
When to Use Micro Frontends
✅ Good Use Cases:
- Large applications with multiple teams
- Applications that need technology diversity
- Long-lived applications requiring gradual modernization
- Organizations with independent business domains
- Teams that need to scale independently
❌ Not Recommended For:
- Small applications with a single team
- Applications requiring tight integration
- Performance-critical applications (overhead considerations)
- Short-lived projects
Implementation Approaches
1. Build-Time Integration
Micro frontends are composed at build time using package management.
{
"name": "shell-app",
"dependencies": {
"@company/header-mfe": "^1.2.0",
"@company/product-mfe": "^2.1.0",
"@company/checkout-mfe": "^1.0.5"
}
}
Pros:
- Simple to implement
- Good performance (single bundle)
- Type safety across boundaries
Cons:
- All micro frontends must be deployed together
- Difficult to have independent deployments
- Versioning can be complex
2. Runtime Integration via iframes
Each micro frontend runs in its own iframe.
<div class="app-container">
<iframe src="https://header.example.com" />
<iframe src="https://products.example.com" />
<iframe src="https://footer.example.com" />
</div>
Pros:
- Complete isolation (CSS, JS, global state)
- Easy to implement
- Technology agnostic
Cons:
- Performance overhead
- Difficult routing and deep linking
- Complex communication between frames
- Poor UX (scrolling, responsiveness)
- SEO challenges
3. Runtime Integration via JavaScript
Micro frontends are loaded dynamically at runtime.
// Shell application
const loadMicroFrontend = async (name, host) => {
const script = document.createElement('script');
script.src = `${host}/remoteEntry.js`;
script.onload = () => {
window[name].init({
sharedDependencies: {
react: React,
'react-dom': ReactDOM
}
});
};
document.head.appendChild(script);
};
await loadMicroFrontend('headerMFE', 'https://header.example.com');
await loadMicroFrontend('productMFE', 'https://products.example.com');
Pros:
- Independent deployments
- Runtime composition
- Shared dependencies
Cons:
- More complex setup
- Runtime overhead
- Version management complexity
4. Web Components
Use web components as a standard for creating micro frontends.
// Product micro frontend
class ProductCatalog extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { display: block; }
.product { padding: 1rem; }
</style>
<div class="product-catalog">
<h2>Products</h2>
<div id="products"></div>
</div>
`;
this.loadProducts();
}
async loadProducts() {
const products = await fetch('/api/products').then(r => r.json());
this.renderProducts(products);
}
renderProducts(products) {
const container = this.shadowRoot.querySelector('#products');
container.innerHTML = products.map(p => `
<div class="product">${p.name} - $${p.price}</div>
`).join('');
}
}
customElements.define('product-catalog', ProductCatalog);
Usage:
<product-catalog></product-catalog>
Pros:
- Standards-based
- Great encapsulation
- Framework agnostic
- Native browser support
Cons:
- Learning curve
- Limited framework integration
- SSR challenges
5. Module Federation (Webpack 5+)
Share code and dependencies across micro frontends at runtime.
// webpack.config.js for Header MFE
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'header',
filename: 'remoteEntry.js',
exposes: {
'./Header': './src/Header'
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
};
// webpack.config.js for Shell App
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
header: 'header@https://header.example.com/remoteEntry.js',
products: 'products@https://products.example.com/remoteEntry.js'
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
};
// Usage in Shell App
import React, { lazy, Suspense } from 'react';
const Header = lazy(() => import('header/Header'));
const Products = lazy(() => import('products/ProductList'));
function App() {
return (
<div>
<Suspense fallback={<div>Loading header...</div>}>
<Header />
</Suspense>
<Suspense fallback={<div>Loading products...</div>}>
<Products />
</Suspense>
</div>
);
}
Pros:
- True runtime integration
- Shared dependencies
- Independent deployments
- Built into Webpack 5
Cons:
- Webpack-specific
- Complex configuration
- Debugging can be difficult
Communication Between Micro Frontends
1. Custom Events
// Micro Frontend A - Dispatch event
const event = new CustomEvent('cart:updated', {
detail: { itemCount: 5, total: 99.99 }
});
window.dispatchEvent(event);
// Micro Frontend B - Listen for event
window.addEventListener('cart:updated', (event) => {
console.log('Cart updated:', event.detail);
updateCartBadge(event.detail.itemCount);
});
2. Shared Event Bus
// eventBus.js (shared library)
class EventBus {
constructor() {
this.events = {};
}
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
return () => this.off(event, callback);
}
off(event, callback) {
if (this.events[event]) {
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
}
emit(event, data) {
if (this.events[event]) {
this.events[event].forEach(callback => callback(data));
}
}
}
export const eventBus = new EventBus();
// Usage in MFE A
import { eventBus } from '@company/shared-event-bus';
eventBus.emit('user:login', { userId: 123, name: 'John' });
// Usage in MFE B
import { eventBus } from '@company/shared-event-bus';
eventBus.on('user:login', (user) => {
console.log('User logged in:', user);
});
3. Shared State (Redux, Zustand)
// Shared store
import create from 'zustand';
export const useGlobalStore = create((set) => ({
user: null,
cart: [],
setUser: (user) => set({ user }),
addToCart: (item) => set((state) => ({ cart: [...state.cart, item] }))
}));
// MFE A
import { useGlobalStore } from '@company/shared-store';
function Header() {
const user = useGlobalStore(state => state.user);
return <div>Welcome, {user?.name}</div>;
}
// MFE B
import { useGlobalStore } from '@company/shared-store';
function ProductCard({ product }) {
const addToCart = useGlobalStore(state => state.addToCart);
return (
<button onClick={() => addToCart(product)}>
Add to Cart
</button>
);
}
4. Props/Callbacks (Parent-Child)
// Shell App
import ProductList from 'products/ProductList';
function App() {
const [selectedProduct, setSelectedProduct] = useState(null);
return (
<div>
<ProductList onProductSelect={setSelectedProduct} />
{selectedProduct && <ProductDetails product={selectedProduct} />}
</div>
);
}
Routing in Micro Frontends
Centralized Routing (Shell Controls)
// Shell App with React Router
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';
const Header = lazy(() => import('header/Header'));
const Products = lazy(() => import('products/ProductList'));
const ProductDetail = lazy(() => import('products/ProductDetail'));
const Checkout = lazy(() => import('checkout/CheckoutFlow'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Header />
<Routes>
<Route path="/" element={<Products />} />
<Route path="/products/:id" element={<ProductDetail />} />
<Route path="/checkout" element={<Checkout />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Decentralized Routing (MFEs Control Own Routes)
// Shell App
import { BrowserRouter, Routes, Route } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Header />
<Routes>
<Route path="/products/*" element={<ProductsApp />} />
<Route path="/checkout/*" element={<CheckoutApp />} />
<Route path="/account/*" element={<AccountApp />} />
</Routes>
</BrowserRouter>
);
}
// Products MFE (internally manages /products/*)
import { Routes, Route } from 'react-router-dom';
function ProductsApp() {
return (
<Routes>
<Route index element={<ProductList />} />
<Route path=":id" element={<ProductDetail />} />
<Route path="search" element={<ProductSearch />} />
</Routes>
);
}
Styling Strategies
1. CSS Modules / Scoped Styles
// ProductCard.module.css
.card {
border: 1px solid #ccc;
padding: 1rem;
}
// ProductCard.jsx
import styles from './ProductCard.module.css';
function ProductCard({ product }) {
return <div className={styles.card}>{product.name}</div>;
}
2. CSS-in-JS with Unique Class Names
import styled from 'styled-components';
// Each MFE uses a unique prefix
const Card = styled.div.withConfig({
displayName: 'ProductCard' // Generates unique class
})`
border: 1px solid #ccc;
padding: 1rem;
`;
3. Shadow DOM (Web Components)
class ProductCard extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* Styles are scoped to this component */
.card { border: 1px solid #ccc; }
</style>
<div class="card">Product</div>
`;
}
}
4. Design System with CSS Variables
/* Shared design tokens */
:root {
--color-primary: #007bff;
--color-secondary: #6c757d;
--spacing-unit: 8px;
--font-family: 'Inter', sans-serif;
}
/* Each MFE uses the tokens */
.product-card {
color: var(--color-primary);
padding: calc(var(--spacing-unit) * 2);
font-family: var(--font-family);
}
Best Practices
1. Define Clear Boundaries
- Each micro frontend should own a specific business domain
- Avoid splitting UI components arbitrarily
- Use vertical slicing (features) not horizontal (layers)
2. Shared Dependencies
// Share common libraries to reduce bundle size
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
'@company/design-system': { singleton: true }
}
3. Versioning Strategy
// Semantic versioning for micro frontends
{
"name": "@company/header-mfe",
"version": "2.1.4",
// Major: Breaking changes
// Minor: New features
// Patch: Bug fixes
}
4. Error Boundaries
class MicroFrontendErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('MFE Error:', error, errorInfo);
// Log to error tracking service
}
render() {
if (this.state.hasError) {
return <div>This section is temporarily unavailable</div>;
}
return this.props.children;
}
}
// Wrap each MFE
<MicroFrontendErrorBoundary>
<ProductsMFE />
</MicroFrontendErrorBoundary>
5. Performance Monitoring
// Track MFE load times
const loadMFE = async (name, url) => {
const startTime = performance.now();
try {
await loadRemoteModule(name, url);
const loadTime = performance.now() - startTime;
// Send to analytics
analytics.track('mfe_loaded', {
name,
loadTime,
success: true
});
} catch (error) {
analytics.track('mfe_load_failed', {
name,
error: error.message
});
}
};
Testing Strategies
Unit Testing
Test each micro frontend independently.
// ProductCard.test.jsx
import { render, screen } from '@testing-library/react';
import ProductCard from './ProductCard';
test('displays product name', () => {
const product = { id: 1, name: 'Widget', price: 9.99 };
render(<ProductCard product={product} />);
expect(screen.getByText('Widget')).toBeInTheDocument();
});
Integration Testing
Test communication between micro frontends.
// Integration test
import { render, fireEvent } from '@testing-library/react';
import { eventBus } from '@company/shared-event-bus';
test('cart updates when product added', () => {
const cartListener = jest.fn();
eventBus.on('cart:updated', cartListener);
const { getByText } = render(<ProductCard product={product} />);
fireEvent.click(getByText('Add to Cart'));
expect(cartListener).toHaveBeenCalledWith({
itemCount: 1,
items: [product]
});
});
E2E Testing
Test the composed application.
// Cypress E2E test
describe('Product Purchase Flow', () => {
it('completes full purchase', () => {
cy.visit('/products');
cy.get('[data-testid="product-1"]').click();
cy.get('[data-testid="add-to-cart"]').click();
cy.get('[data-testid="cart-badge"]').should('contain', '1');
cy.get('[data-testid="checkout-button"]').click();
cy.url().should('include', '/checkout');
});
});
Common Pitfalls and Solutions
1. Version Conflicts
Problem: Different micro frontends use different versions of shared libraries.
Solution:
// Use singleton pattern for critical libraries
shared: {
react: {
singleton: true,
strictVersion: true,
requiredVersion: '^18.2.0'
}
}
2. Performance Issues
Problem: Loading too many micro frontends slows down the app.
Solution:
- Use code splitting and lazy loading
- Implement intelligent preloading
- Monitor bundle sizes
// Lazy load with prefetch
const ProductsMFE = lazy(() => {
const promise = import('products/ProductList');
// Prefetch during idle time
requestIdleCallback(() => promise);
return promise;
});
3. Styling Conflicts
Problem: CSS from different micro frontends conflicts.
Solution:
- Use CSS Modules or CSS-in-JS
- Implement naming conventions
- Use Shadow DOM
4. State Management Complexity
Problem: Sharing state between micro frontends is complex.
Solution:
- Minimize shared state
- Use event-driven communication
- Implement a shared state library only when necessary
Tools and Frameworks
Single-SPA
Framework-agnostic micro frontend framework.
import { registerApplication, start } from 'single-spa';
registerApplication({
name: '@company/products',
app: () => import('@company/products'),
activeWhen: '/products'
});
start();
Module Federation
Webpack 5's built-in solution.
Nx
Monorepo tool with micro frontend support.
nx generate @nrwl/react:host shell
nx generate @nrwl/react:remote products
Bit
Component-driven micro frontends.
bit export @company/header
bit import @company/header
Real-World Examples
- Spotify: Uses micro frontends for different sections
- Zalando: Implements micro frontends for their e-commerce platform
- IKEA: Uses micro frontends for product catalogs
- SoundCloud: Implements different features as micro frontends
Summary
Micro frontends enable:
- ✅ Independent deployments
- ✅ Technology diversity
- ✅ Team autonomy
- ✅ Gradual migrations
- ✅ Scalability
But require:
- ⚠️ Careful architecture planning
- ⚠️ Strong governance
- ⚠️ Performance monitoring
- ⚠️ Clear boundaries
Choose micro frontends when benefits outweigh complexity.