Back to all posts
Web Development
Next.js
Microfrontend
React

Microfrontends with React Part 3: Practical Case Study - Building an E-commerce Platform

18 Jan 2025
36 min read
Jerry S Joseph
Jerry S Joseph
Full Stack Developer

Welcome to the third installment of our microfrontends series. In Part 1, we explored the core concepts and benefits of microfrontends. Part 2 took us through the various implementation approaches and their trade-offs. Now, it's time to put theory into practice.

As someone who has led teams through numerous microfrontend implementations, I've found that practical examples often clarify concepts better than abstract explanations. In this article, we'll build a simplified e-commerce platform using a microfrontend architecture. We'll use Module Federation as our integration approach since it offers the best balance of team independence and seamless user experience.

By the end of this tutorial, you'll understand the practical aspects of implementing microfrontends, from project structure to cross-team communication patterns. Let's dive in.

Project Overview

Our e-commerce platform will consist of five distinct microfrontends, each owned by a different team:

  1. Container - The shell application that hosts other microfrontends
  2. Product Catalog - Handles product listing and filtering
  3. Product Details - Shows detailed information about specific products
  4. Shopping Cart - Manages the user's selected items
  5. User Account - Handles user profile, authentication, and order history

Each microfrontend will be a separate application with its own repository, build process, and deployment pipeline, but they'll work together to create a seamless shopping experience.

Project Structure

Our project follows this directory structure:

/micro-ecommerce
  /container          # Shell application
    /public
    /src
      /components     # Container-specific components
      /context        # Shared state providers
      App.js          # Main application component
      bootstrap.js    # Application entry point
      index.js        # Mount point
    package.json
    webpack.config.js
  
  /product-catalog    # Product listing microfrontend
    /public
    /src
      /components
      /services
      index.js
      ProductCatalog.js
    package.json
    webpack.config.js
  
  /product-details    # Product detail microfrontend
    ...similar structure
  
  /shopping-cart      # Shopping cart microfrontend
    ...similar structure
  
  /user-account       # User account microfrontend
    ...similar structure

Setting Up the Development Environment

Before diving into the code, let's set up our development environment. We'll use a monorepo approach for simplicity, though in a real-world scenario, each microfrontend would likely have its own repository.

First, create the project root directory:

mkdir micro-ecommerce
cd micro-ecommerce

Next, we'll set up each microfrontend. Let's start with the container application.

Container Application Setup

mkdir -p container/src/components container/src/context
cd container
npm init -y

Install the dependencies:

npm install react react-dom react-router-dom history
npm install --save-dev webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/core @babel/preset-env @babel/preset-react style-loader css-loader

Creating the Webpack Configuration

The container application needs to know about the other microfrontends. Let's set up the webpack.config.js file with Module Federation:

// container/webpack.config.js
const HtmlWebPackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack").container.ModuleFederationPlugin;
const path = require("path");
 
module.exports = {
  output: {
    publicPath: "http://localhost:3000/",
  },
  resolve: {
    extensions: [".jsx", ".js", ".json"],
  },
  devServer: {
    port: 3000,
    historyApiFallback: true,
  },
  module: {
    rules: [
      {
        test: /\.m?js/,
        type: "javascript/auto",
        resolve: {
          fullySpecified: false,
        },
      },
      {
        test: /\.jsx?$/,
        loader: "babel-loader",
        exclude: /node_modules/,
        options: {
          presets: ["@babel/preset-react"],
        },
      },
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "container",
      remotes: {
        productCatalog: "productCatalog@http://localhost:3001/remoteEntry.js",
        productDetails: "productDetails@http://localhost:3002/remoteEntry.js",
        shoppingCart: "shoppingCart@http://localhost:3003/remoteEntry.js",
        userAccount: "userAccount@http://localhost:3004/remoteEntry.js",
      },
      exposes: {
        "./GlobalState": "./src/context/GlobalState",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^17.0.2" },
        "react-dom": { singleton: true, requiredVersion: "^17.0.2" },
        "react-router-dom": { singleton: true, requiredVersion: "^5.3.0" },
      },
    }),
    new HtmlWebPackPlugin({
      template: "./public/index.html",
    }),
  ],
};

Note that we're exposing the GlobalState from the container, which will allow microfrontends to share state.

Setting Up the Container Application

Let's create an HTML template:

<!-- container/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>Micro E-Commerce</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>

Now, let's create the global state context:

// container/src/context/GlobalState.js
import React, { createContext, useContext, useState } from 'react';
 
// Create context with default values
const GlobalStateContext = createContext();
 
// Initial state
const initialState = {
  user: null,
  cart: [],
  theme: 'light',
};
 
// State provider component
export const GlobalStateProvider = ({ children }) => {
  const [state, setState] = useState(initialState);
 
  // Convenience function to update only specific parts of the state
  const updateState = (newState) => {
    setState((prevState) => ({
      ...prevState,
      ...newState,
    }));
  };
 
  return (
    <GlobalStateContext.Provider value={[state, updateState]}>
      {children}
    </GlobalStateContext.Provider>
  );
};
 
// Custom hook for using the global state
export const useGlobalState = () => {
  const context = useContext(GlobalStateContext);
  if (!context) {
    throw new Error('useGlobalState must be used within a GlobalStateProvider');
  }
  return context;
};

Let's create the main components of our container application:

// container/src/components/Header.js
import React from 'react';
import { Link } from 'react-router-dom';
import { useGlobalState } from '../context/GlobalState';
import './Header.css';
 
export default function Header() {
  const [{ cart, user }] = useGlobalState();
  
  return (
    <header className="main-header">
      <div className="logo">
        <Link to="/">Micro E-Commerce</Link>
      </div>
      <nav>
        <ul>
          <li><Link to="/">Home</Link></li>
          <li><Link to="/cart">Cart ({cart.length})</Link></li>
          <li>
            {user ? (
              <Link to="/account">My Account</Link>
            ) : (
              <Link to="/account/login">Login</Link>
            )}
          </li>
        </ul>
      </nav>
    </header>
  );
}
/* container/src/components/Header.css */
.main-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 2rem;
  background-color: #1a1a1a;
  color: white;
}
 
.logo a {
  color: white;
  text-decoration: none;
  font-size: 1.5rem;
  font-weight: bold;
}
 
nav ul {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
}
 
nav li {
  margin-left: 1rem;
}
 
nav a {
  color: white;
  text-decoration: none;
}
 
nav a:hover {
  text-decoration: underline;
}
// container/src/components/Footer.js
import React from 'react';
import './Footer.css';
 
export default function Footer() {
  return (
    <footer className="main-footer">
      <div className="footer-content">
        <p>&copy; 2025 Micro E-Commerce. All rights reserved.</p>
      </div>
    </footer>
  );
}
/* container/src/components/Footer.css */
.main-footer {
  background-color: #1a1a1a;
  color: white;
  padding: 1rem 2rem;
  margin-top: 2rem;
}
 
.footer-content {
  text-align: center;
}
// container/src/components/ErrorBoundary.js
import React from 'react';
 
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
 
  static getDerivedStateFromError() {
    return { hasError: true };
  }
 
  componentDidCatch(error, errorInfo) {
    console.error('Microfrontend error:', error, errorInfo);
  }
 
  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;
  }
}
 
export default ErrorBoundary;

Now, let's create the main App component:

// container/src/App.js
import React, { lazy, Suspense } from 'react';
import { Switch, Route, BrowserRouter } from 'react-router-dom';
import { GlobalStateProvider } from './context/GlobalState';
import Header from './components/Header';
import Footer from './components/Footer';
import ErrorBoundary from './components/ErrorBoundary';
import './App.css';
 
// Lazy load microfrontends
const ProductCatalog = lazy(() => import('productCatalog/ProductCatalog'));
const ProductDetails = lazy(() => import('productDetails/ProductDetails'));
const ShoppingCart = lazy(() => import('shoppingCart/ShoppingCart'));
const UserAccount = lazy(() => import('userAccount/UserAccount'));
 
// Loading component
const Loading = () => (
  <div className="loading-container">
    <div className="loading-spinner"></div>
    <p>Loading...</p>
  </div>
);
 
// Error fallback component
const ErrorFallback = ({ name }) => (
  <div className="error-fallback">
    <h2>Failed to load {name}</h2>
    <p>Please try refreshing the page.</p>
  </div>
);
 
export default function App() {
  return (
    <GlobalStateProvider>
      <BrowserRouter>
        <div className="app-container">
          <Header />
          <main className="main-content">
            <Switch>
              <Route exact path="/">
                <ErrorBoundary fallback={<ErrorFallback name="Product Catalog" />}>
                  <Suspense fallback={<Loading />}>
                    <ProductCatalog />
                  </Suspense>
                </ErrorBoundary>
              </Route>
              <Route path="/product/:id">
                <ErrorBoundary fallback={<ErrorFallback name="Product Details" />}>
                  <Suspense fallback={<Loading />}>
                    <ProductDetails />
                  </Suspense>
                </ErrorBoundary>
              </Route>
              <Route path="/cart">
                <ErrorBoundary fallback={<ErrorFallback name="Shopping Cart" />}>
                  <Suspense fallback={<Loading />}>
                    <ShoppingCart />
                  </Suspense>
                </ErrorBoundary>
              </Route>
              <Route path="/account">
                <ErrorBoundary fallback={<ErrorFallback name="User Account" />}>
                  <Suspense fallback={<Loading />}>
                    <UserAccount />
                  </Suspense>
                </ErrorBoundary>
              </Route>
            </Switch>
          </main>
          <Footer />
        </div>
      </BrowserRouter>
    </GlobalStateProvider>
  );
}
/* container/src/App.css */
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}
 
body {
  font-family: Arial, sans-serif;
  line-height: 1.6;
  color: #333;
  background-color: #f4f4f4;
}
 
.app-container {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}
 
.main-content {
  flex: 1;
  padding: 2rem;
  max-width: 1200px;
  margin: 0 auto;
  width: 100%;
}
 
.loading-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 300px;
}
 
.loading-spinner {
  width: 50px;
  height: 50px;
  border: 5px solid #f3f3f3;
  border-top: 5px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-bottom: 1rem;
}
 
@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
 
.error-fallback {
  padding: 2rem;
  border: 1px solid #e74c3c;
  border-radius: 4px;
  background-color: #fdf2f2;
  color: #c0392b;
  text-align: center;
}

Finally, let's set up the entry point:

// container/src/index.js
import('./bootstrap');
// container/src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
 
ReactDOM.render(<App />, document.getElementById('root'));

Setting Up the Product Catalog Microfrontend

Let's create the Product Catalog microfrontend:

mkdir -p product-catalog/src/components product-catalog/src/services product-catalog/public
cd product-catalog
npm init -y

Install the dependencies:

npm install react react-dom react-router-dom
npm install --save-dev webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/core @babel/preset-env @babel/preset-react style-loader css-loader

Set up the webpack configuration:

// product-catalog/webpack.config.js
const HtmlWebPackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack").container.ModuleFederationPlugin;
const path = require("path");
 
module.exports = {
  output: {
    publicPath: "http://localhost:3001/",
  },
  resolve: {
    extensions: [".jsx", ".js", ".json"],
  },
  devServer: {
    port: 3001,
    historyApiFallback: true,
  },
  module: {
    rules: [
      {
        test: /\.m?js/,
        type: "javascript/auto",
        resolve: {
          fullySpecified: false,
        },
      },
      {
        test: /\.jsx?$/,
        loader: "babel-loader",
        exclude: /node_modules/,
        options: {
          presets: ["@babel/preset-react"],
        },
      },
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "productCatalog",
      filename: "remoteEntry.js",
      exposes: {
        "./ProductCatalog": "./src/ProductCatalog",
      },
      remotes: {
        container: "container@http://localhost:3000/remoteEntry.js",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^17.0.2" },
        "react-dom": { singleton: true, requiredVersion: "^17.0.2" },
        "react-router-dom": { singleton: true, requiredVersion: "^5.3.0" },
      },
    }),
    new HtmlWebPackPlugin({
      template: "./public/index.html",
    }),
  ],
};

Create an HTML template:

<!-- product-catalog/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>Product Catalog</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>

Create a product service:

// product-catalog/src/services/productService.js
// In a real application, this would fetch from an API
export const getProducts = () => {
  return Promise.resolve([
    {
      id: "1",
      name: "Wireless Headphones",
      price: 129.99,
      category: "Electronics",
      image: "https://via.placeholder.com/200",
      description: "High-quality wireless headphones with noise cancellation."
    },
    {
      id: "2",
      name: "Smart Watch",
      price: 199.99,
      category: "Electronics",
      image: "https://via.placeholder.com/200",
      description: "Track your fitness goals and receive notifications on your wrist."
    },
    {
      id: "3",
      name: "Coffee Maker",
      price: 89.99,
      category: "Home",
      image: "https://via.placeholder.com/200",
      description: "Programmable coffee maker for the perfect cup every morning."
    },
    {
      id: "4",
      name: "Running Shoes",
      price: 79.99,
      category: "Sports",
      image: "https://via.placeholder.com/200",
      description: "Lightweight running shoes with cushioned soles for comfort."
    },
  ]);
};

Create the product card component:

// product-catalog/src/components/ProductCard.js
import React from 'react';
import { Link } from 'react-router-dom';
import { useGlobalState } from 'container/GlobalState';
import './ProductCard.css';
 
export default function ProductCard({ product }) {
  const [, updateState] = useGlobalState();
  
  const addToCart = () => {
    updateState(prevState => ({
      cart: [...prevState.cart, product]
    }));
  };
 
  return (
    <div className="product-card">
      <div className="product-image">
        <img src={product.image} alt={product.name} />
      </div>
      <div className="product-info">
        <h3>{product.name}</h3>
        <p className="product-price">${product.price.toFixed(2)}</p>
        <p className="product-category">{product.category}</p>
        <div className="product-actions">
          <Link to={`/product/${product.id}`} className="view-details">
            View Details
          </Link>
          <button className="add-to-cart" onClick={addToCart}>
            Add to Cart
          </button>
        </div>
      </div>
    </div>
  );
}
/* product-catalog/src/components/ProductCard.css */
.product-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
  background-color: white;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease;
}
 
.product-card:hover {
  transform: translateY(-5px);
}
 
.product-image img {
  width: 100%;
  height: 200px;
  object-fit: cover;
}
 
.product-info {
  padding: 1rem;
}
 
.product-info h3 {
  margin-bottom: 0.5rem;
}
 
.product-price {
  font-weight: bold;
  color: #e74c3c;
  margin-bottom: 0.5rem;
}
 
.product-category {
  color: #7f8c8d;
  font-size: 0.9rem;
  margin-bottom: 1rem;
}
 
.product-actions {
  display: flex;
  justify-content: space-between;
}
 
.view-details {
  text-decoration: none;
  color: #3498db;
  font-weight: bold;
}
 
.add-to-cart {
  background-color: #2ecc71;
  color: white;
  border: none;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  cursor: pointer;
  font-weight: bold;
}
 
.add-to-cart:hover {
  background-color: #27ae60;
}

Create the product catalog component:

// product-catalog/src/ProductCatalog.js
import React, { useState, useEffect } from 'react';
import { getProducts } from './services/productService';
import ProductCard from './components/ProductCard';
import './ProductCatalog.css';
 
export default function ProductCatalog() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [selectedCategory, setSelectedCategory] = useState('All');
  
  useEffect(() => {
    getProducts()
      .then(data => {
        setProducts(data);
        setLoading(false);
      })
      .catch(error => {
        console.error('Error fetching products:', error);
        setLoading(false);
      });
  }, []);
  
  const categories = ['All', ...new Set(products.map(product => product.category))];
  
  const filteredProducts = selectedCategory === 'All'
    ? products
    : products.filter(product => product.category === selectedCategory);
  
  if (loading) {
    return <div>Loading products...</div>;
  }
  
  return (
    <div className="product-catalog">
      <h1>Product Catalog</h1>
      
      <div className="category-filter">
        {categories.map(category => (
          <button
            key={category}
            className={selectedCategory === category ? 'active' : ''}
            onClick={() => setSelectedCategory(category)}
          >
            {category}
          </button>
        ))}
      </div>
      
      <div className="products-grid">
        {filteredProducts.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}
/* product-catalog/src/ProductCatalog.css */
.product-catalog {
  max-width: 1200px;
  margin: 0 auto;
}
 
.product-catalog h1 {
  margin-bottom: 2rem;
  text-align: center;
}
 
.category-filter {
  display: flex;
  justify-content: center;
  margin-bottom: 2rem;
}
 
.category-filter button {
  background-color: white;
  border: 1px solid #ddd;
  padding: 0.5rem 1rem;
  margin: 0 0.5rem;
  cursor: pointer;
  border-radius: 4px;
}
 
.category-filter button.active {
  background-color: #3498db;
  color: white;
  border-color: #3498db;
}
 
.products-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 2rem;
}

Set up the entry point:

// product-catalog/src/index.js
import('./bootstrap');
// product-catalog/src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import ProductCatalog from './ProductCatalog';
 
// Mount the application when running standalone
const mount = (el) => {
  ReactDOM.render(<ProductCatalog />, el);
};
 
// If we're running in development mode and in isolation, mount immediately
if (process.env.NODE_ENV === 'development') {
  const devRoot = document.getElementById('root');
  if (devRoot) {
    mount(devRoot);
  }
}
 
// Export the component for the container application
export default ProductCatalog;

Similar Setup for Other Microfrontends

You would follow a similar process to set up the other microfrontends (product-details, shopping-cart, and user-account). Each would have its own webpack configuration, service layer, components, and exposed modules.

For brevity, we won't detail all of them, but their structure would be similar to the product-catalog microfrontend.

Communication Patterns Between Microfrontends

In our implementation, we've used several patterns for communication between microfrontends:

1. Shared State via Context

We've exposed a GlobalState context from the container application, which all microfrontends can import and use. This provides a centralized state management solution.

// Usage in a microfrontend
import { useGlobalState } from 'container/GlobalState';
 
function Component() {
  const [state, updateState] = useGlobalState();
  
  // Access state
  console.log(state.cart);
  
  // Update state
  updateState({ user: { id: 1, name: 'John' } });
  
  // ...
}

2. URL-Based Communication

Microfrontends communicate through URL changes using React Router:

// In product-catalog
import { Link } from 'react-router-dom';
 
<Link to={`/product/${product.id}`}>View Details</Link>
 
// In product-details
import { useParams } from 'react-router-dom';
 
function ProductDetails() {
  const { id } = useParams();
  // Fetch product with id
}

3. Props Passing

The container can pass props to microfrontends when rendering them:

// In container
<ProductCatalog initialFilter="electronics" />
 
// In product-catalog
export default function ProductCatalog({ initialFilter = 'All' }) {
  const [selectedCategory, setSelectedCategory] = useState(initialFilter);
  // ...
}

Data Fetching Strategies

In our implementation, each microfrontend is responsible for fetching its own data. This ensures independence and encapsulation.

// product-catalog/src/services/productService.js
export const getProducts = () => {
  return fetch('/api/products')
    .then(response => response.json())
    .catch(error => {
      console.error('Error fetching products:', error);
      throw error;
    });
};
 
// In the component
useEffect(() => {
  getProducts()
    .then(data => {
      setProducts(data);
      setLoading(false);
    })
    .catch(error => {
      setError(error);
      setLoading(false);
    });
}, []);

However, this approach can lead to duplicate requests if multiple microfrontends need the same data. To address this, we could implement:

  1. Caching Layer: Using libraries like React Query or SWR for data caching
  2. Backend for Frontend (BFF): Creating a specialized API gateway for each microfrontend
  3. Shared API Client: Exposing a shared API client from the container

Styling Strategies

Consistent styling across microfrontends is crucial for a seamless user experience. In our implementation, we've used CSS modules scoped to each component, but there are several approaches to consider:

1. Shared Design System

A common approach is to create a shared component library that all microfrontends consume:

// In a shared-ui package
export { Button } from './components/Button';
export { Card } from './components/Card';
export { Typography } from './components/Typography';
 
// Usage in microfrontends
import { Button, Card } from '@company/shared-ui';

2. CSS Custom Properties

Using CSS variables for theming ensures consistency:

/* In container's global CSS */
:root {
  --primary-color: #3498db;
  --secondary-color: #2ecc71;
  --error-color: #e74c3c;
  --text-color: #333;
  --background-color: #f4f4f4;
}
 
/* In microfrontend CSS */
.button {
  background-color: var(--primary-color);
  color: white;
}

3. CSS-in-JS Libraries

Styled-components or Emotion can provide consistent theming:

// In container
import { ThemeProvider } from 'styled-components';
 
const theme = {
  colors: {
    primary: '#3498db',
    secondary: '#2ecc71',
  },
};
 
<ThemeProvider theme={theme}>
  {/* Microfrontends */}
</ThemeProvider>
 
// In microfrontend
import styled from 'styled-components';
 
const Button = styled.button`
  background-color: ${props => props.theme.colors.primary};
  color: white;
`;

Microfrontends with React Part 3: Practical Case Study - Building an E-commerce Platform

[... previously covered content omitted for brevity ...]

Testing Strategies

Testing microfrontends requires a multi-layered approach:

1. Unit Testing Individual Microfrontends

Each microfrontend should have its own unit tests for components and services:

// Example with Jest and React Testing Library
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ProductCard from '../src/components/ProductCard';
 
// Mock the container's global state
jest.mock('container/GlobalState', () => ({
  useGlobalState: jest.fn(() => [
    { cart: [] },
    jest.fn()
  ])
}));
 
describe('ProductCard', () => {
  const mockProduct = {
    id: '1',
    name: 'Test Product',
    price: 99.99,
    category: 'Test',
    image: 'test.jpg',
  };
  
  test('renders product information', () => {
    render(<ProductCard product={mockProduct} />);
    
    expect(screen.getByText('Test Product')).toBeInTheDocument();
    expect(screen.getByText('$99.99')).toBeInTheDocument();
    expect(screen.getByText('Test')).toBeInTheDocument();
  });
  
  test('calls updateState when Add to Cart is clicked', () => {
    const mockUpdateState = jest.fn();
    require('container/GlobalState').useGlobalState.mockReturnValue([
      { cart: [] },
      mockUpdateState
    ]);
    
    render(<ProductCard product={mockProduct} />);
    
    userEvent.click(screen.getByText('Add to Cart'));
    
    expect(mockUpdateState).toHaveBeenCalled();
  });
});

2. Integration Testing

Integration tests verify that microfrontends work together correctly:

// Example with Cypress
describe('E-commerce integration', () => {
  beforeEach(() => {
    cy.visit('http://localhost:3000');
  });
  
  it('allows adding a product to cart and viewing it in the cart', () => {
    // Find a product in the catalog
    cy.contains('Wireless Headphones')
      .parent()
      .within(() => {
        cy.contains('Add to Cart').click();
      });
    
    // Navigate to cart
    cy.contains('Cart').click();
    
    // Verify product is in cart
    cy.contains('Wireless Headphones');
    cy.contains('$129.99');
    cy.contains('Proceed to Checkout');
  });
  
  it('supports user authentication flow', () => {
    // Navigate to login
    cy.contains('Login').click();
    
    // Fill login form
    cy.get('input[name="email"]').type('test@example.com');
    cy.get('input[name="password"]').type('password123');
    cy.contains('Sign In').click();
    
    // Verify user is logged in
    cy.contains('My Account');
    cy.contains('Welcome back');
  });
});

3. Contract Testing

Contract tests ensure that microfrontends adhere to the agreed-upon interfaces:

// Example contract test for ProductCatalog
import ProductCatalog from '../src/ProductCatalog';
 
describe('ProductCatalog Contract', () => {
  test('exposes the correct component', () => {
    expect(typeof ProductCatalog).toBe('function');
  });
  
  test('accepts the expected props', () => {
    // Use react-test-renderer to shallowly render the component
    const renderer = require('react-test-renderer');
    
    // Mock the dependencies
    jest.mock('./services/productService', () => ({
      getProducts: jest.fn(() => Promise.resolve([]))
    }));
    
    jest.mock('container/GlobalState', () => ({
      useGlobalState: jest.fn(() => [{}, jest.fn()])
    }));
    
    // Test that it renders with different props
    expect(() => renderer.create(<ProductCatalog />)).not.toThrow();
    expect(() => renderer.create(<ProductCatalog initialFilter="Electronics" />)).not.toThrow();
  });
});

4. End-to-End Testing

E2E tests should cover critical user journeys across the entire application:

// Example with Cypress
describe('Complete shopping flow', () => {
  it('allows a user to browse, add to cart, and checkout', () => {
    // Visit the homepage
    cy.visit('http://localhost:3000');
    
    // Browse products
    cy.contains('Electronics').click();
    
    // View product details
    cy.contains('Smart Watch').click();
    
    // Add to cart
    cy.contains('Add to Cart').click();
    
    // Go to cart
    cy.contains('Cart').click();
    
    // Proceed to checkout
    cy.contains('Proceed to Checkout').click();
    
    // Log in
    cy.get('input[name="email"]').type('test@example.com');
    cy.get('input[name="password"]').type('password123');
    cy.contains('Continue').click();
    
    // Fill shipping information
    cy.get('input[name="address"]').type('123 Main St');
    cy.get('input[name="city"]').type('New York');
    cy.get('input[name="zip"]').type('10001');
    cy.contains('Continue to Payment').click();
    
    // Fill payment information
    cy.get('input[name="cardNumber"]').type('4111111111111111');
    cy.get('input[name="expiry"]').type('12/25');
    cy.get('input[name="cvv"]').type('123');
    cy.contains('Complete Order').click();
    
    // Verify order confirmation
    cy.contains('Order Confirmed');
    cy.contains('Thank you for your purchase');
  });
});

Running the Application

To run the application, you would need to start each microfrontend separately:

# Terminal 1 - Container
cd container
npm start
 
# Terminal 2 - Product Catalog
cd product-catalog
npm start
 
# Terminal 3 - Product Details
cd product-details
npm start
 
# Terminal 4 - Shopping Cart
cd shopping-cart
npm start
 
# Terminal 5 - User Account
cd user-account
npm start

In a real-world scenario, you might use tools like Lerna or Nx to manage a monorepo, or implement a development environment that can start all microfrontends with a single command.

Deployment Considerations

Deploying microfrontends introduces new challenges compared to monolithic applications. Here are some key considerations:

1. Independent Deployment Pipelines

Each microfrontend should have its own CI/CD pipeline:

# Example GitHub Actions workflow for product-catalog
name: Deploy Product Catalog
 
on:
  push:
    branches:
      - main
    paths:
      - 'product-catalog/**'
 
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      
      - name: Set up Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '14'
      
      - name: Install dependencies
        working-directory: ./product-catalog
        run: npm ci
      
      - name: Build
        working-directory: ./product-catalog
        run: npm run build
      
      - name: Deploy to S3
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      
      - run: |
          aws s3 sync ./product-catalog/dist s3://micro-ecommerce-product-catalog/ --delete
          aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"

2. Environment-Specific Configuration

Your webpack configuration should use environment variables for remote URLs:

// webpack.config.js
const { CONTAINER_URL, PRODUCT_CATALOG_URL, PRODUCT_DETAILS_URL } = process.env;
 
module.exports = {
  // ...
  plugins: [
    new ModuleFederationPlugin({
      name: "container",
      remotes: {
        productCatalog: `productCatalog@${PRODUCT_CATALOG_URL}/remoteEntry.js`,
        productDetails: `productDetails@${PRODUCT_DETAILS_URL}/remoteEntry.js`,
        // ...
      },
      // ...
    }),
  ],
};

3. Versioning and Compatibility

To ensure compatibility between the container and microfrontends, you can use versioning in your URLs:

// webpack.config.js
new ModuleFederationPlugin({
  name: "container",
  remotes: {
    productCatalog: `productCatalog@https://catalog.example.com/v1/remoteEntry.js`,
    productDetails: `productDetails@https://details.example.com/v2/remoteEntry.js`,
    // ...
  },
  // ...
});

4. Fallback Strategies

Implement fallbacks in case a microfrontend fails to load:

// In container/src/App.js
<ErrorBoundary fallback={<ProductCatalogFallback />}>
  <Suspense fallback={<Loading />}>
    <ProductCatalog />
  </Suspense>
</ErrorBoundary>
 
// Fallback component
const ProductCatalogFallback = () => (
  <div className="fallback-container">
    <h2>Sorry, the Product Catalog is currently unavailable.</h2>
    <p>Please try again later or contact support if the problem persists.</p>
    <button onClick={() => window.location.reload()}>
      Refresh Page
    </button>
  </div>
);

Common Challenges and Solutions

Throughout my experience implementing microfrontends, I've encountered several common challenges. Here's how to address them:

1. Shared Dependencies

Challenge: Managing shared dependencies across microfrontends.

Solution: Use the shared configuration in Module Federation:

new ModuleFederationPlugin({
  // ...
  shared: {
    react: { 
      singleton: true, 
      requiredVersion: "^17.0.2",
      eager: true
    },
    "react-dom": { 
      singleton: true, 
      requiredVersion: "^17.0.2",
      eager: true
    },
    "react-router-dom": { 
      singleton: true, 
      requiredVersion: "^5.3.0" 
    },
    // Other shared dependencies
  },
});

2. Consistent Styling

Challenge: Maintaining visual consistency across microfrontends.

Solution: Implement a shared design system:

// In a separate design-system package
export { Button, Card, Typography } from './components';
export { theme, GlobalStyles } from './theme';
 
// In container
import { ThemeProvider, GlobalStyles } from '@company/design-system';
 
<ThemeProvider>
  <GlobalStyles />
  {/* Microfrontends */}
</ThemeProvider>
 
// In microfrontends
import { Button, Card } from '@company/design-system';

3. Authentication and Authorization

Challenge: Sharing authentication state across microfrontends.

Solution: Implement authentication in the container and share via context:

// In container/src/context/AuthContext.js
export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    // Check for existing session
    const token = localStorage.getItem('auth_token');
    if (token) {
      validateToken(token)
        .then(userData => {
          setUser(userData);
          setLoading(false);
        })
        .catch(() => {
          localStorage.removeItem('auth_token');
          setLoading(false);
        });
    } else {
      setLoading(false);
    }
  }, []);
  
  const login = async (credentials) => {
    setLoading(true);
    try {
      const { token, user } = await authService.login(credentials);
      localStorage.setItem('auth_token', token);
      setUser(user);
      return user;
    } catch (error) {
      throw error;
    } finally {
      setLoading(false);
    }
  };
  
  const logout = () => {
    localStorage.removeItem('auth_token');
    setUser(null);
  };
  
  return (
    <AuthContext.Provider value={{ user, loading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};
 
// Expose for microfrontends
export const useAuth = () => useContext(AuthContext);

4. Performance Optimization

Challenge: Ensuring good performance with multiple microfrontends.

Solution: Implement code splitting and lazy loading:

// In container/src/App.js
const ProductCatalog = lazy(() => import('productCatalog/ProductCatalog'));
const ProductDetails = lazy(() => import('productDetails/ProductDetails'));
 
// Only load what's needed based on the route
<Suspense fallback={<Loading />}>
  <Switch>
    <Route exact path="/" component={ProductCatalog} />
    <Route path="/product/:id" component={ProductDetails} />
    {/* Other routes */}
  </Switch>
</Suspense>

5. Debugging Across Microfrontends

Challenge: Debugging issues that span multiple microfrontends.

Solution: Implement centralized logging and add correlation IDs:

// In container/src/services/logger.js
export const createLogger = (source) => {
  return {
    info: (message, data = {}) => {
      console.info(`[${source}] ${message}`, { ...data, timestamp: new Date() });
      // In production, send to a logging service
    },
    error: (message, error, data = {}) => {
      console.error(`[${source}] ${message}`, error, { ...data, timestamp: new Date() });
      // In production, send to a logging service
    }
  };
};
 
// In container
export const logger = createLogger('container');
export { createLogger };
 
// In a microfrontend
import { createLogger } from 'container/logger';
const logger = createLogger('product-catalog');
 
try {
  // Some operation
} catch (error) {
  logger.error('Failed to fetch products', error, { userId: '123' });
}

Lessons Learned

Based on my experience implementing microfrontends across multiple organizations, here are some key lessons:

1. Start Small

Begin with a single, well-defined microfrontend rather than breaking everything up at once. This allows you to establish patterns and processes before scaling.

2. Establish Clear Boundaries

The most successful microfrontend implementations have clear domain boundaries. Align with business capabilities, not technical concerns.

3. Document Interface Contracts

Clear documentation of the interfaces between microfrontends is essential for team autonomy.

4. Invest in Developer Experience

Create tooling and templates to make microfrontend development as smooth as possible. The initial investment pays off as you scale.

5. Centralize Common Concerns

Some aspects benefit from centralization: authentication, logging, monitoring, and design systems. This reduces duplication while maintaining team autonomy.

6. Plan for Failure

Design your system with resilience in mind. No microfrontend should be able to break the entire application.

Conclusion

In this article, we've built a practical e-commerce platform using microfrontends with Module Federation. We've covered project structure, communication patterns, testing strategies, and deployment considerations.

Microfrontends offer significant benefits for large organizations, enabling team autonomy and independent deployment. However, they also introduce complexity that must be managed carefully.

As you implement microfrontends in your own projects, remember that there's no one-size-fits-all approach. The patterns and practices outlined here should be adapted to your specific needs and constraints.

In Part 4 of our series, we'll dive deeper into solving common microfrontend challenges, focusing on performance optimization, state management, and routing strategies.

Food for Thought

As we conclude this practical case study, consider this question:

How might the boundaries between microfrontends evolve as your business grows and changes? What architectural patterns could you implement today to make those future changes less disruptive?

The most successful microfrontend architectures are those that can adapt to changing business needs without requiring complete rewrites. Thinking about this evolution from the start can save significant effort down the road.