Welcome to the second installment of our five-part series on microfrontends with React. In Part 1, we explored the fundamental concepts of microfrontends, their benefits, and key considerations before adoption. Now that we understand the "why" behind microfrontends, it's time to dive into the "how."
One of the most challenging aspects of implementing microfrontends is choosing the right technical approach. There's no one-size-fits-all solution, and each method comes with its own set of trade-offs. As someone who has implemented microfrontends using various techniques across different organizations, I've learned that understanding these trade-offs is crucial for making informed decisions.
In this article, we'll explore the most common approaches to implementing microfrontends with React, providing practical examples, discussing the advantages and disadvantages of each, and offering guidance on when to choose one approach over another.
Microfrontend Integration Approaches
When implementing microfrontends, you need to decide how these independent applications will be composed into a unified user experience. Let's examine the most popular approaches, starting from the simplest and progressing to more sophisticated solutions.
1. Iframe-Based Integration
The most straightforward approach to microfrontends is using iframes—HTML elements that allow embedding one HTML document within another.
How It Works
The container application includes iframes that point to independently deployed microfrontends.
function App() {
return (
<div className="container">
<header>Main Application Header</header>
<div className="content">
<iframe
src="https://team-a-microfrontend.example.com"
title="Team A Microfrontend"
style={{ height: '500px', width: '100%', border: 'none' }}
/>
<iframe
src="https://team-b-microfrontend.example.com"
title="Team B Microfrontend"
style={{ height: '500px', width: '100%', border: 'none' }}
/>
</div>
<footer>Main Application Footer</footer>
</div>
);
}
Advantages
- Perfect Isolation: Each microfrontend runs in its own context, providing strong isolation.
- Framework Agnostic: Different microfrontends can use entirely different frameworks without conflict.
- Independent Deployment: Teams can deploy their microfrontends without coordination.
- Simplest Implementation: No complex build or runtime integration required.
Disadvantages
- Limited Communication: Communication between iframes is restricted to
postMessage
, making deep integration difficult. - Performance Overhead: Each iframe loads its own React, libraries, and resources, increasing page weight.
- UX Challenges: Iframes create challenges with responsive design, focus management, and browser history.
- SEO Impact: Search engines may not properly index content within iframes.
Real-World Example
// Container application using React Router for basic navigation
import { BrowserRouter, Route, Switch, Link } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<div className="app-container">
<nav>
<Link to="/">Home</Link>
<Link to="/catalog">Product Catalog</Link>
<Link to="/account">My Account</Link>
</nav>
<Switch>
<Route exact path="/">
<iframe
src="https://home.example.com"
title="Home"
className="content-iframe"
/>
</Route>
<Route path="/catalog">
<iframe
src="https://catalog.example.com"
title="Product Catalog"
className="content-iframe"
/>
</Route>
<Route path="/account">
<iframe
src="https://account.example.com"
title="My Account"
className="content-iframe"
/>
</Route>
</Switch>
</div>
</BrowserRouter>
);
}
Question: How can I handle cross-iframe communication effectively?
While iframes have limited communication options, you can implement a structured communication protocol using postMessage
:
// In the container application
window.addEventListener('message', (event) => {
// Verify origin for security
if (event.origin !== 'https://catalog.example.com') return;
const { type, payload } = event.data;
switch (type) {
case 'ADD_TO_CART':
// Update cart state in the container
updateCart(payload);
// Notify other iframes about the update
document.querySelectorAll('iframe').forEach(iframe => {
iframe.contentWindow.postMessage({
type: 'CART_UPDATED',
payload: getCartState()
}, iframe.src);
});
break;
// Handle other message types
}
});
2. Build-Time Integration
In build-time integration, microfrontends are published as packages (often to a private npm registry) and imported into the main application during its build process.
How It Works
Each microfrontend is built as a library and published as a package. The container application imports and uses these packages as regular React components.
// Container application
import React from 'react';
import { ProductCatalog } from '@company/product-catalog';
import { UserAccount } from '@company/user-account';
import { ShoppingCart } from '@company/shopping-cart';
function App() {
return (
<div className="container">
<header>Main Application Header</header>
<div className="content">
<ProductCatalog />
<UserAccount />
<ShoppingCart />
</div>
<footer>Main Application Footer</footer>
</div>
);
}
Advantages
- Simplified Integration: Microfrontends are used as regular React components.
- Better Performance: Single React instance, shared dependencies, and optimization at build time.
- Better Developer Experience: Type checking, code completion, and other IDE features work across boundaries.
- Easier Styling Consistency: Styles can be shared more easily across microfrontends.
Disadvantages
- Coupled Releases: The container must be rebuilt and redeployed when microfrontends change.
- Version Conflicts: Potential for dependency version conflicts between microfrontends.
- Limited Independence: Teams aren't fully independent since they must coordinate releases.
- Potential Framework Lock-in: All microfrontends typically must use the same React version.
Real-World Example
// package.json in container application
{
"dependencies": {
"@company/product-catalog": "^1.2.3",
"@company/user-account": "^2.0.1",
"@company/shopping-cart": "^0.8.5",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^5.3.0"
}
}
// App.js in container
import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { ProductCatalog } from '@company/product-catalog';
import { ProductDetails } from '@company/product-details';
import { UserAccount } from '@company/user-account';
import { ShoppingCart } from '@company/shopping-cart';
import { Checkout } from '@company/checkout';
import Header from './components/Header';
import Footer from './components/Footer';
function App() {
return (
<BrowserRouter>
<div className="app-container">
<Header />
<main>
<Switch>
<Route exact path="/" component={ProductCatalog} />
<Route path="/product/:id" component={ProductDetails} />
<Route path="/account" component={UserAccount} />
<Route path="/cart" component={ShoppingCart} />
<Route path="/checkout" component={Checkout} />
</Switch>
</main>
<Footer />
</div>
</BrowserRouter>
);
}
3. Runtime Integration with Module Federation
Module Federation, introduced in Webpack 5, has become a game-changer for microfrontends. It allows JavaScript applications to dynamically load code from other applications at runtime.
How It Works
Each microfrontend is built as a separate application that exposes specific modules. The container application imports these exposed modules at runtime.
Host Application (webpack.config.js):
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
// ... other webpack configuration
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
productCatalog: 'productCatalog@http://localhost:3001/remoteEntry.js',
userAccount: 'userAccount@http://localhost:3002/remoteEntry.js',
shoppingCart: 'shoppingCart@http://localhost:3003/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
},
}),
],
};
Product Catalog Microfrontend (webpack.config.js):
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
// ... other webpack configuration
plugins: [
new ModuleFederationPlugin({
name: 'productCatalog',
filename: 'remoteEntry.js',
exposes: {
'./ProductCatalog': './src/ProductCatalog',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
},
}),
],
};
Host Application Component:
import React, { Suspense, lazy } from 'react';
// Dynamically import components from other microfrontends
const ProductCatalog = lazy(() => import('productCatalog/ProductCatalog'));
const UserAccount = lazy(() => import('userAccount/UserAccount'));
const ShoppingCart = lazy(() => import('shoppingCart/ShoppingCart'));
function App() {
return (
<div className="container">
<header>Main Application Header</header>
<div className="content">
<Suspense fallback={<div>Loading Product Catalog...</div>}>
<ProductCatalog />
</Suspense>
<Suspense fallback={<div>Loading User Account...</div>}>
<UserAccount />
</Suspense>
<Suspense fallback={<div>Loading Shopping Cart...</div>}>
<ShoppingCart />
</Suspense>
</div>
<footer>Main Application Footer</footer>
</div>
);
}
Advantages
- True Independent Deployment: Microfrontends can be deployed without rebuilding the container.
- Runtime Integration: Components are loaded on demand, potentially improving initial load time.
- Shared Dependencies: Libraries like React can be shared, avoiding duplicate downloads.
- Native React Integration: Microfrontends can be used as normal React components.
- Framework Flexibility: Different microfrontends can potentially use different versions of React or even different frameworks.
Disadvantages
- Complex Setup: Requires careful Webpack configuration and understanding of Module Federation.
- Build Tool Lock-in: Currently tied to Webpack 5, though alternatives are emerging.
- Runtime Errors: Integration issues might only surface at runtime rather than build time.
- Debugging Complexity: Issues that cross microfrontend boundaries can be harder to debug.
Real-World Example with React Router
// In container/src/App.js
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import Header from './components/Header';
import Footer from './components/Footer';
import Loading from './components/Loading';
// Dynamically import microfrontends
const ProductCatalog = lazy(() => import('productCatalog/ProductCatalog'));
const ProductDetails = lazy(() => import('productDetails/ProductDetails'));
const UserAccount = lazy(() => import('userAccount/UserAccount'));
const ShoppingCart = lazy(() => import('shoppingCart/ShoppingCart'));
const Checkout = lazy(() => import('checkout/Checkout'));
function App() {
return (
<BrowserRouter>
<div className="app-container">
<Header />
<main>
<Suspense fallback={<Loading />}>
<Switch>
<Route exact path="/" component={ProductCatalog} />
<Route path="/product/:id" component={ProductDetails} />
<Route path="/account/*" component={UserAccount} />
<Route path="/cart" component={ShoppingCart} />
<Route path="/checkout" component={Checkout} />
</Switch>
</Suspense>
</main>
<Footer />
</div>
</BrowserRouter>
);
}
Question: How do I share state between microfrontends with Module Federation?
State sharing can be implemented through several patterns:
// In container/src/context/GlobalState.js
import React, { createContext, useContext, useState } from 'react';
const GlobalStateContext = createContext();
export const GlobalStateProvider = ({ children }) => {
const [state, setState] = useState({
user: null,
cart: [],
theme: 'light'
});
return (
<GlobalStateContext.Provider value={[state, setState]}>
{children}
</GlobalStateContext.Provider>
);
};
export const useGlobalState = () => useContext(GlobalStateContext);
// In container/src/App.js
import { GlobalStateProvider } from './context/GlobalState';
function App() {
return (
<GlobalStateProvider>
{/* Rest of the application */}
</GlobalStateProvider>
);
}
// In a microfrontend component
import { useGlobalState } from 'host/GlobalState';
function ShoppingCartButton({ product }) {
const [state, setState] = useGlobalState();
const addToCart = () => {
setState(prevState => ({
...prevState,
cart: [...prevState.cart, product]
}));
};
return <button onClick={addToCart}>Add to Cart</button>;
}
4. Web Components
Web Components provide a standards-based way to create custom, reusable encapsulated components. In this approach, each microfrontend is wrapped as a custom element.
How It Works
Each microfrontend is built as a custom element using the Web Components standard. The container application simply includes these custom elements in its HTML.
// Microfrontend implementation
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
class ProductCatalogElement extends HTMLElement {
connectedCallback() {
const mountPoint = document.createElement('div');
this.attachShadow({ mode: 'open' }).appendChild(mountPoint);
ReactDOM.render(<App />, mountPoint);
}
disconnectedCallback() {
ReactDOM.unmountComponentAtNode(this.shadowRoot.firstChild);
}
}
customElements.define('product-catalog', ProductCatalogElement);
// Container application using the custom elements
function App() {
return (
<div className="container">
<header>Main Application Header</header>
<div className="content">
<product-catalog></product-catalog>
<user-account></user-account>
<shopping-cart></shopping-cart>
</div>
<footer>Main Application Footer</footer>
</div>
);
}
Advantages
- Framework Agnostic: The container can use any framework, or none at all.
- Standards-Based: Uses web standards rather than framework-specific solutions.
- Encapsulation: Shadow DOM provides strong style and DOM encapsulation.
- Independent Deployment: Each Web Component can be deployed independently.
Disadvantages
- React Integration Challenges: React's event system doesn't always play well with Shadow DOM.
- Styling Complexities: Shadow DOM makes consistent styling more challenging.
- Limited Tooling: Less mature tooling compared to framework-specific solutions.
- Performance Considerations: Each Web Component typically has its own React instance.
Real-World Example
// In product-catalog/src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import ProductCatalogApp from './ProductCatalogApp';
// Helper function to parse attributes
const parseAttribute = (attr) => {
try {
return JSON.parse(attr);
} catch (e) {
return attr;
}
};
class ProductCatalogElement extends HTMLElement {
static get observedAttributes() {
return ['categories', 'initial-filter'];
}
constructor() {
super();
this._props = {
categories: [],
initialFilter: ''
};
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'categories') {
this._props.categories = parseAttribute(newValue);
} else if (name === 'initial-filter') {
this._props.initialFilter = newValue;
}
this._render();
}
connectedCallback() {
const mountPoint = document.createElement('div');
this.attachShadow({ mode: 'open' }).appendChild(mountPoint);
// Define communication API
this.addToCart = (product) => {
const event = new CustomEvent('add-to-cart', {
bubbles: true,
composed: true,
detail: { product }
});
this.dispatchEvent(event);
};
this._render();
}
disconnectedCallback() {
ReactDOM.unmountComponentAtNode(this.shadowRoot.firstChild);
}
_render() {
if (!this.shadowRoot) return;
ReactDOM.render(
<ProductCatalogApp
categories={this._props.categories}
initialFilter={this._props.initialFilter}
addToCart={this.addToCart}
/>,
this.shadowRoot.firstChild
);
}
}
customElements.define('product-catalog', ProductCatalogElement);
// In container application
function App() {
// Handle events from microfrontends
const handleAddToCart = (event) => {
const { product } = event.detail;
// Update cart state in container
console.log('Adding to cart:', product);
};
return (
<div className="app-container">
<header>Main Application Header</header>
<main>
<product-catalog
categories='["electronics", "clothing", "books"]'
initial-filter="new-arrivals"
onAdd-to-cart={handleAddToCart}
></product-catalog>
<user-account></user-account>
<shopping-cart></shopping-cart>
</main>
<footer>Main Application Footer</footer>
</div>
);
}
Comparative Analysis
Now that we've explored the four main approaches to implementing microfrontends, let's compare them across key dimensions to help you choose the right approach for your specific context.
1. Team Independence
How independently can teams work?
- Iframe: 🟢 Highest independence. Teams can deploy without coordination.
- Build-Time: 🔴 Lowest independence. Requires coordination for releases.
- Module Federation: 🟢 High independence. Independent deployment with shared interfaces.
- Web Components: 🟢 High independence. Independent deployment with standard interfaces.
2. Performance
How does each approach impact application performance?
- Iframe: 🔴 Poorest performance. Multiple instances of dependencies, isolation overhead.
- Build-Time: 🟢 Best performance. Single instance of dependencies, build-time optimization.
- Module Federation: 🟡 Good performance. Shared dependencies, potential for lazy loading.
- Web Components: 🟡 Moderate performance. Shadow DOM overhead, potential for multiple framework instances.
3. Developer Experience
How easy is it for developers to work with each approach?
- Iframe: 🟡 Simple to implement but difficult for deep integration.
- Build-Time: 🟢 Best DX. Standard React patterns, good IDE support.
- Module Federation: 🟡 Complex setup but good development experience afterward.
- Web Components: 🔴 More challenging due to React/Shadow DOM integration issues.
4. UX Consistency
How easy is it to maintain a consistent user experience?
- Iframe: 🔴 Most challenging. Iframes create UI/UX boundaries.
- Build-Time: 🟢 Easiest. Single application with shared styles.
- Module Federation: 🟡 Good. Shared styling is possible but requires coordination.
- Web Components: 🟡 Moderate. Shadow DOM creates styling challenges but can be managed.
5. Technology Flexibility
How flexible is each approach in terms of technologies used?
- Iframe: 🟢 Maximum flexibility. Different frameworks, versions, etc.
- Build-Time: 🔴 Limited flexibility. Generally requires same major version of React.
- Module Federation: 🟡 Good flexibility. Can handle different versions with some configuration.
- Web Components: 🟢 High flexibility. Standard interface regardless of internal implementation.
Decision Framework: Choosing the Right Approach
Given these trade-offs, how do you choose the right approach for your organization? Here's a decision framework to guide you:
Choose Iframe-Based Integration When:
- You need maximum team independence
- Your microfrontends have minimal interaction needs
- You're integrating with legacy applications or third-party systems
- Different teams are using different frameworks
- You're willing to accept performance and UX trade-offs for simplicity and isolation
Choose Build-Time Integration When:
- Performance and UX consistency are your top priorities
- Your teams can coordinate deployments effectively
- You're using the same framework (React) across all teams
- Your application doesn't require frequent independent updates
- You value the best developer experience
Choose Module Federation When:
- You want independent deployment with good integration
- You're committed to using Webpack 5
- You need good performance with shared dependencies
- Your teams can agree on interface contracts
- You're willing to invest in more complex build configuration
Choose Web Components When:
- You need a standards-based approach
- You value future-proofing and framework agnosticism
- You may need to integrate non-React code in the future
- You have strong styling isolation requirements
- Your teams can handle the additional complexity of Web Components with React
Hybrid Approaches
In practice, many organizations adopt hybrid approaches that combine multiple integration strategies depending on the specific needs of each microfrontend:
- Using Module Federation for closely integrated microfrontends while using iframes for third-party integrations
- Using Web Components as the outer shell with React components inside
- Starting with Build-Time integration and gradually migrating to Module Federation
Here's an example of a hybrid approach:
// Container application with a hybrid approach
import React, { Suspense, lazy } from 'react';
// Module Federation imports
const ProductCatalog = lazy(() => import('productCatalog/ProductCatalog'));
const ShoppingCart = lazy(() => import('shoppingCart/ShoppingCart'));
function App() {
return (
<div className="app-container">
<header>E-Commerce Store</header>
<main>
{/* Module Federation for main application components */}
<Suspense fallback={<div>Loading Product Catalog...</div>}>
<ProductCatalog />
</Suspense>
<Suspense fallback={<div>Loading Shopping Cart...</div>}>
<ShoppingCart />
</Suspense>
{/* Iframe for third-party chat widget */}
<iframe
src="https://chat-widget.third-party.com"
title="Customer Support Chat"
className="chat-widget"
/>
{/* Web Component for analytics dashboard */}
<analytics-dashboard period="monthly"></analytics-dashboard>
</main>
<footer>© 2025 Example Company</footer>
</div>
);
}
Practical Tips for Implementation
Regardless of the approach you choose, here are some practical tips to ensure success:
1. Start Small
Begin with a single microfrontend and validate your approach before expanding.
Phase 1: Extract one non-critical feature as a microfrontend
Phase 2: Add 2-3 more microfrontends once patterns are established
Phase 3: Scale to more features as teams gain experience
2. Document Interface Contracts
Clearly define the interfaces between microfrontends and the container.
// Example of a documented interface contract
/**
* ProductCatalog Component
*
* @prop {string[]} categories - List of categories to display
* @prop {string} initialFilter - Initial filter to apply
* @prop {Function} onProductSelect - Callback when a product is selected
* @prop {Function} onAddToCart - Callback when a product is added to cart
*
* @returns {Component} - The product catalog component
*/
export function ProductCatalog({
categories = [],
initialFilter = '',
onProductSelect,
onAddToCart
}) {
// Implementation
}
3. Create a Shared Component Library
Establish a common component library to ensure consistency across microfrontends.
// In @company/shared-ui package
export { Button } from './components/Button';
export { Card } from './components/Card';
export { Typography } from './components/Typography';
export { theme } from './theme';
// Usage in microfrontends
import { Button, Card, Typography, theme } from '@company/shared-ui';
4. Set Up Robust Error Handling
Implement error boundaries and fallback UIs to prevent one microfrontend from breaking the entire application.
// Error boundary for microfrontends
class MicrofrontendErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="error-container">
<h2>Something went wrong.</h2>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
// Usage
<MicrofrontendErrorBoundary fallback={<ProductCatalogFallback />}>
<ProductCatalog />
</MicrofrontendErrorBoundary>
Conclusion
Choosing the right microfrontend implementation approach is a critical architectural decision that impacts your organization's ability to develop, deploy, and maintain your application effectively. Each approach—Iframes, Build-Time Integration, Module Federation, and Web Components—offers a different balance of trade-offs that should be aligned with your specific needs and constraints.
Module Federation has emerged as a particularly compelling option for React applications, striking a good balance between team independence and seamless integration. However, the right choice for your organization will depend on your specific requirements, team structure, and technical constraints.
In the next part of our series, we'll dive deeper into a practical case study, showing how to build a complete e-commerce platform using microfrontends with Module Federation. We'll explore the project structure, code organization, and patterns for communication between microfrontends.
Something to Ponder
As you evaluate these different approaches, consider this question:
How do the technical boundaries in your microfrontend architecture reflect the team and organizational boundaries in your company? And how might changing one influence the other?
This reflection on Conway's Law—that organizations design systems that mirror their communication structure—can provide valuable insights into which approach might work best in your specific context.
In Part 3 of our series, where we'll explore a practical case study of implementing microfrontends for an e-commerce platform.