Advanced Micro-Frontends with Module Federation: Mastering Scalability and Complexity (2025 Edition)
Welcome to the advanced journey into Micro-Frontends with Module Federation! This document assumes you have a solid understanding of the foundational and intermediate concepts of Module Federation, including host/remote architecture, exposing/consuming modules, and shared dependencies.
Here, we’ll tackle the sophisticated challenges and unlock the full potential of micro-frontends, addressing topics critical for large-scale, enterprise-grade applications.
1. State Management in Micro-Frontends
Managing state across independently developed and deployed micro-frontends is one of the most significant challenges. While each micro-frontend should ideally manage its own internal state, there are often scenarios where shared state or communication is necessary (e.g., user authentication, shopping cart, global theming).
Detailed Explanation:
Several patterns and tools exist for managing state across federated applications, each with its trade-offs.
1.1 Custom Event Bus (Global Event Emitter)
This is a lightweight and framework-agnostic approach where micro-frontends communicate by dispatching and listening to custom DOM events (or a dedicated EventTarget instance).
- How it works: Micro-frontends dispatch custom events (e.g.,
user-logged-in,item-added-to-cart) on a shared global object (likewindowor a dedicatedEventTargetinstance). Other micro-frontends listen for these events and react accordingly. - Pros: Simple to implement, highly decoupled, framework-agnostic.
- Cons: Can become difficult to trace and debug in complex scenarios (event soup), no built-in state synchronization (requires manual updates), prone to typos in event names.
Code Example: Implementing a Global Event Bus
Let’s enhance our E-commerce example where the cart-app updates the host-app’s cart count using a custom event. (We already touched upon this in Project 1).
cart-app/src/Cart.tsx (Revisited):
// cart-app/src/Cart.tsx
import React, { useState, useEffect } from 'react';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
// Global Event Emitter
// Expose this if other micro-frontends need to dispatch directly
export const globalEventEmitter = new EventTarget();
export const addToCart = (product: { id: number; name: string; price: number }) => {
const event = new CustomEvent('mf-add-to-cart', { detail: product });
globalEventEmitter.dispatchEvent(event);
};
export const CartDisplay: React.FC = () => {
const [cartItems, setCartItems] = useState<CartItem[]>([]);
useEffect(() => {
const handleAddToCart = (event: Event) => {
const product = (event as CustomEvent).detail;
setCartItems(prevItems => {
const existingItem = prevItems.find(item => item.id === product.id);
if (existingItem) {
return prevItems.map(item =>
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
);
}
return [...prevItems, { ...product, quantity: 1 }];
});
};
globalEventEmitter.addEventListener('mf-add-to-cart', handleAddToCart as EventListener);
return () => {
globalEventEmitter.removeEventListener('mf-add-to-cart', handleAddToCart as EventListener);
};
}, []);
const totalItems = cartItems.reduce((sum, item) => sum + item.quantity, 0);
// Notify listeners when cart updates
useEffect(() => {
const cartUpdateEvent = new CustomEvent('mf-cart-updated', { detail: { totalItems } });
globalEventEmitter.dispatchEvent(cartUpdateEvent); // Dispatch on our global event emitter
}, [totalItems]);
return (
<div style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px', backgroundColor: '#f9f9f9', marginTop: '20px' }}>
{/* ... (Cart items display) ... */}
<p style={{ fontWeight: 'bold' }}>Total items: {totalItems}</p>
</div>
);
};
export default CartDisplay;
host-app/src/index.tsx (Revisited):
// host-app/src/index.tsx
import React, { Suspense, lazy, useState, useEffect } from 'react';
import ReactDOM from 'react-dom/client';
const RemoteProductList = lazy(() => import('productsApp/ProductList'));
const RemoteCartDisplay = lazy(() => import('cartApp/CartDisplay'));
// Dynamically import the global event emitter from cartApp
// Note: This relies on the 'cartApp' being loaded first if the host needs it directly,
// or on the host creating its own event emitter and cartApp using that.
// For simplicity, we assume cartApp provides it.
// In a real scenario, you might have a dedicated 'shared-utils' MF exposing the event bus.
let globalEventEmitter: EventTarget;
(async () => {
try {
const { globalEventEmitter: emitter } = await (import('cartApp/Cart') as any);
globalEventEmitter = emitter;
} catch (error) {
console.warn('Could not load globalEventEmitter from cartApp, using local fallback.', error);
globalEventEmitter = new EventTarget(); // Fallback
}
})();
const App = () => {
const [cartCount, setCartCount] = useState(0);
useEffect(() => {
// We listen to events dispatched by the cart-app
const handleCartUpdate = (event: Event) => {
const { totalItems } = (event as CustomEvent).detail;
setCartCount(totalItems);
};
// Ensure globalEventEmitter is defined before adding listener
if (globalEventEmitter) {
globalEventEmitter.addEventListener('mf-cart-updated', handleCartUpdate as EventListener);
return () => {
globalEventEmitter.removeEventListener('mf-cart-updated', handleCartUpdate as EventListener);
};
}
}, []);
return (
<div>
<nav style={{ padding: '10px', backgroundColor: '#333', color: 'white', display: 'flex', justifyContent: 'space-between' }}>
<h1 style={{ margin: 0 }}>E-commerce Host</h1>
<div>
<a href="#" style={{ color: 'white', marginRight: '15px' }}>Home</a>
<a href="#" style={{ color: 'white' }}>Cart ({cartCount})</a>
</div>
</nav>
<div style={{ padding: '20px' }}>
<h2>Welcome to our Store!</h2>
<Suspense fallback={<div>Loading Products...</div>}>
<RemoteProductList />
</Suspense>
<Suspense fallback={<div>Loading Cart...</div>}>
<RemoteCartDisplay />
</Suspense>
</div>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<App />);
products-app/src/ProductList.tsx (Revisited):
// products-app/src/ProductList.tsx
import React from 'react';
interface Product {
id: number;
name: string;
price: number;
}
const products: Product[] = [
{ id: 1, name: 'Laptop', price: 1200 },
{ id: 2, name: 'Mouse', price: 25 },
{ id: 3, name: 'Keyboard', price: 75 },
];
const handleAddToCartClick = async (product: Product) => {
try {
const { addToCart } = await (import('cartApp/Cart') as any); // Import the addToCart function
addToCart(product);
alert(`${product.name} added to cart!`);
} catch (error) {
console.error('Failed to add to cart:', error);
alert('Could not add item to cart. Cart app might not be available or its API changed.');
}
};
const ProductList: React.FC = () => {
return (
<div style={{ border: '1px solid #eee', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
<h2>Products Available</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{products.map(product => (
<li key={product.id} style={{ marginBottom: '10px', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{product.name} - ${product.price}</span>
<button
style={{ padding: '8px 12px', backgroundColor: 'green', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
onClick={() => handleAddToCartClick(product)}
>
Add to Cart
</button>
</li>
))}
</ul>
</div>
);
};
export default ProductList;
Exercise 1.1: Shared User Authentication State
Imagine you have an auth-app micro-frontend that handles user login.
- Create a new
auth-appremote onport: 3003that exposes:- An
AuthComponent(login/logout buttons, displays user status). - A
loginfunction. - A
logoutfunction. - A global event bus (similar to
cart-app) that dispatchesuser-logged-inanduser-logged-outevents, with user details inevent.detail.
- An
- The
host-appshould consumeAuthComponentand also listen touser-logged-in/user-logged-outevents to display a “Welcome, [Username]!” message in its navigation. - Ensure
auth-appandhost-appboth correctly share React/React-DOM. - Run all three applications and verify that logging in/out in the
auth-appupdates thehost-app’s display.
1.2 Shared State Management Library (Federated Store)
For more complex global state needs, a dedicated state management library can be federated.
- How it works: A lightweight state management solution (e.g., Zustand, Jotai, Recoil, or even a custom global store using plain objects/functions) is exposed by one micro-frontend (or a dedicated
shared-store-app). Other micro-frontends consume this federated store and subscribe to its changes. - Pros: Provides a single source of truth, powerful debugging tools (if the library supports it), familiar patterns for developers.
- Cons: Can create tight coupling if not designed carefully, potential for version conflicts if the shared state library is not rigorously managed with
singleton: true.
Code Example: Federated Zustand Store
Let’s refactor the cart state using Zustand as a shared store.
1. Create a shared-state-app (on port: 3004) to expose the Zustand store:
cd module-federation-tutorial/packages
mkdir shared-state-app
cd shared-state-app
npm init -y
npm install react react-dom zustand webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/core @babel/preset-react --save-dev
npm install --save-dev typescript @types/react @types/react-dom @types/webpack @types/webpack-dev-server @types/zustand
shared-state-app/src/cartStore.ts:
// shared-state-app/src/cartStore.ts
import { create } from 'zustand';
interface Product {
id: number;
name: string;
price: number;
}
interface CartItem extends Product {
quantity: number;
}
interface CartState {
items: CartItem[];
addToCart: (product: Product) => void;
clearCart: () => void;
getTotalItems: () => number;
}
export const useCartStore = create<CartState>((set, get) => ({
items: [],
addToCart: (product) =>
set((state) => {
const existingItem = state.items.find((item) => item.id === product.id);
if (existingItem) {
return {
items: state.items.map((item) =>
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
),
};
}
return { items: [...state.items, { ...product, quantity: 1 }] };
}),
clearCart: () => set({ items: [] }),
getTotalItems: () => get().items.reduce((sum, item) => sum + item.quantity, 0),
}));
shared-state-app/src/index.tsx: (Just to have an entry point, not for rendering, as we only expose the store)
// shared-state-app/src/index.tsx
console.log('Shared State App - only exposing cartStore.ts');
shared-state-app/public/index.html:
<!-- shared-state-app/public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shared State App</title>
</head>
<body>
<div id="root"></div>
<script>
// This app doesn't render anything, it just exposes modules.
// You can put a message here to indicate it's running.
console.log('Shared State App is running and exposing cartStore.');
</script>
</body>
</html>
shared-state-app/webpack.config.js:
// shared-state-app/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
module.exports = {
entry: './src/index.tsx', // Point to a dummy entry or main file
mode: 'development',
devServer: {
port: 3004,
historyApiFallback: true,
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react', '@babel/preset-typescript'],
},
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'sharedStateApp',
filename: 'remoteEntry.js',
exposes: {
'./useCartStore': './src/cartStore', // Expose the Zustand store
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
zustand: { singleton: true, requiredVersion: '^4.3.0' }, // IMPORTANT: Share Zustand
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
output: {
publicPath: 'http://localhost:3004/',
},
};
2. Modify host-app/webpack.config.js to consume sharedStateApp:
// host-app/webpack.config.js
// ...
plugins: [
new ModuleFederationPlugin({
name: 'hostApp',
remotes: {
productsApp: 'productsApp@http://localhost:3001/remoteEntry.js',
cartApp: 'cartApp@http://localhost:3002/remoteEntry.js',
sharedStateApp: 'sharedStateApp@http://localhost:3004/remoteEntry.js', // Add sharedStateApp
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
zustand: { singleton: true, requiredVersion: '^4.3.0' }, // Host also shares Zustand
},
}),
// ...
],
// ...
3. Modify products-app/webpack.config.js to consume sharedStateApp:
// products-app/webpack.config.js
// ...
plugins: [
new ModuleFederationPlugin({
name: 'productsApp',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/ProductList',
},
remotes: {
sharedStateApp: 'sharedStateApp@http://localhost:3004/remoteEntry.js', // Consume sharedStateApp
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
zustand: { singleton: true, requiredVersion: '^4.3.0' }, // products-app also shares Zustand
},
}),
// ...
],
// ...
4. Modify cart-app/webpack.config.js to consume sharedStateApp and remove its internal state:
// cart-app/webpack.config.js
// ...
plugins: [
new ModuleFederationPlugin({
name: 'cartApp',
filename: 'remoteEntry.js',
exposes: {
'./CartDisplay': './src/Cart',
// './addToCart': './src/Cart', // addToCart will now directly use the shared store
},
remotes: {
sharedStateApp: 'sharedStateApp@http://localhost:3004/remoteEntry.js', // Consume sharedStateApp
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
zustand: { singleton: true, requiredVersion: '^4.3.0' }, // cart-app also shares Zustand
},
}),
// ...
],
// ...
5. Update cart-app/src/Cart.tsx to use useCartStore:
// cart-app/src/Cart.tsx
import React from 'react';
import { useCartStore } from 'sharedStateApp/useCartStore'; // Import from shared store
interface Product { // Still need this interface for incoming product data
id: number;
name: string;
price: number;
}
// addToCart function now directly interacts with the shared store
export const addToCart = (product: Product) => {
// We need to dynamically import the store creator itself
// because useCartStore is a hook and cannot be called at top level.
// In a real app, you'd likely use a custom hook in each app
// that uses the federated store.
// For this example, we'll expose a wrapper.
// Simpler approach for demonstration:
(async () => {
try {
const { useCartStore: remoteUseCartStore } = await (import('sharedStateApp/useCartStore') as any);
// Access the store directly to call addToCart
remoteUseCartStore.getState().addToCart(product);
} catch (error) {
console.error('Error adding to cart via shared store:', error);
}
})();
};
export const CartDisplay: React.FC = () => {
const cartItems = useCartStore(state => state.items);
const totalItems = useCartStore(state => state.getTotalItems());
const totalPrice = cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);
return (
<div style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px', backgroundColor: '#f9f9f9', marginTop: '20px' }}>
<h3>Your Shopping Cart ({totalItems} items)</h3>
{cartItems.length === 0 ? (
<p>Cart is empty.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{cartItems.map(item => (
<li key={item.id} style={{ marginBottom: '5px' }}>
{item.name} x {item.quantity} - ${item.price * item.quantity}
</li>
))}
</ul>
)}
<p style={{ fontWeight: 'bold' }}>Total: ${totalPrice.toFixed(2)}</p>
<button style={{ padding: '10px 15px', backgroundColor: 'purple', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
Checkout
</button>
</div>
);
};
export default CartDisplay;
6. Update products-app/src/ProductList.tsx to use addToCart from the shared store:
// products-app/src/ProductList.tsx
import React from 'react';
interface Product {
id: number;
name: string;
price: number;
}
const products: Product[] = [
{ id: 1, name: 'Laptop', price: 1200 },
{ id: 2, name: 'Mouse', price: 25 },
{ id: 3, name: 'Keyboard', price: 75 },
];
const handleAddToCartClick = async (product: Product) => {
try {
const { useCartStore: remoteUseCartStore } = await (import('sharedStateApp/useCartStore') as any);
remoteUseCartStore.getState().addToCart(product);
alert(`${product.name} added to cart!`);
} catch (error) {
console.error('Failed to add to cart via shared store:', error);
alert('Could not add item to cart. Shared state app might not be available.');
}
};
const ProductList: React.FC = () => {
return (
<div style={{ border: '1px solid #eee', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
<h2>Products Available</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{products.map(product => (
<li key={product.id} style={{ marginBottom: '10px', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{product.name} - ${product.price}</span>
<button
style={{ padding: '8px 12px', backgroundColor: 'green', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
onClick={() => handleAddToCartClick(product)}
>
Add to Cart
</button>
</li>
))}
</ul>
</div>
);
};
export default ProductList;
7. Update host-app/src/index.tsx to use useCartStore for display:
// host-app/src/index.tsx
import React, { Suspense, lazy } from 'react';
import ReactDOM from 'react-dom/client';
const RemoteProductList = lazy(() => import('productsApp/ProductList'));
const RemoteCartDisplay = lazy(() => import('cartApp/CartDisplay'));
const UseCartStoreHook = lazy(() => import('sharedStateApp/useCartStore').then(m => ({ default: m.useCartStore }))); // Import the hook directly
const App = () => {
const useCartStore = React.useMemo(() => {
// Only access the lazy loaded hook once it's available
// This assumes UseCartStoreHook is available when App renders,
// which relies on 'sharedStateApp' being loaded.
// More robust in a real scenario: check if UseCartStoreHook is null
// or use a custom hook/context that conditionally uses the remote hook.
// For demonstration, we use a trick to get the hook after lazy load.
if (UseCartStoreHook && (UseCartStoreHook as any).default) {
return (UseCartStoreHook as any).default;
}
return null;
}, []);
const cartCount = useCartStore ? useCartStore(state => state.getTotalItems()) : 0;
return (
<div>
<nav style={{ padding: '10px', backgroundColor: '#333', color: 'white', display: 'flex', justifyContent: 'space-between' }}>
<h1 style={{ margin: 0 }}>E-commerce Host</h1>
<div>
<a href="#" style={{ color: 'white', marginRight: '15px' }}>Home</a>
<a href="#" style={{ color: 'white' }}>Cart ({cartCount})</a>
</div>
</nav>
<div style={{ padding: '20px' }}>
<h2>Welcome to our Store!</h2>
<Suspense fallback={<div>Loading Products...</div>}>
<RemoteProductList />
</Suspense>
<Suspense fallback={<div>Loading Cart...</div>}>
<RemoteCartDisplay />
</Suspense>
</div>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<App />);
Running the Federated Zustand Store Example:
- Open four terminal windows.
- Terminal 1:
cd packages/shared-state-app && npm start(Runs onhttp://localhost:3004) - Terminal 2:
cd packages/products-app && npm start(Runs onhttp://localhost:3001) - Terminal 3:
cd packages/cart-app && npm start(Runs onhttp://localhost:3002) - Terminal 4:
cd packages/host-app && npm start(Runs onhttp://localhost:3000) - Navigate to
http://localhost:3000. Click “Add to Cart” buttons. The cart count in the host’s navigation bar, and the cart display fromcart-app, should both update, all driven by theuseCartStorehook exposed byshared-state-app.
1.3 React Context Provider (Federated Context)
Similar to a shared store, a React Context can be exposed by a micro-frontend to provide shared state to its consuming children. This is particularly useful for UI-related state like theme, language, or certain user preferences.
- How it works: A remote application exposes a Context Provider component and a custom hook to consume that context. The host consumes the provider and wraps its application or specific components with it.
- Pros: Natural for React ecosystems, good for hierarchical state, provides strong type safety.
- Cons: Can be more tightly coupled than event bus, requires all consuming components to be descendants of the provider, potentially complex to manage multiple contexts from different remotes.
Exercise 1.2: Federated Theme Context
- Create a new
theme-appremote onport: 3005. - This app should expose a
ThemeProvidercomponent and auseThemehook (using React Context). The theme should include abackgroundColorandtextColorproperty, and atoggleThemefunction. - Modify
host-appto consumeThemeProviderand wrap its main content. - Create a simple
ThemedButtoncomponent inproducts-appthat consumesuseThemeand updates its styles based on the current theme. - Add a
ThemeTogglecomponent to thehost-app’s navigation (which also usesuseTheme) to switch themes. - Run all relevant applications and verify that toggling the theme in the host also changes the style of the
ThemedButtonin theproducts-app.
Best Practices for State Management:
- Minimize Shared State: Each micro-frontend should strive to own its own state. Only share state that is absolutely essential for inter-micro-frontend communication or for a cohesive user experience.
- Clear Contracts: Define clear contracts for shared state (e.g., event names and payload schemas, store structure).
- Isolation: Use techniques to isolate internal state of micro-frontends (e.g., CSS Modules for styling, not relying on global CSS).
- Framework Agnosticism (where possible): For communication, event buses are the most framework-agnostic. For shared data, a federated “data layer” or API client could be beneficial. For UI-specific shared state, libraries within the same framework often work best.
- Version Control: Rigorously manage versions of shared state mechanisms and libraries using
sharedoptions. - Observability: Implement logging and monitoring to trace state changes and communication flows between micro-frontends.
2. Routing in Micro-Frontends
Managing navigation across independently deployed micro-frontends requires careful planning to ensure a seamless user experience, consistent URLs, and proper history management.
Detailed Explanation:
The goal is to allow each micro-frontend to handle its internal routing, while the host application orchestrates the overall navigation and displays the appropriate micro-frontend.
2.1 Host-Driven Routing
In this model, the host application is solely responsible for routing. It dictates which micro-frontend should be rendered based on the URL path.
- How it works: The host uses its router (e.g.,
react-router-dom) to match URL patterns to specific micro-frontends. When a match occurs, the host dynamically loads and renders the corresponding remote application/component. - Pros: Simplifies routing logic within remotes (they don’t need to know about global routes), easy to manage global layout and authentication.
- Cons: Host can become a bottleneck if it needs to know too much about remote routes, less autonomy for micro-frontend teams in defining their own top-level routes.
Code Example: Host-Driven Routing with React Router
Let’s expand our E-commerce example, where the host will route to a products page and a cart page, which are provided by products-app and cart-app respectively.
1. Install react-router-dom in host-app:
cd module-federation-tutorial/packages/host-app
npm install react-router-dom
npm install --save-dev @types/react-router-dom
2. Update host-app/src/index.tsx to use BrowserRouter and Routes:
// host-app/src/index.tsx
import React, { Suspense, lazy, useState, useEffect } from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
const RemoteProductList = lazy(() => import('productsApp/ProductList'));
const RemoteCartDisplay = lazy(() => import('cartApp/CartDisplay'));
const UseCartStoreHook = lazy(() => import('sharedStateApp/useCartStore').then(m => ({ default: m.useCartStore })));
// A simple Home Component for the host
const HomePage = () => (
<div>
<h2>Welcome to the E-commerce Platform!</h2>
<p>Please navigate using the links above.</p>
</div>
);
const App = () => {
const useCartStore = React.useMemo(() => {
if (UseCartStoreHook && (UseCartStoreHook as any).default) {
return (UseCartStoreHook as any).default;
}
return null;
}, []);
const cartCount = useCartStore ? useCartStore(state => state.getTotalItems()) : 0;
return (
<BrowserRouter>
<div>
<nav style={{ padding: '10px', backgroundColor: '#333', color: 'white', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ margin: 0 }}><Link to="/" style={{ color: 'white', textDecoration: 'none' }}>E-commerce Host</Link></h1>
<div>
<Link to="/" style={{ color: 'white', marginRight: '15px' }}>Home</Link>
<Link to="/products" style={{ color: 'white', marginRight: '15px' }}>Products</Link>
<Link to="/cart" style={{ color: 'white' }}>Cart ({cartCount})</Link>
</div>
</nav>
<div style={{ padding: '20px' }}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route
path="/products"
element={
<Suspense fallback={<div>Loading Products...</div>}>
<RemoteProductList />
</Suspense>
}
/>
<Route
path="/cart"
element={
<Suspense fallback={<div>Loading Cart...</div>}>
<RemoteCartDisplay />
</Suspense>
}
/>
{/* Add a generic fallback for not found routes */}
<Route path="*" element={<h2>404 - Page Not Found</h2>} />
</Routes>
</div>
</div>
</BrowserRouter>
);
};
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<App />);
3. Run and Verify:
- Ensure
shared-state-app,products-app, andcart-appare running. - Start
host-app. - Navigate to
http://localhost:3000. Use the navigation links to go to/productsand/cart. Observe how the host loads and displays the respective micro-frontends based on the URL.
2.2 Micro-Frontend-Driven Routing (Cohesive History)
In this model, each micro-frontend can manage its own internal routes (e.g., /products/details/123 within the products-app), but it still cooperates with the host for top-level navigation. The key is to ensure that when a micro-frontend changes its internal route, it also updates the browser’s URL so that the overall application history remains consistent.
- How it works: Each micro-frontend uses its own instance of
react-router-dom(or similar) with aMemoryRouterorcreateMemoryHistoryto manage its internal URL state. It then “pushes” its internal path changes up to the host’s router (e.g., by dispatching a custom event or calling a prop function from the host) which updates the main browser URL. Conversely, the host can “push” route changes down to a micro-frontend. - Pros: Each micro-frontend has full autonomy over its internal routes, allowing for complex sub-navigation.
- Cons: More complex to set up, requires careful synchronization between host and remotes to maintain a single source of truth for the URL.
Code Example: Micro-Frontend-Driven Routing with React Router (Conceptual)
This example is more conceptual as it involves a significant amount of inter-app communication.
1. products-app/src/ProductList.tsx (Internal Routing - Conceptual):
// products-app/src/ProductList.tsx (Conceptual)
import React from 'react';
import { BrowserRouter, Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom';
interface Product {
id: number;
name: string;
price: number;
}
const products: Product[] = [
{ id: 1, name: 'Laptop', price: 1200 },
{ id: 2, name: 'Mouse', price: 25 },
{ id: 3, name: 'Keyboard', price: 75 },
];
const ProductDetailPage: React.FC<{ productId: string }> = ({ productId }) => {
const product = products.find(p => p.id === parseInt(productId));
if (!product) return <h3>Product Not Found</h3>;
return (
<div>
<h4>{product.name} - ${product.price}</h4>
<p>This is the detail page for {product.name}.</p>
{/* Add to cart functionality could be here */}
</div>
);
};
// We need a way for the host to provide the base path for this MF
// and for this MF to notify the host of internal navigation.
interface ProductAppProps {
basePath: string; // e.g., '/products'
onNavigate: (path: string) => void; // Function provided by host to update global URL
}
const ProductListWithRouter: React.FC<ProductAppProps> = ({ basePath, onNavigate }) => {
const location = useLocation();
const navigate = useNavigate();
// Listen for internal navigation and notify the host
React.useEffect(() => {
onNavigate(basePath + location.pathname);
}, [location, basePath, onNavigate]);
return (
<div style={{ border: '1px solid #eee', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
<h2>Products Available</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{products.map(product => (
<li key={product.id} style={{ marginBottom: '10px', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{product.name} - ${product.price}</span>
<Link to={`/details/${product.id}`} style={{ marginRight: '10px' }}>View Details</Link>
<button style={{ padding: '8px 12px', backgroundColor: 'green', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
Add to Cart
</button>
</li>
))}
</ul>
<Routes>
<Route path="/details/:productId" element={<ProductDetailPage productId={useParams().productId!} />} />
{/* Other internal routes for products */}
</Routes>
</div>
);
};
// Main wrapper for products-app to be exposed
// This component would be wrapped in a BrowserRouter in the host,
// or use a MemoryRouter if it needs to isolate its history.
// For host-driven micro-frontend routing, the host would supply the BrowserRouter
// and render ProductListWithRouter inside it.
// For true micro-frontend driven internal routing, a custom history management is needed.
// This is often achieved using a shared history object or event listener.
This requires that the products-app is exposed as a component that accepts routing props (like basePath, onNavigate) from the host.
Exercise 2.1: Implement Simple Micro-Frontend Internal Routing
In
products-app, installreact-router-dom.Modify
products-app/src/ProductList.tsxto include internal routing:- Create a
ProductDetailcomponent that displays details for a specific product ID. - In
ProductList, addLinks to"/details/:productId". - Use
RoutesandRoutewithinProductList.tsx(wrapped by aBrowserRouterwithin its ownindex.tsxfor standalone testing, but will rely on the host’s router when federated). - For the host, make sure it renders the
RemoteProductListon the/products/*route, allowing the remote to handle its sub-paths.
// products-app/src/index.tsx (for standalone testing and potential MF export) import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; import ProductList from './ProductList'; import { useParams } from 'react-router-dom'; // Import useParams const ProductDetail = () => { const { id } = useParams(); return ( <div> <h3>Product Details for ID: {id}</h3> <p>This is a placeholder for product details.</p> </div> ); }; const App = () => { return ( <BrowserRouter basename="/products"> {/* Use basename for standalone testing */} <div> <h1>Products App (Standalone)</h1> <Routes> <Route path="/" element={<ProductList />} /> <Route path="/details/:id" element={<ProductDetail />} /> </Routes> </div> </BrowserRouter> ); }; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render(<App />);- Update
products-app/src/ProductList.tsxto includeLinks to product details:// products-app/src/ProductList.tsx // ... (imports and product data) import { Link } from 'react-router-dom'; const ProductList: React.FC = () => { return ( <div style={{ border: '1px solid #eee', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}> <h2>Products Available</h2> <ul style={{ listStyle: 'none', padding: 0 }}> {products.map(product => ( <li key={product.id} style={{ marginBottom: '10px', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <span>{product.name} - ${product.price}</span> <Link to={`/details/${product.id}`} style={{ marginRight: '10px' }}>View Details</Link> <button /* ... Add to Cart logic ... */ > Add to Cart </button> </li> ))} </ul> </div> ); }; export default ProductList; - Crucial Step for Host-driven: In
host-app/src/index.tsx, change theproductsroute to allow sub-paths:// host-app/src/index.tsx // ... <Route path="/products/*" // IMPORTANT: Wildcard for sub-paths element={ <Suspense fallback={<div>Loading Products...</div>}> <RemoteProductList /> </Suspense> } /> // ...
- Create a
Run the apps (
shared-state-app,products-app,host-app). Navigate tohttp://localhost:3000/products. Click “View Details” on a product. Observe the URL changing tohttp://localhost:3000/products/details/1(or similar) and the product detail being displayed.
Best Practices for Routing:
- Single Browser History: There should be one canonical source of truth for the browser’s URL and history. Usually, this is managed by the host application.
- Base Path Management: Micro-frontends should be aware of their “base path” within the host (e.g.,
/productsfor the products app). - Absolute vs. Relative Links: Encourage remotes to use relative links for internal navigation within their own scope. For navigation between micro-frontends or to global routes, they should either use absolute paths (which the host’s router will handle) or emit events that the host can interpret into global navigation.
- Shared Router (Advanced): For more tightly integrated routing, a shared router instance could be exposed via Module Federation, allowing all micro-frontends to interact with a single routing system. This creates strong coupling but can simplify complex navigation scenarios.
- Router Versioning: Ensure the
react-router-dom(or equivalent) library is shared as asingletondependency to prevent multiple instances.
3. Server-Side Rendering (SSR) with Module Federation
Combining Server-Side Rendering (SSR) with Module Federation introduces a significant level of complexity, but it’s crucial for achieving optimal SEO, faster initial page loads, and a better user experience, especially for content-heavy or public-facing applications.
Detailed Explanation:
SSR means rendering your React components (including federated ones) into HTML on the server before sending them to the client. This typically involves a Node.js server that imports and renders your federated modules.
The main challenge is that Module Federation is fundamentally a client-side runtime concept. For SSR, the Node.js server needs to understand how to:
- Resolve Remote Modules: Find and load the
remoteEntry.jsfiles (or their server-side equivalents) from remote applications within the Node.js environment. - Hydration: Ensure the client-side JavaScript correctly “hydrates” the server-rendered HTML, picking up where the server left off without re-rendering the entire application.
- Shared Dependencies on Server: Manage shared dependencies so that a single instance is used across all federated modules during server rendering.
Approaches to SSR with Module Federation:
1. @module-federation/node (Recommended for Webpack 5):
This package provides a Node.js runtime for Module Federation, allowing your server to consume federated modules. It addresses the challenge of remoteEntry.js resolution in a Node.js environment.
- How it works: You configure your server to use
@module-federation/node’sModuleFederationRuntimeto register remotes and load them during the SSR process. This creates a server-side equivalent of the client-side Module Federation. - Pros: Native Webpack 5 solution, handles shared dependencies on the server, relatively robust.
- Cons: Still adds complexity to your server setup, requires separate server-side builds for federated modules if they have Node.js-specific code.
Conceptual Steps for SSR:
Server-Side Webpack Configuration:
- You’ll need a separate Webpack configuration for your host and remote applications specifically for server-side bundles. These bundles will target
nodeas the environment. - The
ModuleFederationPluginwill also be used, but with specificlibraryandlibraryTargetoptions for Node.js modules. - The
filenamefor the server-sideremoteEntry.jsmight be different (e.g.,remoteEntry.node.js).
- You’ll need a separate Webpack configuration for your host and remote applications specifically for server-side bundles. These bundles will target
Host Server:
- Your Node.js server will use a rendering engine (e.g., Express with
@module-federation/node). - It will dynamically load the server-side
remoteEntry.jsfiles. - It will
require(orimport) the remote components. - It will use
ReactDOMServer.renderToStringorrenderToPipeableStreamto generate HTML. - The generated HTML will include client-side script tags for the federated bundles for hydration.
- Your Node.js server will use a rendering engine (e.g., Express with
Client-Side Hydration:
- On the client, instead of
ReactDOM.render, you’ll useReactDOM.hydrate(orhydrateRootin React 18) to attach React to the server-rendered HTML.
- On the client, instead of
Code Example: Conceptual SSR with @module-federation/node (Highly Simplified)
host-app/webpack.server.js (Conceptual Server-Side Webpack Config):
// host-app/webpack.server.js
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
const webpack = require('webpack');
const { readFileSync } = require('fs'); // To read remoteEntry URLs from a manifest
// This needs to be dynamic based on your deployment
const getRemoteEntryUrl = (remoteName) => {
// In a real app, this would come from a manifest, env variable, or config service
const remotesManifest = JSON.parse(readFileSync(path.resolve(__dirname, '../../mf-remote-urls.json'), 'utf-8'));
return remotesManifest[remoteName] || `http://localhost:3001/remoteEntry.js`; // Fallback
};
module.exports = {
entry: './src/server.tsx', // Your server-side entry point
target: 'node', // Crucial for SSR
mode: 'development',
output: {
path: path.resolve(__dirname, 'dist-server'),
filename: 'server.js',
libraryTarget: 'commonjs-module', // Expose as CommonJS module
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react', '@babel/preset-typescript'],
},
},
},
// You'll need to handle CSS/assets for SSR, usually by ignoring them or using CSS-in-JS
],
},
plugins: [
new ModuleFederationPlugin({
name: 'hostAppServer',
library: { type: 'commonjs-module' }, // For Node.js consumption
remotes: {
productsApp: `productsApp@${getRemoteEntryUrl('productsApp')}`,
cartApp: `cartApp@${getRemoteEntryUrl('cartApp')}`,
sharedStateApp: `sharedStateApp@${getRemoteEntryUrl('sharedStateApp')}`,
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
zustand: { singleton: true, requiredVersion: '^4.3.0' },
'react-router-dom': { singleton: true, requiredVersion: '^6.x.x' },
},
}),
new webpack.DefinePlugin({
'process.env.IS_SERVER': JSON.stringify(true), // To conditionally render on server/client
}),
],
};
host-app/src/server.tsx (Conceptual Server Entry):
// host-app/src/server.tsx
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server'; // For SSR routing
import App from './App'; // The main App component
// Import remote components for SSR
// These will be resolved by ModuleFederationPlugin in webpack.server.js
// using `@module-federation/node` under the hood.
// const RemoteProductList = require('productsApp/ProductList').default;
// const RemoteCartDisplay = require('cartApp/CartDisplay').default;
// const { useCartStore } = require('sharedStateApp/useCartStore');
// Main function to render the app on the server
export function render(url: string) {
// Pass the URL to StaticRouter for server-side routing
const appHtml = ReactDOMServer.renderToString(
<StaticRouter location={url}>
<App />
</StaticRouter>
);
// You would then inject this into your main HTML template
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSR E-commerce Host</title>
</head>
<body>
<div id="root">${appHtml}</div>
<script src="http://localhost:3000/main.js"></script> <!-- Client-side bundle -->
</body>
</html>
`;
}
host-app/server.js (Express Server - Conceptual):
// host-app/server.js (Main Express server)
const express = require('express');
const app = express();
const PORT = 8080; // Server-side port
// This assumes you've built your server-side bundle
const { render } = require('./dist-server/server');
app.get('*', async (req, res) => {
try {
const html = render(req.url); // Call your SSR function
res.send(html);
} catch (error) {
console.error('SSR Error:', error);
res.status(500).send('<h1>Server Error</h1>');
}
});
app.listen(PORT, () => {
console.log(`SSR Server listening on port ${PORT}`);
});
2. Next.js Module Federation Plugin:
For Next.js users, the @module-federation/nextjs-mf plugin simplifies SSR with Module Federation significantly. It integrates with Next.js’s built-in SSR capabilities.
- How it works: This plugin handles the complexities of server-side bundle resolution, shared library management, and hydration specifically for Next.js applications, making it much easier to federate Next.js micro-frontends.
Exercise 3.1: Research Next.js Module Federation SSR Setup
- Research the
@module-federation/nextjs-mfplugin. - Outline the key configuration steps required to set up a Next.js host and a Next.js remote application with SSR using this plugin.
- Pay attention to how shared dependencies are handled and any specific requirements for
_app.jsor_document.jsfiles.
Best Practices for SSR with Module Federation:
- Separate Builds: Always have distinct Webpack configurations for client-side and server-side builds.
- Target
node: Ensure your server-side bundles targetnode. - Handle Styles & Assets: Styles and images are typically not rendered server-side. You’ll need strategies to manage them (e.g., CSS-in-JS for critical CSS, static serving for images).
- Data Fetching: Data fetching on the server must be carefully managed to ensure all necessary data is available before rendering.
- Isomorphic Code: Write code that can run on both the client and server without issues (e.g., avoid
windowordocumentdirectly on the server). - Hydration: Use
ReactDOM.hydrate(orhydrateRoot) to avoid re-rendering on the client. - Caching: Implement aggressive caching for your SSR server responses and federated bundles.
- Complexity: Understand that SSR with Module Federation is advanced. Start with client-side federation and introduce SSR only when necessary.
4. Module Federation with Other Build Tools (Vite, Rspack)
While Webpack is the original home of Module Federation, the core concepts are extensible. Module Federation 2.0 (with its decoupled runtime) aims to facilitate integration with other build tools, providing flexibility.
Detailed Explanation:
4.1 Vite Module Federation (@module-federation/vite)
Vite has rapidly gained popularity due to its extremely fast development server and optimized build process (using Rollup). Integrating Module Federation with Vite brings the benefits of micro-frontends to the Vite ecosystem.
- How it works: The
@module-federation/viteplugin (https://github.com/originjs/vite-plugin-federation) adapts the Module Federation principles to Vite/Rollup. It provides a similar API to Webpack’sModuleFederationPlugin. - Pros: Leverages Vite’s speed, modern build process, and good developer experience.
- Cons: Still a newer integration compared to Webpack, ecosystem might be less mature for very complex scenarios.
Code Example: Vite Module Federation (Conceptual)
vite-host-app/vite.config.ts (Conceptual):
// vite-host-app/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@module-federation/vite'; // Import the Vite plugin
export default defineConfig({
plugins: [
react(),
federation({
name: 'viteHostApp',
remotes: {
viteRemoteApp1: 'viteRemoteApp1@http://localhost:4001/assets/remoteEntry.js', // Adjust path for Vite
},
shared: ['react', 'react-dom'], // Vite plugin simplifies shared config
}),
],
build: {
target: 'esnext',
minify: false,
cssCodeSplit: false,
},
server: {
port: 4000,
},
});
vite-remote-app-1/vite.config.ts (Conceptual):
// vite-remote-app-1/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@module-federation/vite';
export default defineConfig({
plugins: [
react(),
federation({
name: 'viteRemoteApp1',
filename: 'remoteEntry.js', // Output file name
exposes: {
'./ComponentA': './src/components/ComponentA',
},
shared: ['react', 'react-dom'],
}),
],
build: {
target: 'esnext',
minify: false,
cssCodeSplit: false,
},
server: {
port: 4001,
},
});
4.2 Rspack Module Federation
Rspack is a next-generation build tool compatible with the Webpack ecosystem, aiming for much faster build times (leveraging Rust). It has native, built-in support for Module Federation.
- How it works: Since Rspack aims for Webpack compatibility, its Module Federation implementation is very similar to Webpack’s, often requiring minimal changes to existing Webpack MF configurations.
- Pros: Significantly faster build times than Webpack, Webpack compatibility, native MF support.
- Cons: Newer tool, still maturing, some advanced Webpack features might not be fully supported yet.
Code Example: Rspack Module Federation (Conceptual)
The configuration would look very similar to our Webpack examples, just using rspack.config.js instead of webpack.config.js.
// rspack.config.js (Conceptual, for a host or remote)
const { ModuleFederationPlugin } = require('@rspack/core').container; // Import from Rspack core
module.exports = {
// ... other Rspack config
plugins: [
new ModuleFederationPlugin({
name: 'rspackHostApp',
remotes: {
rspackRemoteApp: 'rspackRemoteApp@http://localhost:5001/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
// ...
};
Exercise 4.1: Explore Vite Module Federation Configuration
- Set up a new monorepo (or new folders in your existing one) for a Vite-based micro-frontend project.
- Create a
vite-host-appand avite-remote-app. - Install
@vitejs/plugin-reactand@module-federation/vite. - Configure
vite.config.tsfor both the host and remote to:- Expose a simple component from the
vite-remote-app. - Consume that component in the
vite-host-app. - Ensure
reactandreact-domare shared correctly.
- Expose a simple component from the
- Run both applications using
viteand verify the remote component loads in the host.
Best Practices for Multi-Tool Module Federation:
- Consistency: Try to maintain consistency in your MF configuration across different tools where possible.
- Shared Runtime (MF 2.0): Leverage the decoupled runtime of Module Federation 2.0 to ensure better interoperability between different build tool implementations.
- Documentation: Clearly document which build tool is used for which micro-frontend and any specific configurations.
- Monorepo Tools: Tools like Nx are excellent for managing complex monorepos with mixed build tools and Module Federation configurations.
5. Universal Module Federation (UMF)
Universal Module Federation (UMF) extends the concept of Module Federation beyond just browser-to-browser communication. It allows for sharing modules between different JavaScript runtimes, such as browser, Node.js (for SSR), and even Web Workers.
Detailed Explanation:
UMF aims to create a truly “universal” module sharing mechanism. This means that a module could be built once and consumed by:
A client-side browser application.
A Node.js server for SSR.
A serverless function (e.g., AWS Lambda, Google Cloud Functions).
A Web Worker for offloading heavy computations.
How it works: UMF relies on adapting the Module Federation runtime to different JavaScript environments. The
@module-federation/nodepackage is an example for Node.js. For Web Workers, custom loaders or runtime adaptations would be needed. The core idea is to abstract away the environment-specific module loading mechanisms.Pros: True “build once, run anywhere” for modules, highly efficient code reuse, simplifies complex architectures that span multiple runtimes.
Cons: Very advanced topic, still an evolving area, requires deep understanding of each runtime environment.
Code Example: UMF with Web Workers (Conceptual)
This is a conceptual example, as full Web Worker Module Federation requires specialized plugins/runtimes not covered in depth here.
worker-app/webpack.worker.js (Conceptual Web Worker Webpack Config):
// worker-app/webpack.worker.js
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
module.exports = {
entry: './src/workerEntry.ts',
target: 'webworker', // Target for Web Worker
mode: 'development',
output: {
path: path.resolve(__dirname, 'dist-worker'),
filename: 'worker.js',
},
plugins: [
new ModuleFederationPlugin({
name: 'workerApp',
filename: 'remoteEntry.js', // This remote entry would contain worker-specific modules
exposes: {
'./heavyCalc': './src/heavyCalcWorker', // Exposing a function for heavy computation
},
shared: [], // Workers usually have limited shared dependencies
}),
],
};
host-app/src/index.tsx (Conceptual Host consuming Worker module):
// host-app/src/index.tsx (Conceptual)
import React, { useState } from 'react';
const WorkerComponent: React.FC = () => {
const [result, setResult] = useState<number | null>(null);
const performCalculation = async () => {
const worker = new Worker(new URL('workerApp/remoteEntry.js', import.meta.url)); // Load worker MF
// This is highly simplified and requires a custom worker runtime loader for MF
// In reality, you'd likely post a message to the worker with the data and receive the result.
worker.postMessage({ type: 'start_calc', data: 10000000 });
worker.onmessage = (e) => {
if (e.data.type === 'calc_result') {
setResult(e.data.result);
worker.terminate();
}
};
};
return (
<div>
<h3>Web Worker Module Federation</h3>
<button onClick={performCalculation}>Calculate in Worker</button>
{result !== null && <p>Result from worker: {result}</p>}
</div>
);
};
export default WorkerComponent;
Exercise 5.1: Research UMF Practical Use Cases
- Research real-world examples or detailed architectural discussions about Universal Module Federation.
- Identify a concrete use case (other than basic SSR) where UMF would provide significant benefits.
- Describe the architecture and key challenges of implementing UMF for that specific use case.
Best Practices for UMF:
- Understand Runtime Differences: Be acutely aware of the capabilities and limitations of each JavaScript runtime (browser, Node.js, Web Worker).
- Environment-Specific Builds: You will likely need specific Webpack/build tool configurations for each target runtime.
- Abstraction Layer: Create an abstraction layer for modules that need to be universal, defining clear interfaces that work across all intended runtimes.
- Debugging Complexity: Debugging UMF applications is significantly more complex due to multiple execution environments.
- Start Small: Only introduce UMF for modules that genuinely benefit from it (e.g., heavy computations for Web Workers, shared logic for both client and server).
6. Testing Micro-Frontends
Testing a micro-frontend architecture effectively requires a strategy that balances independent testing with integration verification.
Detailed Explanation:
Testing micro-frontends involves different levels:
6.1 Unit Testing
- What it covers: Individual components, functions, and modules within a single micro-frontend, in isolation.
- Tools: Jest, React Testing Library, Vitest.
- Best Practice: Each micro-frontend team is responsible for thoroughly unit testing its own codebase.
Code Example: Unit Test for a Remote Component (products-app/src/ProductList.test.tsx)
// products-app/src/ProductList.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ProductList from './ProductList';
import '@testing-library/jest-dom';
// Mock the dynamic import of cartApp/Cart if it's used directly in the component
// For this example, if addToCart is imported at the top level, it needs mocking.
// If it's dynamically imported on button click, the test won't trigger it unless you await it.
jest.mock('sharedStateApp/useCartStore', () => ({
useCartStore: {
getState: () => ({
addToCart: jest.fn(),
getTotalItems: jest.fn(() => 0), // Mock initial state
}),
},
}));
describe('ProductList', () => {
it('renders product list correctly', () => {
render(<ProductList />);
expect(screen.getByText(/Products Available/i)).toBeInTheDocument();
expect(screen.getByText('Laptop - $1200')).toBeInTheDocument();
expect(screen.getByText('Mouse - $25')).toBeInTheDocument();
});
it('calls addToCart when button is clicked', async () => {
const { useCartStore } = await import('sharedStateApp/useCartStore');
const mockAddToCart = useCartStore.getState().addToCart;
render(<ProductList />);
const laptopAddToCartButton = screen.getAllByText('Add to Cart')[0]; // First button for Laptop
fireEvent.click(laptopAddToCartButton);
expect(mockAddToCart).toHaveBeenCalledTimes(1);
expect(mockAddToCart).toHaveBeenCalledWith({ id: 1, name: 'Laptop', price: 1200 });
});
});
- Setup:
cd module-federation-tutorial/packages/products-app npm install --save-dev jest @testing-library/react @testing-library/jest-dom babel-jest ts-jest @babel/preset-env # Add jest config to package.json or a jest.config.js # package.json example: "jest": { "preset": "ts-jest", "testEnvironment": "jsdom", "transform": { "^.+\\.(ts|tsx)$": "ts-jest" }, "moduleNameMapper": { "\\.(css|less|scss|sass)$": "identity-obj-proxy" }, "setupFilesAfterEnv": ["<rootDir>/jest-setup.ts"] },- Create
jest-setup.ts:import '@testing-library/jest-dom'; - Add a
testscript:"test": "jest"
- Create
6.2 Integration Testing
- What it covers: Verifying the interaction between a host and a remote, or between two remotes, in a simulated environment. This ensures that the contracts between federated modules are respected.
- Tools: Cypress Component Testing, Storybook Interaction Testing, Jest with mock federation.
- Best Practice:
- “Host-as-Tester” approach: A dedicated test host application (perhaps stripped down) loads real remote micro-frontends and runs integration tests against the combined system.
- Mocking Federation: For complex scenarios, you might mock Module Federation’s
import()calls in isolation tests to control remote component behavior.
Code Example: Integration Test (Conceptual with Cypress)
// cypress/e2e/host-remote.cy.js (Conceptual)
describe('Host-Remote Integration', () => {
it('should load remote product list and add item to cart', () => {
// Visit the host application
cy.visit('http://localhost:3000'); // Ensure host-app is running
// Check if remote product list is visible
cy.contains('Products Available').should('be.visible');
cy.contains('Laptop - $1200').should('be.visible');
// Click 'Add to Cart' button (from products-app, interacts with shared-state-app)
cy.contains('Laptop - $1200').parent().find('button').click();
// Verify cart count updates in host's nav (due to shared state)
cy.contains('Cart (1)').should('be.visible');
// Navigate to cart page and verify item in remote cart display
cy.contains('Cart (1)').click();
cy.url().should('include', '/cart'); // Host-driven routing to cart page
cy.contains('Your Shopping Cart (1 items)').should('be.visible');
cy.contains('Laptop x 1 - $1200').should('be.visible');
});
});
6.3 End-to-End (E2E) Testing
- What it covers: Testing the entire user flow across all deployed micro-frontends in a production-like environment. This is the ultimate validation of your distributed system.
- Tools: Cypress, Playwright, Selenium.
- Best Practice: Run E2E tests against a staged or production environment after all micro-frontends are deployed. Focus on critical user journeys.
6.4 Visual Regression Testing
- What it covers: Ensuring that UI changes in one micro-frontend do not inadvertently break the visual appearance of other federated modules or the host.
- Tools: Percy, Chromatic, Storybook with snapshot testing.
- Best Practice: Integrate visual regression into your CI/CD pipeline.
Best Practices for Testing Micro-Frontends:
- Independent Test Suites: Each micro-frontend should have its own complete set of unit and integration tests, runnable in isolation.
- Clear Contracts for Integration Tests: Treat the exposed modules as APIs. Define clear test cases that verify the interaction contract between host and remote, or between remotes.
- Staging Environment: A dedicated staging environment that mirrors production is crucial for E2E and integration testing of federated applications.
- Test Data Management: Develop strategies for consistent test data across micro-frontends.
- Parallelization: Optimize your test suites for parallel execution to reduce feedback loop times.
- Observability: Integrate testing with monitoring tools to quickly identify failures and trace them back to the responsible micro-frontend.
Exercise 6.1: Add a Basic Integration Test with Jest and Mocked Federation
In
host-app, create a dummy componentsrc/test-mocks/RemoteProductListMock.tsxthat mimics theProductList’s API (e.g., renders a<div>Mock Product List</div>).Write a Jest test in
host-app/src/index.test.tsxthat:- Mocks the
import('productsApp/ProductList')call to return yourRemoteProductListMock. - Renders the
host-app’sAppcomponent. - Asserts that the
Mock Product Listtext is visible when navigating to/products. - This tests the host’s ability to render a product list, regardless of the real remote.
cd module-federation-tutorial/packages/host-app npm install --save-dev jest @testing-library/react @testing-library/jest-dom babel-jest ts-jest @babel/preset-env identity-obj-proxy- Create
host-app/src/test-mocks/RemoteProductListMock.tsx:// host-app/src/test-mocks/RemoteProductListMock.tsx import React from 'react'; const RemoteProductListMock: React.FC = () => { return <div data-testid="mock-product-list">Mock Product List Component</div>; }; export default RemoteProductListMock; - Create
host-app/src/App.test.tsx:// host-app/src/App.test.tsx import React from 'react'; import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; // Use MemoryRouter for testing import App from './App'; // Mock the lazy import of RemoteProductList jest.mock('productsApp/ProductList', () => ({ __esModule: true, default: jest.fn(() => Promise.resolve({ default: require('../src/test-mocks/RemoteProductListMock').default })), })); describe('Host App Routing Integration', () => { it('renders mock product list when navigating to /products', async () => { render( <MemoryRouter initialEntries={['/products']}> <App /> </MemoryRouter> ); // Wait for the lazy loaded component to appear await screen.findByTestId('mock-product-list'); expect(screen.getByTestId('mock-product-list')).toBeInTheDocument(); expect(screen.getByText(/Mock Product List Component/i)).toBeInTheDocument(); }); }); - Add
jestconfig tohost-app/package.jsonandjest-setup.tsas done forproducts-app.
- Mocks the
I understand you’d like more in-depth, hands-on examples for points 7 and 8 (Deployment & CI/CD and Security Considerations). These topics are inherently more about infrastructure, configuration, and best practices than direct in-browser JavaScript code.
For “Deployment and CI/CD,” creating runnable code samples within this chat environment is challenging because it involves setting up actual CI/CD pipelines, cloud resources (like S3/CDN), and server configurations. However, I can provide:
- Illustrative YAML snippets for CI/CD pipeline steps (e.g., GitHub Actions, GitLab CI) that demonstrate the commands and logic you’d use.
- Mock shell scripts for deployment actions.
- Expanded explanations of how different tools fit together.
For “Security Considerations,” I can provide:
- Detailed code snippets for server-side configurations (e.g., Express.js for CORS, Helmet for CSP).
- Examples of client-side implementation for authentication tokens.
- Clear explanations of the security principles involved.
Let’s dive into the revised and expanded sections.
7. Deployment and CI/CD for Micro-Frontends
The promise of micro-frontends is independent deployment. Realizing this requires a robust Continuous Integration/Continuous Deployment (CI/CD) pipeline for each micro-frontend. This section will provide concrete examples of how to set up such pipelines, focusing on GitHub Actions and common cloud services like AWS S3/CloudFront.
Detailed Explanation:
7.1 Independent Deployment Pipelines
Each micro-frontend (and the host application) should ideally have its own dedicated CI/CD pipeline. This means a change in products-app only triggers its pipeline, not the cart-app or host-app pipelines.
- Pros: Faster deployments, reduced risk (changes are isolated), increased team autonomy, easier scaling of development teams.
- Cons: Requires more pipelines to manage, need for strong versioning and compatibility checks.
Example: GitHub Actions Pipeline for a Remote Micro-Frontend (products-app)
Let’s consider our products-app located in module-federation-tutorial/packages/products-app. Its .github/workflows/deploy-products.yml would look something like this.
Pre-requisites:
- Your
products-appneeds to be containerized (e.g., Docker) or simply built as static assets for a CDN. We’ll use static assets for simplicity. - You’ll need AWS credentials (access key, secret key) stored as GitHub Secrets (e.g.,
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION). - An S3 bucket and CloudFront distribution set up for your
products-app(e.g.,s3-products-app-bucket,cloudfront-products-app-distribution-id).
# .github/workflows/deploy-products.yml in module-federation-tutorial/packages/products-app
name: Deploy Products Micro-Frontend
on:
push:
branches:
- main # Trigger on pushes to the main branch
paths:
- 'packages/products-app/**' # Only trigger if changes are in products-app
- '.github/workflows/deploy-products.yml' # Or if pipeline config changes
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18' # Or your desired Node.js version
cache: 'npm'
cache-dependency-path: '**/package-lock.json' # Adjust if using yarn/pnpm
- name: Install root dependencies (monorepo context)
run: npm install --prefix . # Install root node_modules for workspace commands
working-directory: ./
- name: Install products-app dependencies
run: npm install
working-directory: ./packages/products-app
- name: Build products-app
run: npm run build # Assuming 'build' script in products-app/package.json
working-directory: ./packages/products-app
env:
# Inject environment variables needed for the build (e.g., API URLs for the remote)
NODE_ENV: production
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Deploy to S3
run: |
aws s3 sync ./dist s3://${{ secrets.S3_PRODUCTS_BUCKET_NAME }}/products-app/v1 --delete
echo "Deployed products-app to s3://${{ secrets.S3_PRODUCTS_BUCKET_NAME }}/products-app/v1"
working-directory: ./packages/products-app # Assuming 'dist' folder for build output
env:
# S3 bucket name can also be a secret
S3_PRODUCTS_BUCKET_NAME: your-products-s3-bucket-name # Use a secret or env var
- name: Invalidate CloudFront Cache
run: |
aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_PRODUCTS_DISTRIBUTION_ID }} --paths "/products-app/v1/*" "/products-app/v1/remoteEntry.js"
echo "Invalidated CloudFront cache for products-app"
env:
# CloudFront Distribution ID can also be a secret
CLOUDFRONT_PRODUCTS_DISTRIBUTION_ID: your-products-cloudfront-id # Use a secret or env var
- name: Update Host Manifest (Optional - for Dynamic Remote Discovery)
# This step would trigger another workflow or update a central manifest file
# that the host application consumes to know the latest remoteEntry.js URL.
# Example: Using a dedicated webhook or API call to update a config service.
run: |
echo "::notice::Products App deployed. Consider triggering host update."
# curl -X POST -H "Authorization: Bearer ${{ secrets.HOST_API_TOKEN }}" \
# -d "{\"remoteName\": \"productsApp\", \"version\": \"v1\", \"url\": \"https://your-cdn.com/products-app/v1/remoteEntry.js\"}" \
# https://your-host-config-api.com/update-remote-config
working-directory: ./
products-app/package.json (relevant scripts):
{
"name": "products-app",
"version": "1.0.0",
"scripts": {
"start": "webpack serve --open",
"build": "webpack --mode production" // This builds to a 'dist' folder by default
},
// ...
}
Explanation of CI/CD Steps:
onclause: This pipeline runs when changes are pushed tomainonly if those changes affect files within thepackages/products-app/directory or the workflow definition itself. This is crucial for monorepos to avoid unnecessary builds.Checkout code: Fetches the repository content.Set up Node.js: Configures the Node.js environment.Install root dependencies: Important for monorepos. If your rootpackage.jsondefines workspaces,npm installat the root makes all workspace packages discoverable.Install products-app dependencies: Installsnode_modulesspecifically for theproducts-app.Build products-app: Runs thewebpack --mode productioncommand to create optimized production bundles. By default, Webpack outputs to adistfolder, which includesremoteEntry.jsand other federated chunks.Configure AWS Credentials: Uses GitHub secrets to securely authenticate with AWS.Deploy to S3:aws s3 sync ./dist s3://.../products-app/v1 --delete: This command syncs thedistfolder to a specific path (products-app/v1) within your S3 bucket. The--deleteflag removes files from S3 that are no longer indist, ensuring a clean deployment.- Notice the
v1in the path. This relates to our versioning strategy (explained next).
Invalidate CloudFront Cache:- After deploying new files to S3, your CDN (CloudFront) might still be serving old cached versions. This step tells CloudFront to clear its cache for the
products-app/v1/path, ensuring users get the latest version.
- After deploying new files to S3, your CDN (CloudFront) might still be serving old cached versions. This step tells CloudFront to clear its cache for the
Update Host Manifest (Optional): This is a crucial step for dynamic remote discovery. If your host application relies on a central manifest to find remote URLs, this step would update that manifest (e.g., by calling an API or committing a change to another repo).
7.2 Versioning Strategies for Remotes
Effective versioning is key to managing compatibility and allowing independent upgrades.
- Semantic Versioning (SemVer): Crucial for exposed modules.
- Patch (
1.0.x): Backwards compatible bug fixes. - Minor (
1.x.0): Backwards compatible new features. - Major (
x.0.0): Breaking changes.
- Patch (
remoteEntry.jsURL changes:- Major/Minor Versions: For major and minor versions (breaking/feature), the
remoteEntry.jsURL should explicitly change (e.g.,remoteApp1@https://cdn.example.com/products-app/v1/remoteEntry.jsvsv2/remoteEntry.js). This allows hosts to explicitly upgrade and manage compatibility. - Patch Versions: For patch versions, the
remoteEntry.jscan often remain at the same URL (e.g.,https://cdn.example.com/products-app/latest/remoteEntry.jsorv1/remoteEntry.js) if the internal bundles use content hashing for cache busting. The host will simply pick up the updatedremoteEntry.jsafter cache invalidation.
- Major/Minor Versions: For major and minor versions (breaking/feature), the
Example: Dynamic Remote Resolution via a Manifest (Client-Side Host)
The host application needs a way to know the correct, up-to-date URL for each remote.
1. Central mf-remote-urls.json (could be hosted on S3/CDN):
// public/mf-remote-urls.json
{
"productsApp": "https://your-cdn.com/products-app/v1/remoteEntry.js",
"cartApp": "https://your-cdn.com/cart-app/v2/remoteEntry.js",
"sharedStateApp": "https://your-cdn.com/shared-state-app/v1/remoteEntry.js"
}
2. host-app/webpack.config.js (for client-side host):
We’ll adjust the remotes to fetch this manifest at runtime. This is an advanced technique for full dynamic control.
// host-app/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
const webpack = require('webpack');
// This part dynamically fetches remote URLs.
// In a real scenario, this would involve fetching a manifest
// at runtime, or having a build-time step that injects the URLs.
// For a client-side host, we'll make the `remotes` property dynamic.
module.exports = {
entry: './src/index.tsx',
mode: 'development', // or 'production'
devServer: {
port: 3000,
historyApiFallback: true,
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react', '@babel/preset-typescript'],
},
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'hostApp',
// Dynamically load remotes from a manifest
// This string will be evaluated by Webpack at build time
// to create the dynamic import logic for remotes.
remotes: {
productsApp: `promise new Promise(resolve => {
fetch('/mf-remote-urls.json') // Fetch the manifest from host's public folder
.then(res => res.json())
.then(remotesConfig => {
// The format expected by Module Federation is 'name@url'
resolve('productsApp@' + remotesConfig.productsApp);
});
})`,
cartApp: `promise new Promise(resolve => {
fetch('/mf-remote-urls.json')
.then(res => res.json())
.then(remotesConfig => {
resolve('cartApp@' + remotesConfig.cartApp);
});
})`,
sharedStateApp: `promise new Promise(resolve => {
fetch('/mf-remote-urls.json')
.then(res => res.json())
.then(remotesConfig => {
resolve('sharedStateApp@' + remotesConfig.sharedStateApp);
});
})`
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
zustand: { singleton: true, requiredVersion: '^4.3.0' },
'react-router-dom': { singleton: true, requiredVersion: '^6.x.x' },
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
// Also copy the manifest to the host's build output
new webpack.NormalModuleReplacementPlugin(
/.*\/mf-remote-urls\.json/, // This is a placeholder for a more robust solution
(resource) => {
resource.request = path.resolve(__dirname, './public/mf-remote-urls.json');
}
),
],
output: {
publicPath: 'auto', // Or specify 'http://localhost:3000/'
},
};
- Important: This dynamic
remotesconfiguration means thehost-appwill attempt to fetchmf-remote-urls.jsonat runtime. You need to ensure this JSON file is available from the host’s origin (e.g., in itspublicdirectory, and copied during its build, or fetched from a dedicated config service).
3. host-app/public/mf-remote-urls.json:
You would place a copy of the mf-remote-urls.json in your host-app/public directory for local development testing, or ensure it’s served by your host-app’s web server in production.
// host-app/public/mf-remote-urls.json
{
"productsApp": "http://localhost:3001/remoteEntry.js",
"cartApp": "http://localhost:3002/remoteEntry.js",
"sharedStateApp": "http://localhost:3004/remoteEntry.js"
}
In a production deployment, this mf-remote-urls.json would be updated by your CI/CD pipelines (e.g., products-app’s pipeline updates this manifest file after deploying its new remoteEntry.js to a CDN). The host-app would then fetch the updated mf-remote-urls.json from its own CDN.
7.3 Release Orchestration and Rollbacks
- Decentralized Deployment, Centralized Release: While deployments are independent, a “release” (a coherent set of micro-frontend versions that work together) still needs orchestration.
- Automated Testing: Rigorous integration and E2E tests are essential before promoting a new release.
- Atomic Deployments: Each micro-frontend deployment should be atomic – either it fully succeeds or it fully rolls back, without leaving the system in a partial state.
- Fast Rollbacks: The ability to quickly revert to a previous working version of a micro-frontend is paramount.
Example: Rollback Strategy for a Remote (using S3 Versioning)
AWS S3 has built-in object versioning. If enabled on your bucket, every s3 sync (or put-object) operation creates a new version of the file. This allows for easy rollbacks.
Scenario: You deployed products-app v1.1.0, but it introduced a critical bug. You need to roll back to v1.0.0.
1. Enable S3 Versioning: Go to your S3 bucket in the AWS console -> Properties tab -> Bucket Versioning -> Enable.
2. Modify your CI/CD (Simplified Manual Rollback):
When products-app builds, it would typically use its version number in the S3 path.
Let’s assume products-app v1.0.0 was deployed to s3://your-products-bucket/products-app/1.0.0/remoteEntry.js.
And products-app v1.1.0 was deployed to s3://your-products-bucket/products-app/1.1.0/remoteEntry.js.
To roll back the active version, you need to update the mf-remote-urls.json to point back to the v1.0.0 path, and then invalidate CloudFront.
mf-remote-urls.json BEFORE rollback (points to 1.1.0):
{
"productsApp": "https://your-cdn.com/products-app/1.1.0/remoteEntry.js",
"cartApp": "https://your-cdn.com/cart-app/v2/remoteEntry.js"
}
mf-remote-urls.json AFTER rollback (points to 1.0.0):
{
"productsApp": "https://your-cdn.com/products-app/1.0.0/remoteEntry.js",
"cartApp": "https://your-cdn.com/cart-app/v2/remoteEntry.js"
}
CI/CD Step for Rollback (Conceptual, could be a manual trigger or dedicated workflow):
# .github/workflows/rollback-products.yml
name: Rollback Products Micro-Frontend
on:
workflow_dispatch: # Allows manual triggering from GitHub UI
inputs:
version_to_rollback_to:
description: 'Target version for rollback (e.g., 1.0.0)'
required: true
type: string
jobs:
rollback:
runs-on: ubuntu-latest
steps:
- name: Checkout code (for manifest if stored in repo)
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Update Central Remote Manifest
run: |
# Fetch current manifest, update productsApp URL, then upload.
# This is simplified; in production, use a more robust script
# or a dedicated API to modify the manifest.
TEMP_MANIFEST_FILE="temp_mf_remote_urls.json"
aws s3 cp s3://${{ secrets.S3_HOST_CONFIG_BUCKET }}/mf-remote-urls.json $TEMP_MANIFEST_FILE
jq --arg new_url "https://your-cdn.com/products-app/${{ github.event.inputs.version_to_rollback_to }}/remoteEntry.js" \
'.productsApp = $new_url' $TEMP_MANIFEST_FILE > $TEMP_MANIFEST_FILE.updated
aws s3 cp $TEMP_MANIFEST_FILE.updated s3://${{ secrets.S3_HOST_CONFIG_BUCKET }}/mf-remote-urls.json --acl public-read
echo "Manifest updated for productsApp to version ${{ github.event.inputs.version_to_rollback_to }}"
env:
S3_HOST_CONFIG_BUCKET: your-host-config-bucket # Bucket where mf-remote-urls.json is stored
- name: Invalidate CloudFront Cache for Host Config
# Invalidate the cache for the manifest file itself so the host fetches the new one
run: |
aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_HOST_DISTRIBUTION_ID }} --paths "/mf-remote-urls.json"
echo "Invalidated CloudFront cache for host config"
env:
CLOUDFRONT_HOST_DISTRIBUTION_ID: your-host-cloudfront-id
- Note: The
jqtool is used here for JSON manipulation in the shell script. You would needjqinstalled on the runner (sudo apt-get install jq).
7.4 Monorepo vs. Polyrepo for CI/CD
- Polyrepo: Each micro-frontend (and host) lives in its own Git repository with its own CI/CD pipeline.
- Pros: Max team autonomy, clearly defined boundaries, easy to reason about “what belongs where”.
- Cons: Higher overhead for setting up many separate pipelines, complex cross-repo dependency management, harder to ensure consistent tooling.
- Monorepo: All micro-frontends and the host live in one Git repository. Tools like Nx (
https://nx.dev) can orchestrate builds and tests efficiently.- Pros: Simplified dependency management (one
package.jsonfor root, workspaces for others), easier code sharing, unified tooling, easier to implement atomic cross-MF changes. - Cons: Potential for large build times if not optimized (e.g., without Nx’s affected commands), requires careful change detection to avoid rebuilding everything on every commit.
- Pros: Simplified dependency management (one
Example: Monorepo package.json with Workspaces (as used in this guide):
// module-federation-tutorial/package.json (root)
{
"name": "module-federation-monorepo",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"start:host": "npm start -w host-app",
"start:products": "npm start -w products-app",
"start:cart": "npm start -w cart-app",
"start:shared-state": "npm start -w shared-state-app",
"build:all": "npm run build --workspaces"
},
"devDependencies": {
"webpack": "^5.75.0",
"webpack-cli": "^5.0.0"
// ... root dev dependencies
}
}
- The
npm run build --workspacescommand would sequentially run thebuildscript in each package’spackage.jsonfile. Tools like Nx optimize this by only running builds/tests for “affected” projects.
Best Practices for Deployment and CI/CD:
- Automate Everything: From code commit to production deployment.
- Immutable Builds: Build once, deploy many times. Each build should produce artifacts that are never modified.
- Environment-Specific Configuration: Use environment variables or configuration services to manage differences between environments.
- Monitoring and Alerts: Set up comprehensive monitoring for your deployed micro-frontends and configure alerts for errors or performance degradation.
- Rollback Strategy: Have a clear and fast rollback strategy for each micro-frontend.
- Observability: Implement distributed tracing and logging to understand how requests flow across micro-frontends.
8. Security Considerations
Securing a micro-frontend architecture is paramount. The distributed nature introduces new attack vectors and complexities compared to monolithic applications. This section will provide practical code examples for addressing key security concerns.
Detailed Explanation:
8.1 Cross-Origin Issues (CORS)
When micro-frontends are hosted on different domains or ports, the browser’s Same-Origin Policy (SOP) restricts communication between them. You need to explicitly tell the browser that communication is allowed via CORS (Cross-Origin Resource Sharing) headers.
- Problem: Browser will block requests (e.g.,
host-apptrying to loadremoteEntry.jsfromproducts-app’s origin) with a CORS error if not properly configured. - Solution: Configure CORS headers on the server (or CDN/proxy) serving the remote application’s assets.
Example: CORS Configuration in an Express.js Server (for a remote application)
Imagine your products-app (on http://localhost:3001) is served by a small Express server in production.
products-app/server.js (Conceptual production server for remote):
// products-app/server.js (Production server for products-app static assets)
const express = require('express');
const path = require('path');
const cors = require('cors'); // npm install cors
const app = express();
const PORT = process.env.PORT || 3001;
const HOST_ORIGIN = process.env.HOST_ORIGIN || 'http://localhost:3000'; // The host app's origin
// Configure CORS
app.use(cors({
origin: HOST_ORIGIN, // Only allow requests from the host's origin
methods: ['GET', 'HEAD', 'OPTIONS'], // Allowed HTTP methods for static assets
credentials: true, // If you need to send cookies/auth headers with requests
}));
// Serve static files from the 'dist' directory (Webpack build output)
app.use(express.static(path.join(__dirname, 'dist')));
// Serve index.html for all other routes (for SPA routing)
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
app.listen(PORT, () => {
console.log(`Products App production server running on port ${PORT}`);
console.log(`Configured to allow CORS from: ${HOST_ORIGIN}`);
});
- Environment Variables: Use
process.env.HOST_ORIGINto configure the allowed origin in different environments. cors({ origin: ... }): This is the critical part. It tells the browser that yourproducts-appallows requests specifically fromhttp://localhost:3000. In production, this would be your actual host domain (e.g.,https://your-host-domain.com).- Wildcard (
*) caution: Avoidorigin: '*'in production unless your content is genuinely public and requires no authentication, as it opens up your remote to any website.
8.2 Authentication and Authorization
Consistent authentication and authorization are vital.
- Problem: How to manage user authentication and authorization consistently across multiple micro-frontends?
- Solution: Centralize authentication in the host or a dedicated
auth-app. Share authentication tokens securely.
Example: Shared Authentication Token (JWT in HTTP-only Cookie)
Central Login (
auth-appor Host): The user logs in, and the server sets anHttpOnlycookie containing a JWT.HttpOnlyprevents client-side JavaScript from accessing the cookie, mitigating XSS attacks.Server-side after successful login (pseudo-code):
// auth-backend/login.js (Node.js/Express) const jwt = require('jsonwebtoken'); // npm install jsonwebtoken app.post('/api/login', (req, res) => { // ... authenticate user ... if (userAuthenticated) { const token = jwt.sign({ userId: user.id, roles: user.roles }, process.env.JWT_SECRET, { expiresIn: '1h' }); res.cookie('auth_token', token, { httpOnly: true, // Cannot be accessed by client-side JavaScript secure: process.env.NODE_ENV === 'production', // Send only over HTTPS in production sameSite: 'Lax', // or 'Strict' for stronger protection, depends on use case maxAge: 3600000 // 1 hour }); res.status(200).json({ message: 'Login successful' }); } else { res.status(401).json({ message: 'Invalid credentials' }); } });Accessing Protected APIs (Micro-Frontend Client): When any micro-frontend (host or remote) makes an authenticated request to its backend API, the browser automatically includes the
HttpOnlycookie.Client-side fetch request (e.g., in
products-apporhost-app):// products-app/src/services/productService.ts async function fetchProtectedProducts() { // Browser automatically sends 'auth_token' cookie if present due to 'credentials: include' (if used with fetch) // or if same-site policy allows. const response = await fetch('/api/products/protected', { method: 'GET', credentials: 'include' // Important for sending cookies with cross-origin requests }); if (!response.ok) { if (response.status === 401) { // Redirect to login or show re-authentication prompt window.location.href = '/login'; } throw new Error('Failed to fetch protected products'); } return response.json(); }Backend Authorization: The backend for each micro-frontend verifies the JWT in the cookie.
Micro-frontend Backend (e.g., Node.js/Express):
// products-backend/routes/protectedProducts.js const jwt = require('jsonwebtoken'); const authenticateToken = (req, res, next) => { const token = req.cookies.auth_token; // Access from HttpOnly cookie if (!token) return res.sendStatus(401); // Unauthorized jwt.verify(token, process.env.JWT_SECRET, (err, user) => { if (err) return res.sendStatus(403); // Forbidden (e.g., expired token) req.user = user; // Attach user payload to request next(); }); }; app.get('/api/products/protected', authenticateToken, (req, res) => { // If we reach here, user is authenticated and authorized res.json({ message: `Welcome, ${req.user.userId}! Here are your protected products.` }); });
8.3 Content Security Policy (CSP)
CSP is a crucial security layer that helps mitigate Cross-Site Scripting (XSS) and other code injection attacks by specifying which resources the browser should be allowed to load.
- Problem: With dynamic module loading, a poorly configured CSP could block your micro-frontends or, conversely, be too permissive.
- Solution: Implement a strict CSP.
Example: CSP Header for the Host Application (in Express.js)
This CSP would be set by the server serving your host-app’s index.html.
host-app/server.js (Conceptual production server for host):
// host-app/server.js (Production server for host-app)
const express = require('express');
const path = require('path');
const helmet = require('helmet'); // npm install helmet
const app = express();
const PORT = process.env.PORT || 3000;
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"], // Default policy for loading content
scriptSrc: [
"'self'",
"'unsafe-eval'", // Webpack's Module Federation requires 'unsafe-eval' for its runtime in some cases.
// Try to avoid this in production if possible, or use nonces/hashes.
"http://localhost:3000", // Host's own origin
"http://localhost:3001", // products-app origin
"http://localhost:3002", // cart-app origin
"http://localhost:3004", // shared-state-app origin
"https://your-cdn.com", // Your production CDN for all MFs
],
styleSrc: [
"'self'",
"'unsafe-inline'", // Often needed for inline styles, try to avoid
"http://localhost:3000",
"https://your-cdn.com",
],
imgSrc: ["'self'", "data:", "https://*", "http://*"], // Allow images from own origin, data URIs, and any https/http
connectSrc: [ // What URLs can be fetched via XHR/WebSockets
"'self'",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:3002",
"http://localhost:3004",
"https://your-cdn.com",
"ws://localhost:3000", // For WebSocket connections in dev (e.g., webpack-dev-server)
],
objectSrc: ["'none'"], // Disallow <object>, <embed>, <applet>
baseUri: ["'self'"], // Restrict the base URL for relative URLs
frameAncestors: ["'none'"], // Prevent site from being embedded in iframes
},
}));
// Serve static files from the 'dist' directory
app.use(express.static(path.join(__dirname, 'dist')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
app.listen(PORT, () => {
console.log(`Host App production server running on port ${PORT}`);
console.log('CSP header configured.');
});
scriptSrc: Must list all origins from which your host and all federated modules (remoteEntry.js, chunks) are loaded.'unsafe-eval': Webpack’s Module Federation runtime often useseval()-like functions for dynamic module loading. In development, this is common. In production, research options likewebpack-subresource-integrityor explore how to generate nonces/hashes for scripts to avoid'unsafe-eval'. Module Federation 2.0 andmf-manifest.jsonmight offer better solutions here.styleSrc 'unsafe-inline': Often required for inline styles, but a security risk. Prefer CSS Modules or extracting all CSS to files.connectSrc: List all origins for API calls, WebSockets, etc.frameAncestors 'none': Prevents clickjacking by not allowing your site to be embedded in iframes.
8.4 Trusting Remote Modules
- Problem: By loading remote JavaScript at runtime, you are inherently trusting that code. A compromised remote could inject malicious code into your host application.
- Solution: Strong processes, secure pipelines, and monitoring.
Example: Secure Development Practices (Process-Oriented, not code)
- Code Reviews: All changes to any micro-frontend must undergo thorough code reviews.
- Vulnerability Scanning:
- Dependency Scanners: Integrate tools like
npm audit, Snyk, or Dependabot into your CI pipelines. - Static Application Security Testing (SAST): Tools that analyze source code for vulnerabilities (e.g., SonarQube).
- Dependency Scanners: Integrate tools like
- Supply Chain Security: Be aware of the transitive dependencies of your micro-frontends. Use private NPM registries with auditing.
- Container Security: If using Docker, scan your Docker images for vulnerabilities.
- Minimal Privileges: Run micro-frontend services with the absolute minimum necessary permissions.
- Network Segmentation: Use network firewalls to restrict communication between micro-frontend backends.
8.5 Data Security and Isolation
- Problem: Preventing one micro-frontend from accessing or modifying data belonging to another, or sensitive data being exposed.
- Solution: Architectural patterns, strict API contracts, and careful client-side storage management.
Example: Client-Side Storage Isolation
If micro-frontends need to store non-sensitive data in localStorage or sessionStorage, they should do so with clear namespace prefixes to avoid collisions or accidental data overwrites.
products-app/src/utils/localStorage.ts:
// products-app/src/utils/localStorage.ts
const PRODUCTS_APP_PREFIX = 'pa_'; // Unique prefix for products app
export const setProductsAppItem = (key: string, value: any) => {
try {
localStorage.setItem(PRODUCTS_APP_PREFIX + key, JSON.stringify(value));
} catch (error) {
console.error('Error saving to products app local storage:', error);
}
};
export const getProductsAppItem = (key: string) => {
try {
const item = localStorage.getItem(PRODUCTS_APP_PREFIX + key);
return item ? JSON.parse(item) : null;
} catch (error) {
console.error('Error reading from products app local storage:', error);
return null;
}
};
// Example usage within products-app
// setProductsAppItem('userSettings', { view: 'grid' });
// const userSettings = getProductsAppItem('userSettings');
Example: Backend Data Isolation (Architectural)
Separate Databases: Each micro-frontend’s backend should preferably use its own database.
Dedicated APIs: Each micro-frontend should have its own backend APIs, even if they’re behind a shared API Gateway. Access to another micro-frontend’s data should always go through its designated API.
Browser Request -> Host MF -> Products MF Backend API (GET /products) -> Cart MF Backend API (POST /cart)NEVER:
Browser Request -> Host MF -> Products MF Backend API (tries to access Cart DB directly) <-- AVOID
Best Practices for Security:
- Threat Modeling: Systematically identify potential threats and vulnerabilities.
- Defense in Depth: Employ multiple layers of security controls (CORS, CSP, Auth, secure coding, network security).
- Principle of Least Privilege: Grant minimal necessary access to users, systems, and individual micro-frontends.
- Regular Audits: Conduct security audits, penetration testing, and code reviews.
- Stay Updated: Keep all dependencies up-to-date.
- Automated Security Scans: Integrate security scanning into CI/CD pipelines (SAST, DAST, dependency scanning).
- Team Education: Ensure all developers are trained in secure coding practices.
- Monitoring and Alerting: Implement robust security monitoring and alerting for suspicious activities or breaches.
Conclusion
Mastering advanced micro-frontend patterns with Module Federation empowers you to build highly scalable, resilient, and independently deployable web applications. While these topics introduce complexity, the benefits in terms of team autonomy, development velocity, and system maintainability are immense.
By thoughtfully applying strategies for state management, routing, SSR, leveraging diverse build tools, and prioritizing testing, deployment, and security, you can harness the full power of micro-frontends to deliver exceptional user experiences in complex, distributed environments. Continue exploring, experimenting, and contributing to the evolving micro-frontend ecosystem!