Micro-frontends, like microservices in backend development, divide frontend applications into modular, self-contained components that can be independently developed, tested, and deployed by different teams in large projects.
This article showcases advanced coding strategies for implementing vertical and horizontal micro-frontends through highlighting their specific use cases and providing original code examples to help you choose the most effective approach for optimizing your project’s scalability and maintainability.
Micro-frontends apply microservice principles to web design by dividing a large, monolithic frontend into smaller, independent components, similar to building blocks.
Read how micro-frontends apply microservice concepts to enhance scalability, flexibility, and team autonomy in web development by breaking down large frontends into independently manageable units.
State management across different micro-frontend components can become very challenging. Examples of such components are used in the code snippet below:
UserMicroFrontend
and CartMicroFrontend
.
Here, the ParentApp
components are in charge of the shared state management (userData
and cartData
) and pass it down as props to each micro-frontend:
// ParentApp.js import React, { useState, useEffect } from 'react'; import CartMicroFrontend from './CartMicroFrontend'; import UserMicroFrontend from './UserMicroFrontend'; const ParentApp = () => { const [userData, setUserData] = useState(null); const [cartData, setCartData] = useState([]); // Simulating fetching user data useEffect(() => { fetchUserData().then(data => setUserData(data)); }, []); // Handler to update the cart const updateCart = (newItem) => { setCartData([...cartData, newItem]); }; return ( <div> <h1>Parent Application</h1> <UserMicroFrontend userData={userData} /> <CartMicroFrontend cartData={cartData} onAddToCart={updateCart} /> </div> ); }; const fetchUserData = async () => { // Simulate API call to fetch user data return { name: 'John Doe', id: 123 }; }; export default ParentApp; // UserMicroFrontend.js import React from 'react'; const UserMicroFrontend = ({ userData }) => { return ( <div> <h2>User Information</h2> {userData ? ( <p>{`Welcome, ${userData.name}`}</p> ) : ( <p>Loading user data...</p> )} </div> ); }; export default UserMicroFrontend; // CartMicroFrontend.js import React from 'react'; const CartMicroFrontend = ({ cartData, onAddToCart }) => { const handleAddToCart = () => { const newItem = { id: Math.random(), name: 'New Product' }; onAddToCart(newItem); }; return ( <div> <h2>Shopping Cart</h2> <ul> {cartData.map((item) => ( <li key={item.id}>{item.name}</li> ))} </ul> <button onClick={handleAddToCart}>Add Item</button> </div> ); }; export default CartMicroFrontend;
As the application scales, managing shared state between different micro-frontends can become increasingly complex, especially when these micro-frontends are independently deployed and need to communicate with each other.
To address this challenge, consider implementing a global state management solution, such as Redux or the Context API, or using event-driven architectures to facilitate **cross-micro-frontend communication, depending on the complexity and specific needs of your application.
Complexity — While micro-frontends offer significant benefits, they introduce complexity in managing shared state across multiple independent modules.
Each micro-frontend might have its own state, requiring careful planning and architecture to ensure consistent state management and avoid conflicts, particularly when modules need to share data or interact with each other.
Integration — Since each micro-frontend is developed independently, integrating them into a cohesive user experience poses a challenge. This involves establishing common protocols for state sharing and inter-module communication, ensuring that all modules can work together seamlessly without compromising the overall functionality.
Coordination in large teams — In large organizations where different teams are responsible for different parts of an application, keeping everyone aligned on state management becomes difficult.
Ensuring a consistent user experience across the application requires tight coordination, clear guidelines, and effective project management to maintain consistency in how state is managed and shared among micro-frontends.
A key feature of vertical micro-frontends is client-side composition. Unlike server-side rendering, where the server assembles the page, client-side composition allows the application shell to load and manage micro-frontends dynamically.
This approach is particularly useful for single-page applications (SPAs), where a seamless and responsive user experience is paramount.
The routing in a vertical split is typically handled in two layers:
Local routing — Each micro-frontend manages its own internal navigation. For instance, within the user profile micro-frontend, routes might exist for editing user information, viewing order history, etc.. This separation allows teams to have full control over the navigation and logic within their domain, ensuring that changes in one area do not inadvertently affect others
Here’s a simple example of how the application shell might be implemented in a React-based e-commerce application:
// AppShell.jsx import React from 'react'; import { BrowserRouter as Router, Route, Switch, Redirect } from 'react-router-dom'; import ProductListing from './ProductListing'; import ShoppingCart from './ShoppingCart'; import UserProfile from './UserProfile'; function AppShell() { return ( <Router> <Switch> <Route path="/products" component={ProductListing} /> <Route path="/cart" component={ShoppingCart} /> <Route path="/profile" component={UserProfile} /> <Redirect from="/" to="/products" /> </Switch> </Router> ); } export default AppShell;
In this instance, the AppShell.jsx
component manages routing to various micro-frontends like ProductListing
, ShoppingCart
, UserProfile
, etc.. Using React Router, it allows switching between them depending on the URL path.
The Switch
component ensures that only one route is rendered at once while the Redirect
component creates a default route leading to the product listing page.
The ProductListing
component is a micro-frontend responsible for displaying a list of products. It fetches product data from an API and renders it for the user:
// ProductListing.jsx import React, { useState, useEffect } from 'react'; function ProductListing() { const [products, setProducts] = useState([]); useEffect(() => { fetch('/api/products') .then((response) => response.json()) .then((data) => setProducts(data)); }, []); return ( <div> <h1>Product Listing</h1> <ul> {products.map((product) => ( <li key={product.id}>{product.name}</li> ))} </ul> </div> ); } export default ProductListing;
Here, ProductListing.jsx
manages the state of the products using the useState
hook. The useEffect
hook fetches product data from the server when the component mounts, updating the product list.
This micro-frontend focuses solely on product-related functionality, making it easy to maintain and scale independently.
The ShoppingCart
component handles the shopping cart functionality, allowing users to view and manage their cart items:
// ShoppingCart.jsx import React, { useState } from 'react'; function ShoppingCart() { const [cart, setCart] = useState([]); const handleAddToCart = (product) => { setCart([...cart, product]); }; return ( <div> <h1>Shopping Cart</h1> <ul> {cart.map((item, index) => ( <li key={index}>{item.name}</li> ))} </ul> <button onClick={() => handleAddToCart({ name: 'Example Product' })}> Add Example Product </button> </div> ); } export default ShoppingCart;
In ShoppingCart.jsx
, the useState
hook manages the cart’s state, storing the list of items added by the user. The handleAddToCart
function adds new items to the cart. This component provides a focused and isolated experience for managing shopping cart operations, further emphasizing the benefits of a vertical micro-frontend approach.
The UserProfile
component displays and manages user profile information:
// UserProfile.jsx import React, { useState, useEffect } from 'react'; function UserProfile() { const [user, setUser] = useState(null); useEffect(() => { fetch('/api/user') .then((response) => response.json()) .then((data) => setUser(data)); }, []); if (!user) return <div>Loading...</div>; return ( <div> <h1>User Profile</h1> <p>Name: {user.name}</p> <p>Email: {user.email}</p> </div> ); } export default UserProfile;
The UserProfile.jsx
component uses the useState
hook to manage the user data state. The useEffect
hook fetches user information from the server, updating the component’s state when the data is received. This micro-frontend handles user-specific tasks, such as displaying and editing profile information, independently from other parts of the application.
Client-side composition in a horizontal split architecture typically involves loading multiple micro-frontends into a single view. An application shell manages these components, allowing each team to deliver their functionality independently.
This approach is suitable for applications with high traffic, as caching strategies can be efficiently implemented using a CDN.
Here is a React Code Example: Application Shell
:
import React from 'react'; import Header from './Header'; import Footer from './Footer'; import Catalog from './Catalog'; import VideoPlayer from './VideoPlayer'; function AppShell() { return ( <div className="app-shell"> <Header /> <div className="main-content"> {/* Load micro-frontends */} <Catalog /> <VideoPlayer /> </div> <Footer /> </div> ); } export default AppShell; import React from 'react'; import Header from './Header'; import Footer from './Footer'; import Catalog from './Catalog'; import VideoPlayer from './VideoPlayer'; function AppShell() { return ( <div className="app-shell"> <Header /> <div className="main-content"> {/* Load micro-frontends */} <Catalog /> <VideoPlayer /> </div> <Footer /> </div> ); } export default AppShell;
In the example above, the AppShell
component serves as the application shell, loading the Catalog
and VideoPlayer
micro-frontends. Each component is developed by a different team, allowing for independent development and deployment.
Edge-side composition is useful for projects with high traffic and static content. By leveraging a CDN, the application can efficiently handle scalability challenges. This approach can be beneficial for online catalogs, news websites, and other applications where fast content delivery is crucial.
Here is a React code example — edge-side composition with static content:
import React from 'react'; import ProductList from './ProductList'; import AdBanner from './AdBanner'; function CatalogPage() { return ( <div className="catalog-page"> <ProductList /> <AdBanner /> </div> ); } export default CatalogPage;
In this example, we can still compose the CatalogPage
component at CDN level where it gets micro-frontends ProductList
and AdBanner
. The data for these components are served statically, so the load times will still be fast and a great user experience.
Server-side composition controls the final output i.e. when you need maximum control over what users see, especially for SEO-critical sites like online stores or news platforms. This method allows for better performance metrics, as the server can render the entire page before sending it to the client.
Here is a React code example — server-side composition:
import React from 'react'; import express from 'express'; import { renderToString } from 'react-dom/server'; import Header from './Header'; import ProductDetails from './ProductDetails'; import Footer from './Footer'; const app = express(); app.get('/product/:id', (req, res) => { const productDetailsHtml = renderToString(<ProductDetails productId={req.params.id} />); const html = ` <!DOCTYPE html> <html> <head> <title>Product Details</title> </head> <body> <div id="app"> <div>${renderToString(<Header />)}</div> <div>${productDetailsHtml}</div> <div>${renderToString(<Footer />)}</div> </div> </body> </html> `; res.send(html); }); app.listen(3000, () => { console.log('Server is running on port 3000'); });
In this server-side composition example, the server renders the Header
, ProductDetails
, and Footer
components into an HTML string. This HTML is then sent to the client, ensuring that the page is fully rendered before it reaches the user’s browser, which improves load times and SEO.
A key challenge in horizontal-split architectures is managing communication between micro-frontends. Unlike components, micro-frontends should not share a global state, as this would tightly couple them, defeating the purpose of modularization. Instead, event-driven architectures are recommended.
Here is a React code example — EventEmitter
for communication:
// EventEmitter.js import { EventEmitter } from 'events'; export const eventEmitter = new EventEmitter(); // Catalog.js import React from 'react'; import { eventEmitter } from './EventEmitter'; function Catalog() { const selectProduct = (productId) => { eventEmitter.emit('productSelected', productId); }; return ( <div> {/* Product selection logic */} <button onClick={() => selectProduct(1)}>Select Product 1</button> </div> ); } export default Catalog; // VideoPlayer.js import React, { useEffect } from 'react'; import { eventEmitter } from './EventEmitter'; function VideoPlayer() { useEffect(() => { const handleProductSelected = (productId) => { console.log('Product selected:', productId); // Handle product selection logic }; eventEmitter.on('productSelected', handleProductSelected); return () => { eventEmitter.off('productSelected', handleProductSelected); }; }, []); return ( <div> {/* Video player logic */} <h2>Video Player</h2> </div> ); } export default VideoPlayer;
In this code snippet, EventEmitter
is used to manage communication between the Catalog
and VideoPlayer
micro-frontends. When a product is selected in the Catalog
component, an event is emitted. The VideoPlayer
component listens for this event and reacts accordingly, maintaining a loose coupling between the two micro-frontends.
Aspect | Vertical micro-frontends | Horizontal micro-frontends |
---|---|---|
Structure | Full-stack feature ownership (vertical slices) | Layered separation (UI, business logic, data services) |
Development | Independent development per feature | Specialized teams per layer |
Deployment | Feature-specific deployments | Layer-specific deployments |
Scalability | Easy to scale individual features | Coordination needed across teams |
Team Organization | Cross-functional teams | Specialized teams focusing on specific layers |
State Management | Self-contained states within each feature | Shared state across different layers |
Complexity | Easier to manage feature-specific complexity | More complex due to shared components and dependencies |
Examples | E-commerce sites with independent categories | Platforms with unified UI and diverse backend services. |
While micro-frontends offer a powerful approach to scaling and managing large web applications by breaking them into smaller, independently developed modules, they also introduce challenges that must be carefully managed.
The complexity of shared state management, the intricacies of integrating independently developed modules, and the need for coordinated efforts across large teams are all significant considerations.
However, with proper planning, the implementation of robust state management solutions, and effective project coordination, these challenges can be overcome, allowing organizations to fully leverage the benefits of micro-frontends and deliver a seamless, scalable user experience.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowAngular’s two-way data binding has evolved with signals, offering improved performance, simpler syntax, and better type inference.
Fix sticky positioning issues in CSS, from missing offsets to overflow conflicts in flex, grid, and container height constraints.
From basic syntax and advanced techniques to practical applications and error handling, here’s how to use node-cron.
The Angular tree view can be hard to get right, but once you understand it, it can be quite a powerful visual representation.