Welcome to the fourth part of our microfrontends series. In Part 1, we explored the fundamental concepts and benefits of microfrontends. Part 2 covered implementation approaches and their trade-offs. Part 3 provided a practical case study of building an e-commerce platform using microfrontends.
While microfrontends offer compelling benefits for organizations with multiple teams working on complex applications, they also introduce challenges that can undermine these benefits if not addressed properly. Throughout my career leading engineering teams, I've encountered numerous challenges with microfrontend architectures and developed patterns to solve them.
In this article, we'll explore the most common challenges that teams face when implementing microfrontends and provide practical solutions to address them. By the end, you'll have a toolkit of strategies to ensure your microfrontend architecture remains maintainable, performant, and user-friendly.
Challenge 1: Consistent Styling and Design
One of the most visible challenges with microfrontends is maintaining a consistent look and feel across independently developed components. Without careful planning, users might notice inconsistencies in styling, component behavior, or visual language.
The Problem
When different teams independently develop microfrontends, they might:
- Use slightly different colors, fonts, or spacing
- Implement similar components with different behaviors
- Follow inconsistent UX patterns for common interactions
- Create incompatible responsive design approaches
This leads to a fragmented user experience that feels cobbled together rather than cohesive.
Solution: Implement a Shared Design System
A comprehensive design system provides a single source of truth for UI components, styling, and interaction patterns.
1. Create a Design System Package:
mkdir -p design-system/src/{components,theme,hooks,utils}
cd design-system
npm init -y
2. Implement Core Components:
// design-system/src/components/Button.js
import React from 'react';
import styled from 'styled-components';
import { theme } from '../theme';
const StyledButton = styled.button`
padding: ${props => props.size === 'small' ? '0.5rem 1rem' : '0.75rem 1.5rem'};
background-color: ${props =>
props.variant === 'primary' ? theme.colors.primary :
props.variant === 'secondary' ? theme.colors.secondary :
'transparent'
};
color: ${props =>
props.variant === 'primary' || props.variant === 'secondary' ?
'white' : theme.colors.primary
};
border: ${props =>
props.variant === 'outline' ?
`1px solid ${theme.colors.primary}` : 'none'
};
border-radius: ${theme.borderRadius};
font-family: ${theme.fontFamily};
font-weight: 600;
font-size: ${props => props.size === 'small' ? '0.875rem' : '1rem'};
cursor: pointer;
transition: all 0.2s ease;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
`;
export const Button = ({
children,
variant = 'primary',
size = 'medium',
...props
}) => (
<StyledButton
variant={variant}
size={size}
{...props}
>
{children}
</StyledButton>
);
3. Define Theme and Global Styles:
// design-system/src/theme/index.js
export const theme = {
colors: {
primary: '#3498db',
secondary: '#2ecc71',
danger: '#e74c3c',
warning: '#f39c12',
info: '#1abc9c',
light: '#f5f5f5',
dark: '#333333',
text: '#2d3436',
background: '#ffffff',
},
fontFamily: "'Roboto', sans-serif",
fontSize: {
small: '0.875rem',
medium: '1rem',
large: '1.25rem',
xlarge: '1.5rem',
xxlarge: '2rem',
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
xxl: '3rem',
},
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
transition: '0.3s ease',
breakpoints: {
sm: '576px',
md: '768px',
lg: '992px',
xl: '1200px',
},
};
// design-system/src/theme/GlobalStyles.js
import { createGlobalStyle } from 'styled-components';
import { theme } from './index';
export const GlobalStyles = createGlobalStyle`
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: ${theme.fontFamily};
color: ${theme.colors.text};
background-color: ${theme.colors.background};
line-height: 1.5;
}
a {
color: ${theme.colors.primary};
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
/* Additional global styles */
`;
4. Export Components and Theme:
// design-system/src/index.js
// Components
export { Button } from './components/Button';
export { Card } from './components/Card';
export { Input } from './components/Input';
export { Typography } from './components/Typography';
// ... other components
// Theme
export { theme } from './theme';
export { GlobalStyles } from './theme/GlobalStyles';
export { ThemeProvider } from 'styled-components';
// Hooks
export { useMediaQuery } from './hooks/useMediaQuery';
// ... other hooks
// Utils
export { formatCurrency } from './utils/formatters';
// ... other utilities
5. Use the Design System in the Container App:
// container/src/App.js
import React from 'react';
import { ThemeProvider, GlobalStyles } from '@company/design-system';
export default function App() {
return (
<ThemeProvider>
<GlobalStyles />
{/* Microfrontends */}
</ThemeProvider>
);
}
6. Use in Microfrontends:
// product-catalog/src/components/ProductCard.js
import React from 'react';
import { Card, Typography, Button, formatCurrency } from '@company/design-system';
export default function ProductCard({ product, onAddToCart }) {
return (
<Card>
<img src={product.image} alt={product.name} />
<Typography variant="h3">{product.name}</Typography>
<Typography variant="body">{product.description}</Typography>
<Typography variant="h4">{formatCurrency(product.price)}</Typography>
<Button onClick={() => onAddToCart(product)}>Add to Cart</Button>
</Card>
);
}
Additional Strategies for Consistent Design
-
Design Tokens: Extract design decisions into tokens (like colors, spacing) that can be shared across teams.
-
Storybook Documentation: Document your design system with Storybook to provide examples and usage guidelines.
npx sb init --type react
- Visual Regression Testing: Implement visual tests to catch unintended styling changes.
// Example with jest-image-snapshot
import { toMatchImageSnapshot } from 'jest-image-snapshot';
expect.extend({ toMatchImageSnapshot });
it('renders button correctly', async () => {
const image = await page.screenshot();
expect(image).toMatchImageSnapshot();
});
- Design System Team: For larger organizations, consider a dedicated team to maintain the design system.
Challenge 2: State Management Across Microfrontends
Managing state across independent microfrontends is one of the most complex challenges. Each microfrontend needs to function independently but also share data with others.
The Problem
- State duplication across microfrontends
- Inconsistent state due to asynchronous updates
- Difficulty implementing features that span multiple microfrontends
- Performance issues from inefficient state synchronization
Solution 1: Shared State via Context
As we saw in our case study, one approach is to expose a global state context from the container application:
// container/src/context/GlobalState.js
import React, { createContext, useContext, useReducer } from 'react';
// Create context
const GlobalStateContext = createContext();
// Initial state
const initialState = {
user: null,
cart: [],
theme: 'light',
};
// Reducer function
function reducer(state, action) {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
case 'ADD_TO_CART':
return { ...state, cart: [...state.cart, action.payload] };
case 'REMOVE_FROM_CART':
return {
...state,
cart: state.cart.filter(item => item.id !== action.payload),
};
case 'CLEAR_CART':
return { ...state, cart: [] };
case 'SET_THEME':
return { ...state, theme: action.payload };
default:
return state;
}
}
// Provider component
export const GlobalStateProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<GlobalStateContext.Provider value={{ state, dispatch }}>
{children}
</GlobalStateContext.Provider>
);
};
// Custom hook for using the global state
export const useGlobalState = () => {
const context = useContext(GlobalStateContext);
if (!context) {
throw new Error('useGlobalState must be used within a GlobalStateProvider');
}
return context;
};
// Action creators (optional but helpful)
export const actions = {
setUser: (user) => ({ type: 'SET_USER', payload: user }),
addToCart: (product) => ({ type: 'ADD_TO_CART', payload: product }),
removeFromCart: (productId) => ({ type: 'REMOVE_FROM_CART', payload: productId }),
clearCart: () => ({ type: 'CLEAR_CART' }),
setTheme: (theme) => ({ type: 'SET_THEME', payload: theme }),
};
// In container app
import { GlobalStateProvider } from './context/GlobalState';
function App() {
return (
<GlobalStateProvider>
{/* Microfrontends */}
</GlobalStateProvider>
);
}
// In a microfrontend
import { useGlobalState, actions } from 'container/GlobalState';
function ProductCard({ product }) {
const { state, dispatch } = useGlobalState();
const handleAddToCart = () => {
dispatch(actions.addToCart(product));
};
return (
<div>
<h3>{product.name}</h3>
<button onClick={handleAddToCart}>Add to Cart</button>
</div>
);
}
Solution 2: Event-Based Communication
Another approach is to use a pub/sub pattern for communication between microfrontends:
// Create a simple event bus in the container
// container/src/services/EventBus.js
class EventBus {
constructor() {
this.listeners = {};
}
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
// Return an unsubscribe function
return () => {
this.listeners[event] = this.listeners[event].filter(
listener => listener !== callback
);
};
}
emit(event, data) {
if (this.listeners[event]) {
this.listeners[event].forEach(callback => callback(data));
}
}
}
export default new EventBus();
// In container, expose the event bus
// webpack.config.js
new ModuleFederationPlugin({
name: "container",
exposes: {
"./EventBus": "./src/services/EventBus",
},
// ...
});
// In a microfrontend (publisher)
import EventBus from 'container/EventBus';
function ProductCard({ product }) {
const handleAddToCart = () => {
EventBus.emit('PRODUCT_ADDED_TO_CART', product);
};
return (
<div>
<h3>{product.name}</h3>
<button onClick={handleAddToCart}>Add to Cart</button>
</div>
);
}
// In another microfrontend (subscriber)
import React, { useEffect, useState } from 'react';
import EventBus from 'container/EventBus';
function CartIndicator() {
const [cartItems, setCartItems] = useState([]);
useEffect(() => {
const unsubscribe = EventBus.on('PRODUCT_ADDED_TO_CART', (product) => {
setCartItems(prevItems => [...prevItems, product]);
});
// Clean up the subscription when component unmounts
return unsubscribe;
}, []);
return <div>Cart Items: {cartItems.length}</div>;
}
Solution 3: State Management Libraries
For more complex state management, you can use libraries like Redux, MobX, or Zustand with Module Federation:
// container/src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import { userReducer } from './slices/userSlice';
import { cartReducer } from './slices/cartSlice';
import { themeReducer } from './slices/themeSlice';
export const createStore = () => {
return configureStore({
reducer: {
user: userReducer,
cart: cartReducer,
theme: themeReducer,
},
});
};
export const store = createStore();
// container/src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
// Expose store in webpack.config.js
new ModuleFederationPlugin({
name: "container",
exposes: {
"./store": "./src/store",
},
// ...
});
// In a microfrontend
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { addToCart } from 'container/store/slices/cartSlice';
function ProductCard({ product }) {
const dispatch = useDispatch();
const cartItems = useSelector(state => state.cart.items);
const handleAddToCart = () => {
dispatch(addToCart(product));
};
return (
<div>
<h3>{product.name}</h3>
<button onClick={handleAddToCart}>Add to Cart</button>
</div>
);
}
Solution 4: Backend State Synchronization
For truly independent microfrontends, consider synchronizing state through backend services:
// In one microfrontend
const addToCart = async (product) => {
try {
await fetch('/api/cart/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ productId: product.id, quantity: 1 }),
});
// Optionally notify other microfrontends via EventBus
EventBus.emit('CART_UPDATED');
} catch (error) {
console.error('Failed to add to cart:', error);
}
};
// In another microfrontend
const [cartItems, setCartItems] = useState([]);
const fetchCartItems = async () => {
try {
const response = await fetch('/api/cart');
const data = await response.json();
setCartItems(data.items);
} catch (error) {
console.error('Failed to fetch cart items:', error);
}
};
useEffect(() => {
fetchCartItems();
// Listen for cart updates from other microfrontends
const unsubscribe = EventBus.on('CART_UPDATED', fetchCartItems);
return unsubscribe;
}, []);
Challenge 3: Navigation and Routing
Coordinating navigation between microfrontends can be complex, especially when different teams own different routes and navigation components.
The Problem
- Route conflicts between microfrontends
- Handling nested routes within a microfrontend
- Maintaining browser history across microfrontend boundaries
- Implementing route-based code splitting efficiently
Solution 1: Shell-Based Routing
In this approach, the container application controls the primary routing:
// container/src/App.js
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
const ProductCatalog = lazy(() => import('productCatalog/ProductCatalog'));
const ProductDetails = lazy(() => import('productDetails/ProductDetails'));
const ShoppingCart = lazy(() => import('shoppingCart/ShoppingCart'));
const UserAccount = lazy(() => import('userAccount/UserAccount'));
export default function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route exact path="/" component={ProductCatalog} />
<Route path="/product/:id" component={ProductDetails} />
<Route path="/cart" component={ShoppingCart} />
<Route path="/account/*" component={UserAccount} />
</Switch>
</Suspense>
</BrowserRouter>
);
}
In this model, each microfrontend can handle its own sub-routes:
// userAccount/src/UserAccount.js
import React from 'react';
import { Switch, Route, useRouteMatch } from 'react-router-dom';
import Profile from './components/Profile';
import Orders from './components/Orders';
import Settings from './components/Settings';
export default function UserAccount() {
const { path } = useRouteMatch();
return (
<div className="user-account">
<h1>My Account</h1>
<nav>
{/* Note that these links use the parent path */}
<Link to={`${path}`}>Profile</Link>
<Link to={`${path}/orders`}>Orders</Link>
<Link to={`${path}/settings`}>Settings</Link>
</nav>
<Switch>
<Route exact path={path} component={Profile} />
<Route path={`${path}/orders`} component={Orders} />
<Route path={`${path}/settings`} component={Settings} />
</Switch>
</div>
);
}
Solution 2: Shared Router Instance
Another approach is to create a shared history object in the container and expose it to all microfrontends:
// container/src/services/history.js
import { createBrowserHistory } from 'history';
export const history = createBrowserHistory();
// container/src/App.js
import React from 'react';
import { Router } from 'react-router-dom';
import { history } from './services/history';
export default function App() {
return (
<Router history={history}>
{/* Routes and microfrontends */}
</Router>
);
}
// Expose history in webpack.config.js
new ModuleFederationPlugin({
name: "container",
exposes: {
"./history": "./src/services/history",
},
// ...
});
// In a microfrontend
import { history } from 'container/history';
function navigateToProduct(productId) {
history.push(`/product/${productId}`);
}
Solution 3: Route Registration Pattern
For more dynamic routing, microfrontends can register their routes with the container:
// container/src/services/RouteRegistry.js
class RouteRegistry {
constructor() {
this.routes = [];
this.listeners = [];
}
registerRoutes(newRoutes) {
this.routes = [...this.routes, ...newRoutes];
this.notifyListeners();
// Return function to unregister routes
return () => {
this.routes = this.routes.filter(
route => !newRoutes.includes(route)
);
this.notifyListeners();
};
}
subscribeToRoutes(listener) {
this.listeners.push(listener);
listener(this.routes);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
notifyListeners() {
this.listeners.forEach(listener => listener(this.routes));
}
}
export default new RouteRegistry();
// container/src/App.js
import React, { useState, useEffect } from 'react';
import { BrowserRouter, Switch } from 'react-router-dom';
import RouteRegistry from './services/RouteRegistry';
export default function App() {
const [routes, setRoutes] = useState([]);
useEffect(() => {
const unsubscribe = RouteRegistry.subscribeToRoutes(setRoutes);
return unsubscribe;
}, []);
return (
<BrowserRouter>
<Switch>
{routes.map((route, index) => (
<Route key={index} {...route} />
))}
</Switch>
</BrowserRouter>
);
}
// In a microfrontend
import React, { useEffect } from 'react';
import RouteRegistry from 'container/RouteRegistry';
import ProductList from './components/ProductList';
import ProductDetail from './components/ProductDetail';
export default function ProductApp() {
useEffect(() => {
const routes = [
{
path: '/products',
exact: true,
component: ProductList
},
{
path: '/products/:id',
component: ProductDetail
}
];
const unregister = RouteRegistry.registerRoutes(routes);
return unregister;
}, []);
return null; // This component doesn't render anything itself
}
Challenge 4: Performance Optimization
Microfrontends can lead to performance issues if not implemented carefully. Multiple JavaScript bundles, duplicate dependencies, and inefficient loading can impact user experience.
The Problem
- Excessive JavaScript bundle sizes
- Duplicate dependencies across microfrontends
- Too many concurrent HTTP requests
- Poor loading and rendering performance
Solution 1: Shared Dependencies
Use Module Federation's shared dependencies feature to avoid duplicates:
// webpack.config.js
new ModuleFederationPlugin({
name: "container",
// ...
shared: {
react: {
singleton: true,
requiredVersion: "^17.0.0",
eager: true
},
"react-dom": {
singleton: true,
requiredVersion: "^17.0.0",
eager: true
},
"react-router-dom": {
singleton: true,
requiredVersion: "^5.2.0"
},
"@company/design-system": {
singleton: true,
requiredVersion: "^1.0.0"
},
// Other shared dependencies
},
});
The eager: true
flag loads the shared dependency with the container, ensuring it's available immediately rather than loaded asynchronously.
Solution 2: Intelligent Code Splitting
Apply thoughtful code splitting within each microfrontend:
// product-details/src/ProductDetails.js
import React, { lazy, Suspense } from 'react';
import ProductBasicInfo from './components/ProductBasicInfo';
// These components are only loaded when needed
const ProductReviews = lazy(() => import('./components/ProductReviews'));
const RelatedProducts = lazy(() => import('./components/RelatedProducts'));
const ProductVideos = lazy(() => import('./components/ProductVideos'));
export default function ProductDetails({ productId }) {
const [activeTab, setActiveTab] = useState('info');
return (
<div className="product-details">
<ProductBasicInfo productId={productId} />
<div className="product-tabs">
<button
className={activeTab === 'info' ? 'active' : ''}
onClick={() => setActiveTab('info')}
>
Product Info
</button>
<button
className={activeTab === 'reviews' ? 'active' : ''}
onClick={() => setActiveTab('reviews')}
>
Reviews
</button>
<button
className={activeTab === 'related' ? 'active' : ''}
onClick={() => setActiveTab('related')}
>
Related Products
</button>
<button
className={activeTab === 'videos' ? 'active' : ''}
onClick={() => setActiveTab('videos')}
>
Videos
</button>
</div>
<div className="tab-content">
<Suspense fallback={<div>Loading...</div>}>
{activeTab === 'reviews' && <ProductReviews productId={productId} />}
{activeTab === 'related' && <RelatedProducts productId={productId} />}
{activeTab === 'videos' && <ProductVideos productId={productId} />}
</Suspense>
</div>
</div>
);
}
Solution 3: Progressive Loading Strategies
Implement priority-based loading to improve perceived performance:
// container/src/App.js
import React, { lazy, Suspense } from 'react';
// Critical microfrontends - load immediately
const Header = lazy(() => import('header/Header'));
const Footer = lazy(() => import('footer/Footer'));
// Important but not critical - load with slight delay
const ProductCatalog = lazy(() => {
// Small delay to prioritize critical components
return new Promise(resolve => {
setTimeout(() => {
resolve(import('productCatalog/ProductCatalog'));
}, 100);
});
});
// Lower priority - load after critical content is visible
const RecommendedProducts = lazy(() => {
// Load after the main content
return new Promise(resolve => {
setTimeout(() => {
resolve(import('recommendations/RecommendedProducts'));
}, 500);
});
});
export default function App() {
return (
<div className="app">
<Suspense fallback={<SimpleHeader />}>
<Header />
</Suspense>
<main>
<Suspense fallback={<ProductCatalogSkeleton />}>
<ProductCatalog />
</Suspense>
<Suspense fallback={<div>Loading recommendations...</div>}>
<RecommendedProducts />
</Suspense>
</main>
<Suspense fallback={<SimpleFooter />}>
<Footer />
</Suspense>
</div>
);
}
Solution 4: Optimized Asset Loading
Use modern loading techniques to improve performance:
<!-- In index.html -->
<head>
<!-- Preload critical resources -->
<link rel="preload" href="https://cdn.example.com/fonts/roboto.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/remoteEntry.js" as="script">
<!-- Prefetch likely-to-be-needed resources -->
<link rel="prefetch" href="https://product-catalog.example.com/remoteEntry.js">
<!-- Use resource hints for third-party domains -->
<link rel="dns-prefetch" href="https://api.example.com">
<link rel="preconnect" href="https://cdn.example.com">
</head>
// In components, use IntersectionObserver to load resources when needed
import React, { useEffect, useRef, useState } from 'react';
function LazyLoadedSection({ importFunc, fallback }) {
const [Component, setComponent] = useState(null);
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
// When the element is about to enter the viewport
if (entry.isIntersecting) {
// Load the component
importFunc().then(module => {
setComponent(() => module.default);
});
// Stop observing after loading
observer.disconnect();
}
},
{ rootMargin: '200px' } // Start loading when within 200px of viewport
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [importFunc]);
return (
<div ref={ref}>
{Component ? <Component /> : fallback}
</div>
);
}
// Usage
<LazyLoadedSection
importFunc={() => import('./HeavyComponent')}
fallback={<div>Loading...</div>}
/>
Challenge 5: Authentication and Authorization
Managing user authentication consistently across microfrontends is essential for security and user experience.
The Problem
- Maintaining consistent auth state across microfrontends
- Securing routes and features based on user permissions
- Handling token renewal and session management
- Preventing duplicate login/logout flows
Solution 1: Centralized Auth Provider
Implement authentication in the container application and share it via context:
// container/src/context/AuthContext.js
import React, { createContext, useContext, useState, useEffect } from 'react';
import authService from '../services/authService';
const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Check for existing session on load
useEffect(() => {
const initAuth = async () => {
try {
const user = await authService.getCurrentUser();
setUser(user);
} catch (error) {
console.error('Failed to restore auth session:', error);
setError(error);
} finally {
setLoading(false);
}
};
initAuth();
}, []);
// Set up token refresh interval
useEffect(() => {
if (!user) return;
const refreshInterval = setInterval(async () => {
try {
await authService.refreshToken();
} catch (error) {
console.error('Token refresh failed:', error);
// Force logout if refresh fails
handleLogout();
}
}, 15 * 60 * 1000); // Refresh every 15 minutes
return () => clearInterval(refreshInterval);
}, [user]);
const handleLogin = async (credentials) => {
setLoading(true);
setError(null);
try {
const user = await authService.login(credentials);
setUser(user);
return user;
} catch (error) {
setError(error);
throw error;
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
try {
await authService.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
setUser(null);
}
};
const value = {
user,
loading,
error,
login: handleLogin,
logout: handleLogout,
isAuthenticated: !!user,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
Expose the auth provider in the container:
// container/src/App.js
import { AuthProvider } from './context/AuthContext';
function App() {
return (
<AuthProvider>
{/* The rest of your app */}
</AuthProvider>
);
}
// Expose in webpack.config.js
new ModuleFederationPlugin({
name: "container",
exposes: {
"./auth": "./src/context/AuthContext",
},
// ...
});
Use the auth context in microfrontends:
// In a microfrontend
import { useAuth } from 'container/auth';
function ProtectedFeature() {
const { user, isAuthenticated, login, logout } = useAuth();
if (!isAuthenticated) {
return (
<div>
<h2>Please log in to access this feature</h2>
<LoginForm onSubmit={login} />
</div>
);
}
return (
<div>
<h2>Welcome, {user.name}!</h2>
<button onClick={logout}>Log out</button>
{/* Protected content */}
</div>
);
}
Solution 2: Protected Route HOC
Create a higher-order component for route protection:
// container/src/components/ProtectedRoute.js
import React from 'react';
import { Route, Redirect } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute({
component: Component,
requiredPermission,
...rest
}) {
const { isAuthenticated, user, loading } = useAuth();
if (loading) {
return <div>Loading authentication...</div>;
}
return (
<Route
{...rest}
render={(props) => {
// Check if user is authenticated
if (!isAuthenticated) {
return (
<Redirect
to={{
pathname: '/login',
state: { from: props.location },
}}
/>
);
}
// Check for specific permission if required
if (requiredPermission &&
(!user.permissions || !user.permissions.includes(requiredPermission))) {
return <div>You don't have permission to access this page.</div>;
}
// If authenticated and authorized, render the component
return <Component {...props} />;
}}
/>
);
}
// Usage in container
<ProtectedRoute
path="/account"
component={UserAccount}
requiredPermission="account:view"
/>
Solution 3: Token Management with Storage
For some applications, managing authentication tokens in storage (with proper security considerations) can be a simpler approach:
// container/src/services/authService.js
class AuthService {
constructor() {
this.tokenKey = 'auth_token';
this.userKey = 'auth_user';
}
async login(credentials) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
// Save token and user data
localStorage.setItem(this.tokenKey, data.token);
localStorage.setItem(this.userKey, JSON.stringify(data.user));
// Broadcast auth change event for other microfrontends
window.dispatchEvent(new Event('auth_changed'));
return data.user;
}
logout() {
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.userKey);
// Broadcast auth change event
window.dispatchEvent(new Event('auth_changed'));
}
getCurrentUser() {
const userJson = localStorage.getItem(this.userKey);
return userJson ? JSON.parse(userJson) : null;
}
getToken() {
return localStorage.getItem(this.tokenKey);
}
isAuthenticated() {
return !!this.getToken();
}
async refreshToken() {
const token = this.getToken();
if (!token) return null;
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
this.logout();
throw new Error('Token refresh failed');
}
const data = await response.json();
localStorage.setItem(this.tokenKey, data.token);
return data.token;
}
}
export default new AuthService();
// In a microfrontend
import React, { useState, useEffect } from 'react';
function AuthAwareComponent() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
// Check initial auth state
const checkAuth = () => {
const token = localStorage.getItem('auth_token');
setIsAuthenticated(!!token);
};
// Set up listener for auth changes
window.addEventListener('auth_changed', checkAuth);
// Check initial state
checkAuth();
// Clean up
return () => {
window.removeEventListener('auth_changed', checkAuth);
};
}, []);
return (
<div>
{isAuthenticated ? (
<p>User is logged in</p>
) : (
<p>User is not logged in</p>
)}
</div>
);
}
Challenge 6: Error Handling and Resilience
In a microfrontend architecture, a failure in one microfrontend should not crash the entire application.
The Problem
- Errors in one microfrontend can cascade to others
- Poor error recovery and fallback UI
- No isolation between microfrontends
- Difficulty diagnosing errors that cross microfrontend boundaries
Solution 1: Error Boundaries
Use React's Error Boundaries to catch errors in microfrontends:
// container/src/components/ErrorBoundary.js
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log the error to an error reporting service
console.error('Error caught by boundary:', error, errorInfo);
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
}
reset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return typeof this.props.fallback === 'function'
? this.props.fallback(this.state.error, this.reset)
: this.props.fallback;
}
return (
<div className="error-container">
<h2>Something went wrong</h2>
<p>We're sorry, but there was an error loading this component.</p>
<button onClick={this.reset}>Try Again</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
Wrap each microfrontend with an error boundary in the container:
// container/src/App.js
import React, { lazy, Suspense } from 'react';
import ErrorBoundary from './components/ErrorBoundary';
const ProductCatalog = lazy(() => import('productCatalog/ProductCatalog'));
const ProductDetails = lazy(() => import('productDetails/ProductDetails'));
function App() {
return (
<div>
<ErrorBoundary
fallback={(error, reset) => (
<div>
<h2>Product Catalog Error</h2>
<p>There was a problem loading the product catalog.</p>
<button onClick={reset}>Retry</button>
</div>
)}
>
<Suspense fallback={<div>Loading catalog...</div>}>
<ProductCatalog />
</Suspense>
</ErrorBoundary>
<ErrorBoundary
fallback={<div>Failed to load product details</div>}
>
<Suspense fallback={<div>Loading details...</div>}>
<ProductDetails />
</Suspense>
</ErrorBoundary>
</div>
);
}
Solution 2: Circuit Breaker Pattern
Implement a circuit breaker to prevent continued attempts to load failing microfrontends:
// container/src/hooks/useCircuitBreaker.js
import { useState, useEffect } from 'react';
export default function useCircuitBreaker(loadComponent, options = {}) {
const {
maxFailures = 3,
resetTimeout = 30000, // 30 seconds
fallback = null,
} = options;
const [component, setComponent] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [failures, setFailures] = useState(0);
const [circuitOpen, setCircuitOpen] = useState(false);
useEffect(() => {
// If circuit is open, don't attempt to load
if (circuitOpen) return;
let isMounted = true;
setLoading(true);
loadComponent()
.then(moduleOrComponent => {
if (!isMounted) return;
// Get the default export if it's a module
const Component = moduleOrComponent.default || moduleOrComponent;
setComponent(() => Component);
setLoading(false);
// Reset failures on success
setFailures(0);
})
.catch(err => {
if (!isMounted) return;
setError(err);
setLoading(false);
// Increment failure count
const newFailures = failures + 1;
setFailures(newFailures);
// Open the circuit if max failures reached
if (newFailures >= maxFailures) {
setCircuitOpen(true);
// Set a timeout to close the circuit
setTimeout(() => {
if (isMounted) {
setCircuitOpen(false);
setFailures(0);
}
}, resetTimeout);
}
});
return () => {
isMounted = false;
};
}, [loadComponent, failures, circuitOpen, maxFailures, resetTimeout]);
// Return an object with the component and state
return {
Component: component,
loading,
error,
circuitOpen,
retry: () => {
setFailures(0);
setCircuitOpen(false);
setLoading(true);
},
};
}
// Usage in container
import useCircuitBreaker from './hooks/useCircuitBreaker';
function MicrofrontendLoader({ name, importFunc, fallback }) {
const {
Component,
loading,
error,
circuitOpen,
retry
} = useCircuitBreaker(() => importFunc(), {
maxFailures: 3,
resetTimeout: 60000, // 1 minute
});
if (loading) {
return <div>Loading {name}...</div>;
}
if (circuitOpen) {
return (
<div className="circuit-breaker-fallback">
<h2>{name} is currently unavailable</h2>
<p>We're experiencing issues loading this feature.</p>
<button onClick={retry}>Try Again</button>
{fallback && <div className="fallback-content">{fallback}</div>}
</div>
);
}
if (error) {
return (
<div className="error-fallback">
<h2>Error loading {name}</h2>
<button onClick={retry}>Retry</button>
</div>
);
}
return <Component />;
}
// In App.js
<MicrofrontendLoader
name="Product Catalog"
importFunc={() => import('productCatalog/ProductCatalog')}
fallback={<StaticProductList />}
/>
Solution 3: Health Checks
Implement health checks to detect issues before loading microfrontends:
// container/src/services/healthCheck.js
class HealthCheckService {
async checkHealth(microfrontendUrl) {
try {
// Append health endpoint to the microfrontend URL
const healthUrl = new URL('/health', microfrontendUrl).toString();
const response = await fetch(healthUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
// Short timeout to avoid long waits
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
return { healthy: false, error: `Status: ${response.status}` };
}
const data = await response.json();
return { healthy: data.status === 'ok', data };
} catch (error) {
return { healthy: false, error: error.message };
}
}
// Check multiple microfrontends at once
async checkAll(microfrontends) {
const results = {};
const checks = microfrontends.map(async mf => {
results[mf.name] = await this.checkHealth(mf.url);
});
await Promise.allSettled(checks);
return results;
}
}
export default new HealthCheckService();
// In container app
import { useEffect, useState } from 'react';
import healthCheckService from './services/healthCheck';
function App() {
const [healthStatus, setHealthStatus] = useState({});
useEffect(() => {
const microfrontends = [
{ name: 'productCatalog', url: 'http://localhost:3001' },
{ name: 'productDetails', url: 'http://localhost:3002' },
{ name: 'shoppingCart', url: 'http://localhost:3003' },
];
const checkHealth = async () => {
const results = await healthCheckService.checkAll(microfrontends);
setHealthStatus(results);
};
// Check immediately on load
checkHealth();
// Then check periodically
const interval = setInterval(checkHealth, 60000); // Every minute
return () => clearInterval(interval);
}, []);
// Render microfrontends based on health status
return (
<div>
{healthStatus.productCatalog?.healthy ? (
<Suspense fallback={<div>Loading catalog...</div>}>
<ProductCatalog />
</Suspense>
) : (
<ProductCatalogFallback />
)}
{/* Other microfrontends */}
</div>
);
}
Challenge 7: Deployment and Release Coordination
Coordinating deployments across multiple independently developed microfrontends can be complex.
The Problem
- Version compatibility issues between container and microfrontends
- Handling breaking changes in shared interfaces
- Coordinating releases for features that span multiple microfrontends
- Testing in staging environments with multiple deployment pipelines
Solution 1: Versioned URLs
Use versioned URLs for microfrontends to ensure compatibility:
// container/webpack.config.js
new ModuleFederationPlugin({
name: "container",
remotes: {
productCatalog: `productCatalog@https://catalog.example.com/v1/remoteEntry.js`,
productDetails: `productDetails@https://details.example.com/v2/remoteEntry.js`,
// Other remotes
},
// ...
});
This approach ensures that the container always loads a compatible version of each microfrontend, even as newer versions are deployed.
Solution 2: Environment-Based Configuration
Use environment variables to configure microfrontend URLs based on environment:
// container/webpack.config.js
const {
PRODUCT_CATALOG_URL,
PRODUCT_DETAILS_URL,
SHOPPING_CART_URL
} = process.env;
module.exports = {
// ...
plugins: [
new ModuleFederationPlugin({
name: "container",
remotes: {
productCatalog: `productCatalog@${PRODUCT_CATALOG_URL}/remoteEntry.js`,
productDetails: `productDetails@${PRODUCT_DETAILS_URL}/remoteEntry.js`,
shoppingCart: `shoppingCart@${SHOPPING_CART_URL}/remoteEntry.js`,
},
// ...
}),
],
};
# GitHub Actions workflow example
name: Deploy Container
on:
push:
branches:
- main
paths:
- 'container/**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Configure environment
run: |
if [[ $GITHUB_REF == refs/heads/main ]]; then
echo "Setting production environment variables"
echo "PRODUCT_CATALOG_URL=https://catalog.example.com" >> $GITHUB_ENV
echo "PRODUCT_DETAILS_URL=https://details.example.com" >> $GITHUB_ENV
echo "SHOPPING_CART_URL=https://cart.example.com" >> $GITHUB_ENV
else
echo "Setting staging environment variables"
echo "PRODUCT_CATALOG_URL=https://staging-catalog.example.com" >> $GITHUB_ENV
echo "PRODUCT_DETAILS_URL=https://staging-details.example.com" >> $GITHUB_ENV
echo "SHOPPING_CART_URL=https://staging-cart.example.com" >> $GITHUB_ENV
fi
- name: Build
run: |
cd container
npm ci
npm run build
env:
PRODUCT_CATALOG_URL: ${{ env.PRODUCT_CATALOG_URL }}
PRODUCT_DETAILS_URL: ${{ env.PRODUCT_DETAILS_URL }}
SHOPPING_CART_URL: ${{ env.SHOPPING_CART_URL }}
# Deployment steps...
Solution 3: Runtime URL Configuration
Load microfrontend URLs at runtime for more flexibility:
// container/src/config.js
const environments = {
production: {
productCatalog: 'https://catalog.example.com',
productDetails: 'https://details.example.com',
shoppingCart: 'https://cart.example.com',
},
staging: {
productCatalog: 'https://staging-catalog.example.com',
productDetails: 'https://staging-details.example.com',
shoppingCart: 'https://staging-cart.example.com',
},
development: {
productCatalog: 'http://localhost:3001',
productDetails: 'http://localhost:3002',
shoppingCart: 'http://localhost:3003',
},
};
// Determine environment from hostname or env var
function getEnvironment() {
const hostname = window.location.hostname;
if (hostname === 'localhost') {
return 'development';
} else if (hostname.includes('staging')) {
return 'staging';
} else {
return 'production';
}
}
// Generate microfrontend URLs
export function getMicrofrontendUrls() {
const env = getEnvironment();
const urls = environments[env];
// Return URLs with remoteEntry.js path
return Object.entries(urls).reduce((acc, [name, url]) => {
acc[name] = `${url}/remoteEntry.js`;
return acc;
}, {});
}
// container/src/bootstrap.js
import { getMicrofrontendUrls } from './config';
// Dynamically load microfrontends
async function loadMicrofrontend(name, url) {
// Create script element for the remote entry
const script = document.createElement('script');
script.src = url;
script.async = true;
// Wait for script to load
await new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
// Access the global variable created by the remote entry
// @ts-ignore
const container = window[name];
// Initialize the container
await container.init(__webpack_share_scopes__.default);
return container;
}
async function initApp() {
const urls = getMicrofrontendUrls();
try {
// Load microfrontends
const productCatalog = await loadMicrofrontend('productCatalog', urls.productCatalog);
const ProductCatalogComponent = await productCatalog.get('./ProductCatalog');
// Load other microfrontends...
// Render the app once all microfrontends are loaded
const root = document.getElementById('root');
ReactDOM.render(<App />, root);
} catch (error) {
console.error('Failed to load microfrontends:', error);
// Show error UI
}
}
initApp();
Solution 4: Feature Flags
Use feature flags to coordinate the release of features that span multiple microfrontends:
// container/src/services/featureFlags.js
class FeatureFlagService {
constructor() {
this.flags = {};
this.listeners = [];
}
async initialize() {
try {
const response = await fetch('/api/feature-flags');
const data = await response.json();
this.flags = data;
this.notifyListeners();
} catch (error) {
console.error('Failed to load feature flags:', error);
// Default to conservative values (features off)
this.flags = {};
}
}
isEnabled(flagName) {
return !!this.flags[flagName];
}
subscribe(listener) {
this.listeners.push(listener);
listener(this.flags);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
notifyListeners() {
this.listeners.forEach(listener => listener(this.flags));
}
}
export default new FeatureFlagService();
// container/src/hooks/useFeatureFlag.js
import { useState, useEffect } from 'react';
import featureFlagService from '../services/featureFlags';
export function useFeatureFlag(flagName, defaultValue = false) {
const [isEnabled, setIsEnabled] = useState(
() => featureFlagService.isEnabled(flagName) ?? defaultValue
);
useEffect(() => {
return featureFlagService.subscribe(flags => {
setIsEnabled(flags[flagName] ?? defaultValue);
});
}, [flagName, defaultValue]);
return isEnabled;
}
// Usage in components
import { useFeatureFlag } from '../hooks/useFeatureFlag';
function NewFeature() {
const isEnabled = useFeatureFlag('new-checkout-flow', false);
if (!isEnabled) {
return null; // Or return old version
}
return (
<div>
{/* New feature implementation */}
</div>
);
}
Conclusion
Building a successful microfrontend architecture requires careful consideration of numerous challenges. In this article, we've explored solutions to the most common issues:
-
Consistent Styling and Design: Implement a shared design system to ensure visual consistency.
-
State Management: Choose the right state sharing approach based on your needs, from context-based solutions to event-driven patterns.
-
Navigation and Routing: Coordinate routing through shell-based routing, shared router instances, or dynamic route registration.
-
Performance Optimization: Optimize bundle sizes, share dependencies, and implement intelligent loading strategies.
-
Authentication and Authorization: Centralize auth logic while allowing microfrontends to respond appropriately.
-
Error Handling and Resilience: Use error boundaries, circuit breakers, and health checks to build resilient applications.
-
Deployment and Release Coordination: Use versioned URLs, environment configuration, and feature flags to coordinate releases.
With these solutions in your toolkit, you'll be well-equipped to build robust, scalable microfrontend architectures that enable team autonomy while providing a seamless user experience.
In the final part of our series, we'll look at testing, monitoring, and debugging microfrontend applications, as well as exploring advanced patterns for specific use cases.
A Question to Ponder
As you implement solutions to these microfrontend challenges, consider this:
In our quest for team independence through microfrontends, how do we balance the autonomy of individual teams with the need for a cohesive product experience and development standards?
This tension between autonomy and cohesion is at the heart of microfrontend architecture. Finding the right balance for your organization is key to success.
Stay tuned for Part 5, where we'll explore testing, monitoring, and advanced patterns for microfrontends.