As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Micro-frontend architectures have transformed how we build large-scale web applications. As development teams grow and applications become more complex, the traditional monolithic frontend approach often creates bottlenecks. I've implemented micro-frontends across several enterprise applications and found they solve many scaling challenges while introducing new considerations for integration and consistency.
Understanding Micro-Frontend Architecture
Micro-frontend architecture applies microservice principles to frontend development. It divides a large application into smaller, semi-independent applications that focus on specific business domains. Each micro-frontend can be developed, tested, and deployed independently by separate teams.
The core benefit lies in autonomy. Teams can work at their own pace with their preferred technologies while contributing to a cohesive product. This architectural style particularly benefits organizations with multiple teams working on a single web application.
The pattern emerged from the need to scale development processes beyond what monolithic frontends could support. When dozens of developers work on the same codebase, merge conflicts, coordination overhead, and deployment complexity can significantly slow down delivery.
Module Federation: Runtime Integration
Module Federation represents one of the most powerful integration techniques for micro-frontends. Introduced in Webpack 5, it enables applications to dynamically load code from other applications at runtime.
The key advantage is that applications can share dependencies and components without rebuilding. This creates a more seamless user experience while preserving team autonomy.
Here's how to implement basic module federation:
// Host application webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
filename: 'remoteEntry.js',
remotes: {
checkout: 'checkout@http://localhost:8081/remoteEntry.js',
products: 'products@http://localhost:8082/remoteEntry.js'
},
shared: ['react', 'react-dom', 'react-router-dom']
})
]
};
// Remote application (products) webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'products',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/components/ProductList',
'./ProductDetail': './src/components/ProductDetail'
},
shared: ['react', 'react-dom', 'react-router-dom']
})
]
};
To consume the exposed components:
// In the host application
import React, { lazy, Suspense } from 'react';
const ProductList = lazy(() => import('products/ProductList'));
function App() {
return (
<div>
<h1>Our Store</h1>
<Suspense fallback={<div>Loading products...</div>}>
<ProductList />
</Suspense>
</div>
);
}
I've found Module Federation works particularly well for React applications, but it supports other frameworks too. The shared dependencies feature prevents duplicate libraries, reducing bundle sizes and improving performance.
Web Components: Framework-Agnostic Integration
Web Components provide a standards-based approach to creating reusable, encapsulated components that work across any framework. This makes them ideal for organizations where teams use different frontend technologies.
Custom Elements, a core part of Web Components, allow you to define new HTML elements with custom behavior:
class ProductCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const product = JSON.parse(this.getAttribute('product-data'));
this.shadowRoot.innerHTML = `
<style>
.card { border: 1px solid #ddd; padding: 15px; border-radius: 4px; }
.title { font-weight: bold; }
.price { color: green; }
</style>
<div class="card">
<div class="title">${product.name}</div>
<div class="price">$${product.price.toFixed(2)}</div>
<button class="add-to-cart">Add to Cart</button>
</div>
`;
this.shadowRoot.querySelector('.add-to-cart').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('add-to-cart', {
detail: product,
bubbles: true,
composed: true
}));
});
}
}
customElements.define('product-card', ProductCard);
Using this component from any framework becomes straightforward:
<!-- In React -->
function ProductPage({ products }) {
function handleAddToCart(e) {
console.log('Added to cart:', e.detail);
}
useEffect(() => {
document.addEventListener('add-to-cart', handleAddToCart);
return () => document.removeEventListener('add-to-cart', handleAddToCart);
}, []);
return (
<div>
{products.map(product => (
<product-card
key={product.id}
product-data={JSON.stringify(product)}
/>
))}
</div>
);
}
<!-- In Vue -->
<template>
<div>
<product-card
v-for="product in products"
:key="product.id"
:product-data="JSON.stringify(product)"
@add-to-cart="handleAddToCart"
></product-card>
</div>
</template>
The Shadow DOM provides style encapsulation, preventing CSS conflicts between components. This isolation is crucial in micro-frontend architectures where different teams might use conflicting style rules.
Routing: Coordinating Navigation
Routing requires special attention in micro-frontend architectures. Each application might need its own routes while maintaining a cohesive navigation experience for users.
I typically implement a two-level routing strategy:
- A shell application manages top-level routes, determining which micro-frontend to load
- Each micro-frontend manages its own internal routes
Here's an implementation using React Router:
// Shell application
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
const ProductsApp = lazy(() => import('products/App'));
const CheckoutApp = lazy(() => import('checkout/App'));
const AccountApp = lazy(() => import('account/App'));
function Shell() {
return (
<BrowserRouter>
<nav>
<Link to="/">Products</Link>
<Link to="/checkout">Checkout</Link>
<Link to="/account">Account</Link>
</nav>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/*" element={<ProductsApp />} />
<Route path="/checkout/*" element={<CheckoutApp />} />
<Route path="/account/*" element={<AccountApp />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Within each micro-frontend, the routing is relative to its mount point:
// Products micro-frontend
import { Routes, Route } from 'react-router-dom';
function ProductsApp() {
return (
<Routes>
<Route path="/" element={<ProductList />} />
<Route path="/category/:id" element={<CategoryPage />} />
<Route path="/product/:id" element={<ProductDetail />} />
</Routes>
);
}
History API manipulation is another approach for framework-agnostic routing coordination:
// Shell application
window.addEventListener('popstate', handleRouteChange);
function handleRouteChange() {
const path = window.location.pathname;
if (path.startsWith('/products')) {
loadMicroFrontend('products-container', 'http://products.example.com');
} else if (path.startsWith('/checkout')) {
loadMicroFrontend('checkout-container', 'http://checkout.example.com');
} else if (path.startsWith('/account')) {
loadMicroFrontend('account-container', 'http://account.example.com');
}
}
function loadMicroFrontend(containerId, url) {
const container = document.getElementById(containerId);
// Load the micro-frontend (iframe, fetch content, etc.)
}
Design System Integration
Visual consistency across micro-frontends is crucial for delivering a unified user experience. A shared design system helps maintain this consistency while allowing teams to work independently.
I prefer using design tokens as a foundation:
/* shared-tokens.css */
:root {
--primary-color: #0066cc;
--secondary-color: #f8f9fa;
--danger-color: #dc3545;
--success-color: #28a745;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--font-family: 'Roboto', sans-serif;
--font-size-small: 0.875rem;
--font-size-base: 1rem;
--font-size-large: 1.25rem;
--font-size-xlarge: 1.5rem;
--border-radius: 4px;
--box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
These tokens can be imported in each micro-frontend and used to build higher-level components:
// Button component in React
import 'shared-tokens.css';
import styled from 'styled-components';
const Button = styled.button`
background-color: var(--primary-color);
color: white;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: var(--font-size-base);
cursor: pointer;
&:hover {
opacity: 0.9;
}
&.secondary {
background-color: var(--secondary-color);
color: #333;
border: 1px solid #ddd;
}
`;
export default Button;
For more complex design systems, consider creating a shared component library that each micro-frontend imports. This approach requires careful versioning to prevent breaking changes.
Backend-for-Frontend (BFF) Pattern
The Backend-for-Frontend pattern provides dedicated API gateways for each micro-frontend. This approach prevents tight coupling between frontends and backend services while optimizing data delivery.
Here's how I typically implement a BFF:
// products-bff/server.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
const PORT = 3001;
// Product-specific data transformation middleware
app.use('/api/products', async (req, res, next) => {
// Enrich or transform the request if needed
if (req.query.expand === 'details') {
req.headers['x-include-details'] = 'true';
}
next();
});
// Proxy to underlying services
app.use('/api/products', createProxyMiddleware({
target: 'https://product-service.example.com',
changeOrigin: true,
pathRewrite: {
'^/api/products': '/products/v1'
}
}));
app.use('/api/inventory', createProxyMiddleware({
target: 'https://inventory-service.example.com',
changeOrigin: true
}));
// Product-specific endpoints
app.get('/api/recommendations', async (req, res) => {
try {
// Fetch from multiple services and combine the data
const [products, userPreferences] = await Promise.all([
fetch('https://product-service.example.com/featured').then(r => r.json()),
fetch(`https://user-service.example.com/preferences/${req.query.userId}`).then(r => r.json())
]);
// Business logic to match products with user preferences
const recommendations = products.filter(product =>
userPreferences.categories.includes(product.category)
);
res.json({ recommendations });
} catch (error) {
res.status(500).json({ error: 'Failed to generate recommendations' });
}
});
app.listen(PORT, () => {
console.log(`Products BFF listening on port ${PORT}`);
});
Each micro-frontend team owns their BFF, allowing them to evolve their API requirements independently. This pattern also simplifies authentication and authorization by centralizing these concerns at the BFF level.
Distributed State Management
Managing state across micro-frontends presents unique challenges. I recommend a hybrid approach that combines local state management with cross-application communication.
For local state, each micro-frontend can use its preferred solution (Redux, MobX, React Context, etc.). For cross-application state, consider these options:
- Custom Events: Simple and framework-agnostic
// Publishing events from one micro-frontend
function addToCart(product) {
const event = new CustomEvent('cart:item-added', {
detail: product,
bubbles: true,
composed: true
});
document.dispatchEvent(event);
}
// Subscribing to events in another micro-frontend
function setupCartListeners() {
document.addEventListener('cart:item-added', (event) => {
console.log('Product added to cart:', event.detail);
updateCartCount();
});
}
- Shared State Services: For more complex state requirements
// shared-state/cart-service.js
class CartService {
constructor() {
this.items = [];
this.subscribers = [];
}
addItem(product, quantity = 1) {
const existingItem = this.items.find(item => item.id === product.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push({ ...product, quantity });
}
this.notifySubscribers();
this.persistCart();
}
removeItem(productId) {
this.items = this.items.filter(item => item.id !== productId);
this.notifySubscribers();
this.persistCart();
}
getItems() {
return [...this.items];
}
getCount() {
return this.items.reduce((count, item) => count + item.quantity, 0);
}
subscribe(callback) {
this.subscribers.push(callback);
return () => {
this.subscribers = this.subscribers.filter(cb => cb !== callback);
};
}
notifySubscribers() {
this.subscribers.forEach(callback => callback(this.getItems()));
}
persistCart() {
localStorage.setItem('cart', JSON.stringify(this.items));
}
loadCart() {
const savedCart = localStorage.getItem('cart');
if (savedCart) {
this.items = JSON.parse(savedCart);
this.notifySubscribers();
}
}
}
export const cartService = new CartService();
cartService.loadCart();
This service can be imported and used across micro-frontends:
// In the products micro-frontend
import { cartService } from 'shared-state/cart-service';
function ProductDetail({ product }) {
const addToCart = () => {
cartService.addItem(product);
showNotification(`Added ${product.name} to cart`);
};
return (
<div>
<h2>{product.name}</h2>
<p>${product.price.toFixed(2)}</p>
<button onClick={addToCart}>Add to Cart</button>
</div>
);
}
// In the header micro-frontend
import { useState, useEffect } from 'react';
import { cartService } from 'shared-state/cart-service';
function CartIndicator() {
const [count, setCount] = useState(cartService.getCount());
useEffect(() => {
const unsubscribe = cartService.subscribe(() => {
setCount(cartService.getCount());
});
return unsubscribe;
}, []);
return (
<div className="cart-icon">
🛒 <span className="cart-count">{count}</span>
</div>
);
}
Progressive Implementation Strategy
Migrating to micro-frontends doesn't require a complete rewrite. I've successfully implemented incremental approaches that reduce risk and provide immediate benefits.
Start by identifying natural boundaries in your application that align with team structures or business domains. These make ideal candidates for your first micro-frontends.
For an existing application, consider these implementation patterns:
- App Shell Pattern: Create a lightweight shell that loads both the existing monolith and new micro-frontends
// App shell
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import LegacyAppWrapper from './LegacyAppWrapper';
const NewFeatureApp = lazy(() => import('new-feature/App'));
function AppShell() {
return (
<BrowserRouter>
<header>
<nav>{/* Common navigation */}</nav>
</header>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/new-feature/*" element={<NewFeatureApp />} />
<Route path="/*" element={<LegacyAppWrapper />} />
</Routes>
</Suspense>
<footer>{/* Common footer */}</footer>
</BrowserRouter>
);
}
- Strangler Fig Pattern: Gradually replace parts of the monolith with micro-frontends
// Example of route-based strangler implementation
function determineAppToLoad(path) {
const microFrontendRoutes = {
'/products': 'products-app',
'/checkout': 'checkout-app',
'/account': 'account-app'
};
// Find the longest matching path prefix
const matchingPrefix = Object.keys(microFrontendRoutes)
.filter(route => path.startsWith(route))
.sort((a, b) => b.length - a.length)[0];
return matchingPrefix
? microFrontendRoutes[matchingPrefix]
: 'legacy-app';
}
function loadApp(appName, container) {
if (appName === 'legacy-app') {
renderLegacyApp(container);
return;
}
// Load the micro-frontend
const script = document.createElement('script');
script.src = `/${appName}/remoteEntry.js`;
script.onload = () => {
window[appName].init(container);
};
document.head.appendChild(script);
}
// On navigation or initial load
const container = document.getElementById('app-container');
const appToLoad = determineAppToLoad(window.location.pathname);
loadApp(appToLoad, container);
Performance Considerations
Micro-frontends can impact performance if not implemented carefully. Here are techniques I use to optimize performance:
Shared Dependencies: Use Module Federation's shared dependencies feature to avoid downloading the same libraries multiple times
Lazy Loading: Only load micro-frontends when needed
import React, { lazy, Suspense } from 'react';
const ProductDetail = lazy(() => import('products/ProductDetail'));
function App() {
return (
<Suspense fallback={<div>Loading product details...</div>}>
<ProductDetail id="123" />
</Suspense>
);
}
- Asset Optimization: Implement efficient caching strategies
// webpack.config.js
module.exports = {
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].chunk.js'
},
optimization: {
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
}
};
- Skeleton Screens: Show placeholder UI while loading micro-frontends
function ProductPageSkeleton() {
return (
<div className="product-skeleton">
<div className="skeleton-header"></div>
<div className="skeleton-image"></div>
<div className="skeleton-details">
<div className="skeleton-text"></div>
<div className="skeleton-text"></div>
<div className="skeleton-price"></div>
</div>
</div>
);
}
function App() {
return (
<Suspense fallback={<ProductPageSkeleton />}>
<ProductDetail id="123" />
</Suspense>
);
}
Conclusion
Micro-frontend architectures provide powerful solutions for scaling web application development. Through careful implementation of module federation, web components, coordinated routing, shared design systems, BFF patterns, distributed state management, and progressive adoption strategies, teams can build maintainable and scalable applications.
The approach isn't without challenges. It requires careful consideration of integration patterns, consistency mechanisms, and performance optimizations. However, when properly implemented, micro-frontends enable organizations to scale their development processes while delivering cohesive user experiences.
I've found that successful micro-frontend implementations focus on business domains rather than technical concerns. By aligning micro-frontend boundaries with the organization's team structure and business capabilities, the architecture naturally supports the company's growth and evolution.
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)