Comprehensive Guide to Microfrontends
Introduction to Microfrontends
Microfrontends extend the principles of microservices to the frontend by splitting a monolithic UI into smaller, more manageable, and independently deployable components. Each microfrontend (MFE) is an isolated application responsible for a specific piece of functionality. For example, in an e-commerce platform, the homepage, product detail page (PDP), and shopping cart could be implemented as separate microfrontends.
Why Use Microfrontends?
Scalability: Teams can work independently on different parts of the application without stepping on each other’s toes.
Faster Deployment: Independent deploy cycles for each MFE enable rapid iterations.
Technology Flexibility: Each MFE can use a different tech stack if necessary.
Improved Maintainability: Isolated codebases reduce complexity and technical debt.
Setup and Implementation
Step 1: Setting Up Applications
Each microfrontend is its own application. Tools like create-mf-app
can simplify this process. Below is an example setup:
Create Applications:
Home Application: Acts as the host.
PDP Application: Loads product details.
Cart Application: Manages shopping cart functionality.
Choose Frameworks and Tools:
- React with Webpack 5 and Tailwind CSS for styling.
Step 2: Sharing Components
Shared components like headers or footers can be exposed using Webpack’s Module Federation plugin:
// webpack.config.js in Home App
new ModuleFederationPlugin({
name: "home",
filename: "remoteEntry.js",
exposes: {
"./Header": "./src/components/Header",
"./Footer": "./src/components/Footer",
},
});
In the PDP App, these components are consumed as remotes:
new ModuleFederationPlugin({
remotes: {
home: "home@http://localhost:3000/remoteEntry.js",
},
});
What Can Be Shared Between Applications
Webpack's Module Federation allows sharing more than just UI components. Below is a list of items that can be shared between applications:
Components: Reusable UI components such as headers, footers, buttons, etc.
JSON Data: Encoded JSON files or dynamically imported data structures.
Utility Functions: Helper functions for common operations like formatting or validation.
Constants: Shared constant values like API URLs or configuration keys.
Styles: Shared stylesheets or design tokens, provided they are compatible with both applications.
Modules: Full modules or libraries, including authentication handlers or state management tools.
APIs: Functions for making API requests or sharing services like authentication.
Sharing JWT Tokens Between Microfrontends
Sharing JWT tokens securely is critical in a microfrontend architecture to enable seamless authentication across the system.
Approach 1: Local Storage or Cookies
Store the JWT in localStorage
or cookies
at the host level. Each microfrontend accesses the token when needed:
// Set token in localStorage
localStorage.setItem('authToken', token);
// Access token in microfrontend
const token = localStorage.getItem('authToken');
Security Tip: Avoid storing sensitive data in
localStorage
. UseHttpOnly
cookies for added security.
Approach 2: Context API for Token Sharing
Use React’s Context API to provide the token to all microfrontends:
const AuthContext = React.createContext();
export const AuthProvider = ({ children }) => {
const [authToken, setAuthToken] = React.useState(null);
return (
<AuthContext.Provider value={{ authToken, setAuthToken }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => React.useContext(AuthContext);
Each microfrontend can consume the token from the context:
const { authToken } = useAuth();
Sharing the Cart Between Microfrontends
A shared shopping cart is a common requirement in e-commerce microfrontends. Below are ways to implement it:
Approach 1: Shared State Using RxJS
Use an observable to manage and share cart state:
import { BehaviorSubject } from 'rxjs';
const cart$ = new BehaviorSubject([]);
export const addToCart = (item) => {
cart$.next([...cart$.getValue(), item]);
};
export const getCart = () => cart$.getValue();
export const cartObservable = cart$;
Microfrontends can subscribe to updates:
cartObservable.subscribe((cart) => {
console.log('Updated cart:', cart);
});
Approach 2: API-Driven Cart
Use a centralized API to manage the cart:
// Add to cart
fetch('/api/cart', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(item),
});
// Get cart
fetch('/api/cart', {
headers: {
Authorization: `Bearer ${authToken}`,
},
}).then((response) => response.json());
Approach 3: Context API for Cart State
Leverage Context API for sharing cart state:
const CartContext = React.createContext();
export const CartProvider = ({ children }) => {
const [cart, setCart] = React.useState([]);
return (
<CartContext.Provider value={{ cart, setCart }}>
{children}
</CartContext.Provider>
);
};
export const useCart = () => React.useContext(CartContext);
Routing in Microfrontends
Routing in microfrontends can be handled using React Router or similar libraries. Here’s how it works:
Parent Routing: The host application manages high-level routes, delegating control to microfrontends:
<BrowserRouter> <Routes> <Route path="/home" element={<Home />} /> <Route path="/pdp/*" element={<PDP />} /> <Route path="/cart" element={<Cart />} /> </Routes> </BrowserRouter>
Child Routing: Each microfrontend handles its own internal routing using React Router:
<Routes> <Route path="/details/:id" element={<ProductDetails />} /> </Routes>
History Management: Ensure history objects are shared properly between microfrontends to enable seamless navigation.
State Management in Microfrontends
State management can be challenging in microfrontend architectures due to the distributed nature of the system. Below are common approaches:
Shared State Using RxJS
RxJS can be used to maintain and share state across microfrontends without coupling them tightly:
import { BehaviorSubject } from 'rxjs';
const sharedState$ = new BehaviorSubject({});
export const setSharedState = (newState) => {
sharedState$.next({ ...sharedState$.getValue(), ...newState });
};
export const getSharedState = () => sharedState$.getValue();
export default sharedState$;
Each microfrontend can subscribe to sharedState$
and update its own state accordingly.
Context API
For React-based applications, the Context API can be used to share state within a microfrontend or between closely related ones:
const GlobalContext = React.createContext();
export const GlobalProvider = ({ children }) => {
const [state, setState] = React.useState({});
return (
<GlobalContext.Provider value={{ state, setState }}>
{children}
</GlobalContext.Provider>
);
};
export const useGlobalContext = () => React.useContext(GlobalContext);
API-Driven State
In some cases, state can be derived from a centralized API. This approach reduces duplication but may introduce latency issues.
Host and Remote Communication
In a microfrontend architecture, the host application orchestrates remotes. Below are key concepts:
Host Application
The host manages high-level routing and provides shared resources (e.g., state or libraries).
Example Webpack Configuration:
new ModuleFederationPlugin({
name: 'host',
remotes: {
remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
},
shared: {
react: { singleton: true },
},
});
Remote Applications
Remotes define and expose functionality to be consumed by the host.
Example Configuration:
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
'./Widget': './src/Widget',
},
shared: {
react: { singleton: true },
},
});
Runtime Communication
Host and remote applications communicate dynamically, enabling real-time updates without redeployment.
Singletons in Module Federation
Singletons are a critical feature of Webpack’s Module Federation to ensure consistent versions of shared libraries. For instance, in React-based applications, React must be a singleton to avoid conflicts.
Webpack Configuration for Singletons
The shared
key in the Module Federation configuration allows for setting up singleton mode:
new ModuleFederationPlugin({
shared: {
react: {
singleton: true,
requiredVersion: '^17.0.0',
},
'react-dom': {
singleton: true,
requiredVersion: '^17.0.0',
},
},
});
Benefits of Singleton Mode
Ensures that only one version of a library is loaded.
Avoids issues with libraries like React that rely on a single global instance.
Deployment Considerations
Static Asset Stores: Deploy microfrontends as static assets to a CDN like AWS S3.
Cache Management: Use unique filenames for builds to ensure updates are served correctly.
Versioning: Implement a fallback mechanism in case of API contract changes.
Comparison with Alternatives
Feature | Module Federation | NPM Packages | Asset Store |
Runtime Sharing | Yes | No | Yes |
Version Management | Automatic | Manual | Manual |
Build Complexity | Moderate | High | Low |
Illustrative Diagrams
Microfrontend Architecture
State Sharing
Cross-Platform Micro-Frontends
Cross-platform micro-frontends aim to provide consistent functionality and user experience across web, mobile, and desktop platforms. This strategy leverages shared components, unified state management, and modular architectures to deliver platform-specific variations with minimal duplication.
Key Benefits
Code Reusability: Shared business logic and components reduce redundancy.
Consistent User Experience: Unified design systems ensure visual consistency.
Efficient Maintenance: Single codebase for shared logic minimizes maintenance overhead.
Platform Optimization: Customization for platform-specific capabilities (e.g., touch interactions for mobile).
Technological Stack
Web:
Framework: React, Vue.js, or Angular.
Bundler: Webpack or Vite with Module Federation.
Mobile:
- Framework: React Native, Flutter, or Swift/Kotlin (with shared libraries).
Desktop:
- Framework: Electron or Tauri for cross-platform desktop apps.
Architecture Overview
Shared Layer:
- Shared components, business logic, and state management are housed in this layer.
Platform-Specific Layers:
Web: Uses web-specific styling and responsive design.
Mobile: Includes platform-specific components like
ScrollView
in React Native.Desktop: Optimized for window management and offline capabilities.
Implementation Strategy
Shared Component Library: Create a shared component library using tools like Storybook. These components should be platform-agnostic:
// Button.js export const Button = ({ onPress, children }) => { return ( <button onClick={onPress} style={styles.button}> {children} </button> ); };
State Management: Use libraries like Redux or Zustand with adapters for platform-specific requirements:
const sharedState = createStore({ cart: [], user: null, });
Platform Adapters: Build adapters for platform-specific needs:
// Adapter for mobile gestures export const usePlatformGestures = () => { return Platform.OS === 'web' ? useMouseGestures() : useTouchGestures(); };
Webpack Module Federation: Enable sharing of business logic and UI components across platforms:
new ModuleFederationPlugin({ name: "host", remotes: { mobile: "mobile@http://localhost:3001/remoteEntry.js", web: "web@http://localhost:3002/remoteEntry.js", }, shared: ["react", "redux"], });
Challenges and Solutions
Platform-Specific Styles:
- Solution: Use libraries like Tailwind or Styled Components with platform-specific themes.
Performance Optimization:
- Solution: Optimize shared components for the lowest common denominator, and enhance with platform-specific extensions.
State Management Conflicts:
- Solution: Namespace state slices for platform-specific logic.
Micro-Frontend Routing
Routing in micro-frontends is essential for enabling seamless navigation between independently deployed modules while maintaining a unified user experience. This section outlines the strategies and implementation techniques for routing in micro-frontend architectures.
Approaches to Routing
Host-Managed Routing: The host application manages high-level routes and delegates rendering responsibilities to micro-frontends.
<BrowserRouter> <Routes> <Route path="/home" element={<Home />} /> <Route path="/pdp/*" element={<PDP />} /> <Route path="/cart" element={<Cart />} /> </Routes> </BrowserRouter>
Micro-Frontend Local Routing: Each micro-frontend manages its own internal routes for pages specific to its domain.
<Routes> <Route path="/details/:id" element={<ProductDetails />} /> <Route path="/reviews/:id" element={<ProductReviews />} /> </Routes>
Dynamic Route Injection: Routes are dynamically added to the host application as micro-frontends are loaded.
const loadMicroFrontend = (route, element) => { router.addRoute({ path: route, element }); }; loadMicroFrontend('/pdp', PDPComponent);
Route Synchronization: Synchronize the history object across micro-frontends to maintain consistent navigation state.
import { createBrowserHistory } from 'history'; const globalHistory = createBrowserHistory(); // Share the global history instance export default globalHistory;
Handling Navigation Conflicts
Namespace Routes: Prefix routes for each micro-frontend to avoid collisions (e.g.,
/cart
,/pdp
).Fallbacks: Implement fallback routes to handle unmatched paths.
<Route path="*" element={<NotFound />} />
Cross-Micro-Frontend Navigation
Enable communication between micro-frontends for navigation events.
// Host app
const navigateTo = (path) => {
globalHistory.push(path);
};
// Micro-frontend consuming the navigation API
navigateTo('/cart');
Best Practices
Route Isolation: Keep routes isolated within micro-frontends to ensure modularity.
Shared Utilities: Use shared navigation utilities to manage cross-frontend navigation.
Error Handling: Add error boundaries to manage navigation failures gracefully.
Conclusion
Microfrontends enable scalability, flexibility, and faster development cycles for large-scale applications. By following best practices and leveraging tools like Module Federation, you can build robust, maintainable systems that cater to modern development needs.
For more details, refer to the provided examples and expand as necessary!