Back to all posts
Web Development
Next.js
Microfrontend
React

Microfrontends with React Part 4: Solving Common Microfrontend Challenges

25 Jan 2025
47 min read
Jerry S Joseph
Jerry S Joseph
Full Stack Developer

Welcome to the fourth part of our microfrontends series. In Part 1, we explored the fundamental concepts and benefits of microfrontends. Part 2 covered implementation approaches and their trade-offs. Part 3 provided a practical case study of building an e-commerce platform using microfrontends.

While microfrontends offer compelling benefits for organizations with multiple teams working on complex applications, they also introduce challenges that can undermine these benefits if not addressed properly. Throughout my career leading engineering teams, I've encountered numerous challenges with microfrontend architectures and developed patterns to solve them.

In this article, we'll explore the most common challenges that teams face when implementing microfrontends and provide practical solutions to address them. By the end, you'll have a toolkit of strategies to ensure your microfrontend architecture remains maintainable, performant, and user-friendly.

Challenge 1: Consistent Styling and Design

One of the most visible challenges with microfrontends is maintaining a consistent look and feel across independently developed components. Without careful planning, users might notice inconsistencies in styling, component behavior, or visual language.

The Problem

When different teams independently develop microfrontends, they might:

  • Use slightly different colors, fonts, or spacing
  • Implement similar components with different behaviors
  • Follow inconsistent UX patterns for common interactions
  • Create incompatible responsive design approaches

This leads to a fragmented user experience that feels cobbled together rather than cohesive.

Solution: Implement a Shared Design System

A comprehensive design system provides a single source of truth for UI components, styling, and interaction patterns.

1. Create a Design System Package:

mkdir -p design-system/src/{components,theme,hooks,utils}
cd design-system
npm init -y

2. Implement Core Components:

// design-system/src/components/Button.js
import React from 'react';
import styled from 'styled-components';
import { theme } from '../theme';
 
const StyledButton = styled.button`
  padding: ${props => props.size === 'small' ? '0.5rem 1rem' : '0.75rem 1.5rem'};
  background-color: ${props => 
    props.variant === 'primary' ? theme.colors.primary : 
    props.variant === 'secondary' ? theme.colors.secondary : 
    'transparent'
  };
  color: ${props => 
    props.variant === 'primary' || props.variant === 'secondary' ? 
    'white' : theme.colors.primary
  };
  border: ${props => 
    props.variant === 'outline' ? 
    `1px solid ${theme.colors.primary}` : 'none'
  };
  border-radius: ${theme.borderRadius};
  font-family: ${theme.fontFamily};
  font-weight: 600;
  font-size: ${props => props.size === 'small' ? '0.875rem' : '1rem'};
  cursor: pointer;
  transition: all 0.2s ease;
 
  &:hover {
    opacity: 0.9;
    transform: translateY(-1px);
  }
 
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
    transform: none;
  }
`;
 
export const Button = ({
  children,
  variant = 'primary',
  size = 'medium',
  ...props
}) => (
  <StyledButton 
    variant={variant} 
    size={size} 
    {...props}
  >
    {children}
  </StyledButton>
);

3. Define Theme and Global Styles:

// design-system/src/theme/index.js
export const theme = {
  colors: {
    primary: '#3498db',
    secondary: '#2ecc71',
    danger: '#e74c3c',
    warning: '#f39c12',
    info: '#1abc9c',
    light: '#f5f5f5',
    dark: '#333333',
    text: '#2d3436',
    background: '#ffffff',
  },
  fontFamily: "'Roboto', sans-serif",
  fontSize: {
    small: '0.875rem',
    medium: '1rem',
    large: '1.25rem',
    xlarge: '1.5rem',
    xxlarge: '2rem',
  },
  spacing: {
    xs: '0.25rem',
    sm: '0.5rem',
    md: '1rem',
    lg: '1.5rem',
    xl: '2rem',
    xxl: '3rem',
  },
  borderRadius: '4px',
  boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
  transition: '0.3s ease',
  breakpoints: {
    sm: '576px',
    md: '768px',
    lg: '992px',
    xl: '1200px',
  },
};
 
// design-system/src/theme/GlobalStyles.js
import { createGlobalStyle } from 'styled-components';
import { theme } from './index';
 
export const GlobalStyles = createGlobalStyle`
  * {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
 
  body {
    font-family: ${theme.fontFamily};
    color: ${theme.colors.text};
    background-color: ${theme.colors.background};
    line-height: 1.5;
  }
 
  a {
    color: ${theme.colors.primary};
    text-decoration: none;
 
    &:hover {
      text-decoration: underline;
    }
  }
 
  /* Additional global styles */
`;

4. Export Components and Theme:

// design-system/src/index.js
// Components
export { Button } from './components/Button';
export { Card } from './components/Card';
export { Input } from './components/Input';
export { Typography } from './components/Typography';
// ... other components
 
// Theme
export { theme } from './theme';
export { GlobalStyles } from './theme/GlobalStyles';
export { ThemeProvider } from 'styled-components';
 
// Hooks
export { useMediaQuery } from './hooks/useMediaQuery';
// ... other hooks
 
// Utils
export { formatCurrency } from './utils/formatters';
// ... other utilities

5. Use the Design System in the Container App:

// container/src/App.js
import React from 'react';
import { ThemeProvider, GlobalStyles } from '@company/design-system';
 
export default function App() {
  return (
    <ThemeProvider>
      <GlobalStyles />
      {/* Microfrontends */}
    </ThemeProvider>
  );
}

6. Use in Microfrontends:

// product-catalog/src/components/ProductCard.js
import React from 'react';
import { Card, Typography, Button, formatCurrency } from '@company/design-system';
 
export default function ProductCard({ product, onAddToCart }) {
  return (
    <Card>
      <img src={product.image} alt={product.name} />
      <Typography variant="h3">{product.name}</Typography>
      <Typography variant="body">{product.description}</Typography>
      <Typography variant="h4">{formatCurrency(product.price)}</Typography>
      <Button onClick={() => onAddToCart(product)}>Add to Cart</Button>
    </Card>
  );
}

Additional Strategies for Consistent Design

  1. Design Tokens: Extract design decisions into tokens (like colors, spacing) that can be shared across teams.

  2. Storybook Documentation: Document your design system with Storybook to provide examples and usage guidelines.

npx sb init --type react
  1. Visual Regression Testing: Implement visual tests to catch unintended styling changes.
// Example with jest-image-snapshot
import { toMatchImageSnapshot } from 'jest-image-snapshot';
expect.extend({ toMatchImageSnapshot });
 
it('renders button correctly', async () => {
  const image = await page.screenshot();
  expect(image).toMatchImageSnapshot();
});
  1. Design System Team: For larger organizations, consider a dedicated team to maintain the design system.

Challenge 2: State Management Across Microfrontends

Managing state across independent microfrontends is one of the most complex challenges. Each microfrontend needs to function independently but also share data with others.

The Problem

  • State duplication across microfrontends
  • Inconsistent state due to asynchronous updates
  • Difficulty implementing features that span multiple microfrontends
  • Performance issues from inefficient state synchronization

Solution 1: Shared State via Context

As we saw in our case study, one approach is to expose a global state context from the container application:

// container/src/context/GlobalState.js
import React, { createContext, useContext, useReducer } from 'react';
 
// Create context
const GlobalStateContext = createContext();
 
// Initial state
const initialState = {
  user: null,
  cart: [],
  theme: 'light',
};
 
// Reducer function
function reducer(state, action) {
  switch (action.type) {
    case 'SET_USER':
      return { ...state, user: action.payload };
    case 'ADD_TO_CART':
      return { ...state, cart: [...state.cart, action.payload] };
    case 'REMOVE_FROM_CART':
      return {
        ...state,
        cart: state.cart.filter(item => item.id !== action.payload),
      };
    case 'CLEAR_CART':
      return { ...state, cart: [] };
    case 'SET_THEME':
      return { ...state, theme: action.payload };
    default:
      return state;
  }
}
 
// Provider component
export const GlobalStateProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
 
  return (
    <GlobalStateContext.Provider value={{ state, dispatch }}>
      {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;
};
 
// Action creators (optional but helpful)
export const actions = {
  setUser: (user) => ({ type: 'SET_USER', payload: user }),
  addToCart: (product) => ({ type: 'ADD_TO_CART', payload: product }),
  removeFromCart: (productId) => ({ type: 'REMOVE_FROM_CART', payload: productId }),
  clearCart: () => ({ type: 'CLEAR_CART' }),
  setTheme: (theme) => ({ type: 'SET_THEME', payload: theme }),
};
// In container app
import { GlobalStateProvider } from './context/GlobalState';
 
function App() {
  return (
    <GlobalStateProvider>
      {/* Microfrontends */}
    </GlobalStateProvider>
  );
}
 
// In a microfrontend
import { useGlobalState, actions } from 'container/GlobalState';
 
function ProductCard({ product }) {
  const { state, dispatch } = useGlobalState();
  
  const handleAddToCart = () => {
    dispatch(actions.addToCart(product));
  };
  
  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={handleAddToCart}>Add to Cart</button>
    </div>
  );
}

Solution 2: Event-Based Communication

Another approach is to use a pub/sub pattern for communication between microfrontends:

// Create a simple event bus in the container
// container/src/services/EventBus.js
class EventBus {
  constructor() {
    this.listeners = {};
  }
 
  on(event, callback) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event].push(callback);
    
    // Return an unsubscribe function
    return () => {
      this.listeners[event] = this.listeners[event].filter(
        listener => listener !== callback
      );
    };
  }
 
  emit(event, data) {
    if (this.listeners[event]) {
      this.listeners[event].forEach(callback => callback(data));
    }
  }
}
 
export default new EventBus();
// In container, expose the event bus
// webpack.config.js
new ModuleFederationPlugin({
  name: "container",
  exposes: {
    "./EventBus": "./src/services/EventBus",
  },
  // ...
});
 
// In a microfrontend (publisher)
import EventBus from 'container/EventBus';
 
function ProductCard({ product }) {
  const handleAddToCart = () => {
    EventBus.emit('PRODUCT_ADDED_TO_CART', product);
  };
  
  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={handleAddToCart}>Add to Cart</button>
    </div>
  );
}
 
// In another microfrontend (subscriber)
import React, { useEffect, useState } from 'react';
import EventBus from 'container/EventBus';
 
function CartIndicator() {
  const [cartItems, setCartItems] = useState([]);
  
  useEffect(() => {
    const unsubscribe = EventBus.on('PRODUCT_ADDED_TO_CART', (product) => {
      setCartItems(prevItems => [...prevItems, product]);
    });
    
    // Clean up the subscription when component unmounts
    return unsubscribe;
  }, []);
  
  return <div>Cart Items: {cartItems.length}</div>;
}

Solution 3: State Management Libraries

For more complex state management, you can use libraries like Redux, MobX, or Zustand with Module Federation:

// container/src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import { userReducer } from './slices/userSlice';
import { cartReducer } from './slices/cartSlice';
import { themeReducer } from './slices/themeSlice';
 
export const createStore = () => {
  return configureStore({
    reducer: {
      user: userReducer,
      cart: cartReducer,
      theme: themeReducer,
    },
  });
};
 
export const store = createStore();
// container/src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
 
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);
// Expose store in webpack.config.js
new ModuleFederationPlugin({
  name: "container",
  exposes: {
    "./store": "./src/store",
  },
  // ...
});
 
// In a microfrontend
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { addToCart } from 'container/store/slices/cartSlice';
 
function ProductCard({ product }) {
  const dispatch = useDispatch();
  const cartItems = useSelector(state => state.cart.items);
  
  const handleAddToCart = () => {
    dispatch(addToCart(product));
  };
  
  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={handleAddToCart}>Add to Cart</button>
    </div>
  );
}

Solution 4: Backend State Synchronization

For truly independent microfrontends, consider synchronizing state through backend services:

// In one microfrontend
const addToCart = async (product) => {
  try {
    await fetch('/api/cart/add', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ productId: product.id, quantity: 1 }),
    });
    
    // Optionally notify other microfrontends via EventBus
    EventBus.emit('CART_UPDATED');
  } catch (error) {
    console.error('Failed to add to cart:', error);
  }
};
 
// In another microfrontend
const [cartItems, setCartItems] = useState([]);
 
const fetchCartItems = async () => {
  try {
    const response = await fetch('/api/cart');
    const data = await response.json();
    setCartItems(data.items);
  } catch (error) {
    console.error('Failed to fetch cart items:', error);
  }
};
 
useEffect(() => {
  fetchCartItems();
  
  // Listen for cart updates from other microfrontends
  const unsubscribe = EventBus.on('CART_UPDATED', fetchCartItems);
  return unsubscribe;
}, []);

Challenge 3: Navigation and Routing

Coordinating navigation between microfrontends can be complex, especially when different teams own different routes and navigation components.

The Problem

  • Route conflicts between microfrontends
  • Handling nested routes within a microfrontend
  • Maintaining browser history across microfrontend boundaries
  • Implementing route-based code splitting efficiently

Solution 1: Shell-Based Routing

In this approach, the container application controls the primary routing:

// container/src/App.js
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
 
const ProductCatalog = lazy(() => import('productCatalog/ProductCatalog'));
const ProductDetails = lazy(() => import('productDetails/ProductDetails'));
const ShoppingCart = lazy(() => import('shoppingCart/ShoppingCart'));
const UserAccount = lazy(() => import('userAccount/UserAccount'));
 
export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading...</div>}>
        <Switch>
          <Route exact path="/" component={ProductCatalog} />
          <Route path="/product/:id" component={ProductDetails} />
          <Route path="/cart" component={ShoppingCart} />
          <Route path="/account/*" component={UserAccount} />
        </Switch>
      </Suspense>
    </BrowserRouter>
  );
}

In this model, each microfrontend can handle its own sub-routes:

// userAccount/src/UserAccount.js
import React from 'react';
import { Switch, Route, useRouteMatch } from 'react-router-dom';
import Profile from './components/Profile';
import Orders from './components/Orders';
import Settings from './components/Settings';
 
export default function UserAccount() {
  const { path } = useRouteMatch();
  
  return (
    <div className="user-account">
      <h1>My Account</h1>
      <nav>
        {/* Note that these links use the parent path */}
        <Link to={`${path}`}>Profile</Link>
        <Link to={`${path}/orders`}>Orders</Link>
        <Link to={`${path}/settings`}>Settings</Link>
      </nav>
      
      <Switch>
        <Route exact path={path} component={Profile} />
        <Route path={`${path}/orders`} component={Orders} />
        <Route path={`${path}/settings`} component={Settings} />
      </Switch>
    </div>
  );
}

Solution 2: Shared Router Instance

Another approach is to create a shared history object in the container and expose it to all microfrontends:

// container/src/services/history.js
import { createBrowserHistory } from 'history';
export const history = createBrowserHistory();
 
// container/src/App.js
import React from 'react';
import { Router } from 'react-router-dom';
import { history } from './services/history';
 
export default function App() {
  return (
    <Router history={history}>
      {/* Routes and microfrontends */}
    </Router>
  );
}
 
// Expose history in webpack.config.js
new ModuleFederationPlugin({
  name: "container",
  exposes: {
    "./history": "./src/services/history",
  },
  // ...
});
 
// In a microfrontend
import { history } from 'container/history';
 
function navigateToProduct(productId) {
  history.push(`/product/${productId}`);
}

Solution 3: Route Registration Pattern

For more dynamic routing, microfrontends can register their routes with the container:

// container/src/services/RouteRegistry.js
class RouteRegistry {
  constructor() {
    this.routes = [];
    this.listeners = [];
  }
  
  registerRoutes(newRoutes) {
    this.routes = [...this.routes, ...newRoutes];
    this.notifyListeners();
    
    // Return function to unregister routes
    return () => {
      this.routes = this.routes.filter(
        route => !newRoutes.includes(route)
      );
      this.notifyListeners();
    };
  }
  
  subscribeToRoutes(listener) {
    this.listeners.push(listener);
    listener(this.routes);
    
    return () => {
      this.listeners = this.listeners.filter(l => l !== listener);
    };
  }
  
  notifyListeners() {
    this.listeners.forEach(listener => listener(this.routes));
  }
}
 
export default new RouteRegistry();
 
// container/src/App.js
import React, { useState, useEffect } from 'react';
import { BrowserRouter, Switch } from 'react-router-dom';
import RouteRegistry from './services/RouteRegistry';
 
export default function App() {
  const [routes, setRoutes] = useState([]);
  
  useEffect(() => {
    const unsubscribe = RouteRegistry.subscribeToRoutes(setRoutes);
    return unsubscribe;
  }, []);
  
  return (
    <BrowserRouter>
      <Switch>
        {routes.map((route, index) => (
          <Route key={index} {...route} />
        ))}
      </Switch>
    </BrowserRouter>
  );
}
 
// In a microfrontend
import React, { useEffect } from 'react';
import RouteRegistry from 'container/RouteRegistry';
import ProductList from './components/ProductList';
import ProductDetail from './components/ProductDetail';
 
export default function ProductApp() {
  useEffect(() => {
    const routes = [
      {
        path: '/products',
        exact: true,
        component: ProductList
      },
      {
        path: '/products/:id',
        component: ProductDetail
      }
    ];
    
    const unregister = RouteRegistry.registerRoutes(routes);
    return unregister;
  }, []);
  
  return null; // This component doesn't render anything itself
}

Challenge 4: Performance Optimization

Microfrontends can lead to performance issues if not implemented carefully. Multiple JavaScript bundles, duplicate dependencies, and inefficient loading can impact user experience.

The Problem

  • Excessive JavaScript bundle sizes
  • Duplicate dependencies across microfrontends
  • Too many concurrent HTTP requests
  • Poor loading and rendering performance

Solution 1: Shared Dependencies

Use Module Federation's shared dependencies feature to avoid duplicates:

// webpack.config.js
new ModuleFederationPlugin({
  name: "container",
  // ...
  shared: {
    react: { 
      singleton: true, 
      requiredVersion: "^17.0.0",
      eager: true
    },
    "react-dom": { 
      singleton: true, 
      requiredVersion: "^17.0.0",
      eager: true
    },
    "react-router-dom": { 
      singleton: true, 
      requiredVersion: "^5.2.0" 
    },
    "@company/design-system": { 
      singleton: true,
      requiredVersion: "^1.0.0"
    },
    // Other shared dependencies
  },
});

The eager: true flag loads the shared dependency with the container, ensuring it's available immediately rather than loaded asynchronously.

Solution 2: Intelligent Code Splitting

Apply thoughtful code splitting within each microfrontend:

// product-details/src/ProductDetails.js
import React, { lazy, Suspense } from 'react';
import ProductBasicInfo from './components/ProductBasicInfo';
 
// These components are only loaded when needed
const ProductReviews = lazy(() => import('./components/ProductReviews'));
const RelatedProducts = lazy(() => import('./components/RelatedProducts'));
const ProductVideos = lazy(() => import('./components/ProductVideos'));
 
export default function ProductDetails({ productId }) {
  const [activeTab, setActiveTab] = useState('info');
  
  return (
    <div className="product-details">
      <ProductBasicInfo productId={productId} />
      
      <div className="product-tabs">
        <button 
          className={activeTab === 'info' ? 'active' : ''} 
          onClick={() => setActiveTab('info')}
        >
          Product Info
        </button>
        <button 
          className={activeTab === 'reviews' ? 'active' : ''} 
          onClick={() => setActiveTab('reviews')}
        >
          Reviews
        </button>
        <button 
          className={activeTab === 'related' ? 'active' : ''} 
          onClick={() => setActiveTab('related')}
        >
          Related Products
        </button>
        <button 
          className={activeTab === 'videos' ? 'active' : ''} 
          onClick={() => setActiveTab('videos')}
        >
          Videos
        </button>
      </div>
      
      <div className="tab-content">
        <Suspense fallback={<div>Loading...</div>}>
          {activeTab === 'reviews' && <ProductReviews productId={productId} />}
          {activeTab === 'related' && <RelatedProducts productId={productId} />}
          {activeTab === 'videos' && <ProductVideos productId={productId} />}
        </Suspense>
      </div>
    </div>
  );
}

Solution 3: Progressive Loading Strategies

Implement priority-based loading to improve perceived performance:

// container/src/App.js
import React, { lazy, Suspense } from 'react';
 
// Critical microfrontends - load immediately
const Header = lazy(() => import('header/Header'));
const Footer = lazy(() => import('footer/Footer'));
 
// Important but not critical - load with slight delay
const ProductCatalog = lazy(() => {
  // Small delay to prioritize critical components
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(import('productCatalog/ProductCatalog'));
    }, 100);
  });
});
 
// Lower priority - load after critical content is visible
const RecommendedProducts = lazy(() => {
  // Load after the main content
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(import('recommendations/RecommendedProducts'));
    }, 500);
  });
});
 
export default function App() {
  return (
    <div className="app">
      <Suspense fallback={<SimpleHeader />}>
        <Header />
      </Suspense>
      
      <main>
        <Suspense fallback={<ProductCatalogSkeleton />}>
          <ProductCatalog />
        </Suspense>
        
        <Suspense fallback={<div>Loading recommendations...</div>}>
          <RecommendedProducts />
        </Suspense>
      </main>
      
      <Suspense fallback={<SimpleFooter />}>
        <Footer />
      </Suspense>
    </div>
  );
}

Solution 4: Optimized Asset Loading

Use modern loading techniques to improve performance:

<!-- In index.html -->
<head>
  <!-- Preload critical resources -->
  <link rel="preload" href="https://cdn.example.com/fonts/roboto.woff2" as="font" type="font/woff2" crossorigin>
  <link rel="preload" href="/remoteEntry.js" as="script">
  
  <!-- Prefetch likely-to-be-needed resources -->
  <link rel="prefetch" href="https://product-catalog.example.com/remoteEntry.js">
  
  <!-- Use resource hints for third-party domains -->
  <link rel="dns-prefetch" href="https://api.example.com">
  <link rel="preconnect" href="https://cdn.example.com">
</head>
// In components, use IntersectionObserver to load resources when needed
import React, { useEffect, useRef, useState } from 'react';
 
function LazyLoadedSection({ importFunc, fallback }) {
  const [Component, setComponent] = useState(null);
  const ref = useRef(null);
  
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        // When the element is about to enter the viewport
        if (entry.isIntersecting) {
          // Load the component
          importFunc().then(module => {
            setComponent(() => module.default);
          });
          // Stop observing after loading
          observer.disconnect();
        }
      },
      { rootMargin: '200px' } // Start loading when within 200px of viewport
    );
    
    if (ref.current) {
      observer.observe(ref.current);
    }
    
    return () => observer.disconnect();
  }, [importFunc]);
  
  return (
    <div ref={ref}>
      {Component ? <Component /> : fallback}
    </div>
  );
}
 
// Usage
<LazyLoadedSection 
  importFunc={() => import('./HeavyComponent')}
  fallback={<div>Loading...</div>}
/>

Challenge 5: Authentication and Authorization

Managing user authentication consistently across microfrontends is essential for security and user experience.

The Problem

  • Maintaining consistent auth state across microfrontends
  • Securing routes and features based on user permissions
  • Handling token renewal and session management
  • Preventing duplicate login/logout flows

Solution 1: Centralized Auth Provider

Implement authentication in the container application and share it via context:

// container/src/context/AuthContext.js
import React, { createContext, useContext, useState, useEffect } from 'react';
import authService from '../services/authService';
 
const AuthContext = createContext();
 
export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  // Check for existing session on load
  useEffect(() => {
    const initAuth = async () => {
      try {
        const user = await authService.getCurrentUser();
        setUser(user);
      } catch (error) {
        console.error('Failed to restore auth session:', error);
        setError(error);
      } finally {
        setLoading(false);
      }
    };
    
    initAuth();
  }, []);
  
  // Set up token refresh interval
  useEffect(() => {
    if (!user) return;
    
    const refreshInterval = setInterval(async () => {
      try {
        await authService.refreshToken();
      } catch (error) {
        console.error('Token refresh failed:', error);
        // Force logout if refresh fails
        handleLogout();
      }
    }, 15 * 60 * 1000); // Refresh every 15 minutes
    
    return () => clearInterval(refreshInterval);
  }, [user]);
  
  const handleLogin = async (credentials) => {
    setLoading(true);
    setError(null);
    
    try {
      const user = await authService.login(credentials);
      setUser(user);
      return user;
    } catch (error) {
      setError(error);
      throw error;
    } finally {
      setLoading(false);
    }
  };
  
  const handleLogout = async () => {
    try {
      await authService.logout();
    } catch (error) {
      console.error('Logout error:', error);
    } finally {
      setUser(null);
    }
  };
  
  const value = {
    user,
    loading,
    error,
    login: handleLogin,
    logout: handleLogout,
    isAuthenticated: !!user,
  };
  
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
};
 
export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};

Expose the auth provider in the container:

// container/src/App.js
import { AuthProvider } from './context/AuthContext';
 
function App() {
  return (
    <AuthProvider>
      {/* The rest of your app */}
    </AuthProvider>
  );
}
 
// Expose in webpack.config.js
new ModuleFederationPlugin({
  name: "container",
  exposes: {
    "./auth": "./src/context/AuthContext",
  },
  // ...
});

Use the auth context in microfrontends:

// In a microfrontend
import { useAuth } from 'container/auth';
 
function ProtectedFeature() {
  const { user, isAuthenticated, login, logout } = useAuth();
  
  if (!isAuthenticated) {
    return (
      <div>
        <h2>Please log in to access this feature</h2>
        <LoginForm onSubmit={login} />
      </div>
    );
  }
  
  return (
    <div>
      <h2>Welcome, {user.name}!</h2>
      <button onClick={logout}>Log out</button>
      {/* Protected content */}
    </div>
  );
}

Solution 2: Protected Route HOC

Create a higher-order component for route protection:

// container/src/components/ProtectedRoute.js
import React from 'react';
import { Route, Redirect } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
 
export default function ProtectedRoute({
  component: Component,
  requiredPermission,
  ...rest
}) {
  const { isAuthenticated, user, loading } = useAuth();
  
  if (loading) {
    return <div>Loading authentication...</div>;
  }
  
  return (
    <Route
      {...rest}
      render={(props) => {
        // Check if user is authenticated
        if (!isAuthenticated) {
          return (
            <Redirect
              to={{
                pathname: '/login',
                state: { from: props.location },
              }}
            />
          );
        }
        
        // Check for specific permission if required
        if (requiredPermission && 
            (!user.permissions || !user.permissions.includes(requiredPermission))) {
          return <div>You don't have permission to access this page.</div>;
        }
        
        // If authenticated and authorized, render the component
        return <Component {...props} />;
      }}
    />
  );
}
 
// Usage in container
<ProtectedRoute
  path="/account"
  component={UserAccount}
  requiredPermission="account:view"
/>

Solution 3: Token Management with Storage

For some applications, managing authentication tokens in storage (with proper security considerations) can be a simpler approach:

// container/src/services/authService.js
class AuthService {
  constructor() {
    this.tokenKey = 'auth_token';
    this.userKey = 'auth_user';
  }
  
  async login(credentials) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(credentials),
    });
    
    if (!response.ok) {
      throw new Error('Login failed');
    }
    
    const data = await response.json();
    
    // Save token and user data
    localStorage.setItem(this.tokenKey, data.token);
    localStorage.setItem(this.userKey, JSON.stringify(data.user));
    
    // Broadcast auth change event for other microfrontends
    window.dispatchEvent(new Event('auth_changed'));
    
    return data.user;
  }
  
  logout() {
    localStorage.removeItem(this.tokenKey);
    localStorage.removeItem(this.userKey);
    
    // Broadcast auth change event
    window.dispatchEvent(new Event('auth_changed'));
  }
  
  getCurrentUser() {
    const userJson = localStorage.getItem(this.userKey);
    return userJson ? JSON.parse(userJson) : null;
  }
  
  getToken() {
    return localStorage.getItem(this.tokenKey);
  }
  
  isAuthenticated() {
    return !!this.getToken();
  }
  
  async refreshToken() {
    const token = this.getToken();
    if (!token) return null;
    
    const response = await fetch('/api/auth/refresh', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`,
      },
    });
    
    if (!response.ok) {
      this.logout();
      throw new Error('Token refresh failed');
    }
    
    const data = await response.json();
    localStorage.setItem(this.tokenKey, data.token);
    
    return data.token;
  }
}
 
export default new AuthService();
// In a microfrontend
import React, { useState, useEffect } from 'react';
 
function AuthAwareComponent() {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  
  useEffect(() => {
    // Check initial auth state
    const checkAuth = () => {
      const token = localStorage.getItem('auth_token');
      setIsAuthenticated(!!token);
    };
    
    // Set up listener for auth changes
    window.addEventListener('auth_changed', checkAuth);
    
    // Check initial state
    checkAuth();
    
    // Clean up
    return () => {
      window.removeEventListener('auth_changed', checkAuth);
    };
  }, []);
  
  return (
    <div>
      {isAuthenticated ? (
        <p>User is logged in</p>
      ) : (
        <p>User is not logged in</p>
      )}
    </div>
  );
}

Challenge 6: Error Handling and Resilience

In a microfrontend architecture, a failure in one microfrontend should not crash the entire application.

The Problem

  • Errors in one microfrontend can cascade to others
  • Poor error recovery and fallback UI
  • No isolation between microfrontends
  • Difficulty diagnosing errors that cross microfrontend boundaries

Solution 1: Error Boundaries

Use React's Error Boundaries to catch errors in microfrontends:

// container/src/components/ErrorBoundary.js
import React from 'react';
 
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }
 
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
 
  componentDidCatch(error, errorInfo) {
    // Log the error to an error reporting service
    console.error('Error caught by boundary:', error, errorInfo);
    
    if (this.props.onError) {
      this.props.onError(error, errorInfo);
    }
  }
 
  reset = () => {
    this.setState({ hasError: false, error: null });
  };
 
  render() {
    if (this.state.hasError) {
      if (this.props.fallback) {
        return typeof this.props.fallback === 'function'
          ? this.props.fallback(this.state.error, this.reset)
          : this.props.fallback;
      }
      
      return (
        <div className="error-container">
          <h2>Something went wrong</h2>
          <p>We're sorry, but there was an error loading this component.</p>
          <button onClick={this.reset}>Try Again</button>
        </div>
      );
    }
 
    return this.props.children;
  }
}
 
export default ErrorBoundary;

Wrap each microfrontend with an error boundary in the container:

// container/src/App.js
import React, { lazy, Suspense } from 'react';
import ErrorBoundary from './components/ErrorBoundary';
 
const ProductCatalog = lazy(() => import('productCatalog/ProductCatalog'));
const ProductDetails = lazy(() => import('productDetails/ProductDetails'));
 
function App() {
  return (
    <div>
      <ErrorBoundary 
        fallback={(error, reset) => (
          <div>
            <h2>Product Catalog Error</h2>
            <p>There was a problem loading the product catalog.</p>
            <button onClick={reset}>Retry</button>
          </div>
        )}
      >
        <Suspense fallback={<div>Loading catalog...</div>}>
          <ProductCatalog />
        </Suspense>
      </ErrorBoundary>
      
      <ErrorBoundary
        fallback={<div>Failed to load product details</div>}
      >
        <Suspense fallback={<div>Loading details...</div>}>
          <ProductDetails />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Solution 2: Circuit Breaker Pattern

Implement a circuit breaker to prevent continued attempts to load failing microfrontends:

// container/src/hooks/useCircuitBreaker.js
import { useState, useEffect } from 'react';
 
export default function useCircuitBreaker(loadComponent, options = {}) {
  const {
    maxFailures = 3,
    resetTimeout = 30000, // 30 seconds
    fallback = null,
  } = options;
  
  const [component, setComponent] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [failures, setFailures] = useState(0);
  const [circuitOpen, setCircuitOpen] = useState(false);
  
  useEffect(() => {
    // If circuit is open, don't attempt to load
    if (circuitOpen) return;
    
    let isMounted = true;
    setLoading(true);
    
    loadComponent()
      .then(moduleOrComponent => {
        if (!isMounted) return;
        // Get the default export if it's a module
        const Component = moduleOrComponent.default || moduleOrComponent;
        setComponent(() => Component);
        setLoading(false);
        // Reset failures on success
        setFailures(0);
      })
      .catch(err => {
        if (!isMounted) return;
        
        setError(err);
        setLoading(false);
        
        // Increment failure count
        const newFailures = failures + 1;
        setFailures(newFailures);
        
        // Open the circuit if max failures reached
        if (newFailures >= maxFailures) {
          setCircuitOpen(true);
          
          // Set a timeout to close the circuit
          setTimeout(() => {
            if (isMounted) {
              setCircuitOpen(false);
              setFailures(0);
            }
          }, resetTimeout);
        }
      });
    
    return () => {
      isMounted = false;
    };
  }, [loadComponent, failures, circuitOpen, maxFailures, resetTimeout]);
  
  // Return an object with the component and state
  return {
    Component: component,
    loading,
    error,
    circuitOpen,
    retry: () => {
      setFailures(0);
      setCircuitOpen(false);
      setLoading(true);
    },
  };
}
// Usage in container
import useCircuitBreaker from './hooks/useCircuitBreaker';
 
function MicrofrontendLoader({ name, importFunc, fallback }) {
  const {
    Component,
    loading,
    error,
    circuitOpen,
    retry
  } = useCircuitBreaker(() => importFunc(), {
    maxFailures: 3,
    resetTimeout: 60000, // 1 minute
  });
  
  if (loading) {
    return <div>Loading {name}...</div>;
  }
  
  if (circuitOpen) {
    return (
      <div className="circuit-breaker-fallback">
        <h2>{name} is currently unavailable</h2>
        <p>We're experiencing issues loading this feature.</p>
        <button onClick={retry}>Try Again</button>
        {fallback && <div className="fallback-content">{fallback}</div>}
      </div>
    );
  }
  
  if (error) {
    return (
      <div className="error-fallback">
        <h2>Error loading {name}</h2>
        <button onClick={retry}>Retry</button>
      </div>
    );
  }
  
  return <Component />;
}
 
// In App.js
<MicrofrontendLoader
  name="Product Catalog"
  importFunc={() => import('productCatalog/ProductCatalog')}
  fallback={<StaticProductList />}
/>

Solution 3: Health Checks

Implement health checks to detect issues before loading microfrontends:

// container/src/services/healthCheck.js
class HealthCheckService {
  async checkHealth(microfrontendUrl) {
    try {
      // Append health endpoint to the microfrontend URL
      const healthUrl = new URL('/health', microfrontendUrl).toString();
      
      const response = await fetch(healthUrl, {
        method: 'GET',
        headers: {
          'Accept': 'application/json',
        },
        // Short timeout to avoid long waits
        signal: AbortSignal.timeout(3000),
      });
      
      if (!response.ok) {
        return { healthy: false, error: `Status: ${response.status}` };
      }
      
      const data = await response.json();
      return { healthy: data.status === 'ok', data };
    } catch (error) {
      return { healthy: false, error: error.message };
    }
  }
  
  // Check multiple microfrontends at once
  async checkAll(microfrontends) {
    const results = {};
    
    const checks = microfrontends.map(async mf => {
      results[mf.name] = await this.checkHealth(mf.url);
    });
    
    await Promise.allSettled(checks);
    return results;
  }
}
 
export default new HealthCheckService();
// In container app
import { useEffect, useState } from 'react';
import healthCheckService from './services/healthCheck';
 
function App() {
  const [healthStatus, setHealthStatus] = useState({});
  
  useEffect(() => {
    const microfrontends = [
      { name: 'productCatalog', url: 'http://localhost:3001' },
      { name: 'productDetails', url: 'http://localhost:3002' },
      { name: 'shoppingCart', url: 'http://localhost:3003' },
    ];
    
    const checkHealth = async () => {
      const results = await healthCheckService.checkAll(microfrontends);
      setHealthStatus(results);
    };
    
    // Check immediately on load
    checkHealth();
    
    // Then check periodically
    const interval = setInterval(checkHealth, 60000); // Every minute
    
    return () => clearInterval(interval);
  }, []);
  
  // Render microfrontends based on health status
  return (
    <div>
      {healthStatus.productCatalog?.healthy ? (
        <Suspense fallback={<div>Loading catalog...</div>}>
          <ProductCatalog />
        </Suspense>
      ) : (
        <ProductCatalogFallback />
      )}
      
      {/* Other microfrontends */}
    </div>
  );
}

Challenge 7: Deployment and Release Coordination

Coordinating deployments across multiple independently developed microfrontends can be complex.

The Problem

  • Version compatibility issues between container and microfrontends
  • Handling breaking changes in shared interfaces
  • Coordinating releases for features that span multiple microfrontends
  • Testing in staging environments with multiple deployment pipelines

Solution 1: Versioned URLs

Use versioned URLs for microfrontends to ensure compatibility:

// container/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`,
    // Other remotes
  },
  // ...
});

This approach ensures that the container always loads a compatible version of each microfrontend, even as newer versions are deployed.

Solution 2: Environment-Based Configuration

Use environment variables to configure microfrontend URLs based on environment:

// container/webpack.config.js
const { 
  PRODUCT_CATALOG_URL, 
  PRODUCT_DETAILS_URL,
  SHOPPING_CART_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`,
        shoppingCart: `shoppingCart@${SHOPPING_CART_URL}/remoteEntry.js`,
      },
      // ...
    }),
  ],
};
# GitHub Actions workflow example
name: Deploy Container
 
on:
  push:
    branches:
      - main
    paths:
      - 'container/**'
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      
      - name: Configure environment
        run: |
          if [[ $GITHUB_REF == refs/heads/main ]]; then
            echo "Setting production environment variables"
            echo "PRODUCT_CATALOG_URL=https://catalog.example.com" >> $GITHUB_ENV
            echo "PRODUCT_DETAILS_URL=https://details.example.com" >> $GITHUB_ENV
            echo "SHOPPING_CART_URL=https://cart.example.com" >> $GITHUB_ENV
          else
            echo "Setting staging environment variables"
            echo "PRODUCT_CATALOG_URL=https://staging-catalog.example.com" >> $GITHUB_ENV
            echo "PRODUCT_DETAILS_URL=https://staging-details.example.com" >> $GITHUB_ENV
            echo "SHOPPING_CART_URL=https://staging-cart.example.com" >> $GITHUB_ENV
          fi
      
      - name: Build
        run: |
          cd container
          npm ci
          npm run build
        env:
          PRODUCT_CATALOG_URL: ${{ env.PRODUCT_CATALOG_URL }}
          PRODUCT_DETAILS_URL: ${{ env.PRODUCT_DETAILS_URL }}
          SHOPPING_CART_URL: ${{ env.SHOPPING_CART_URL }}
      
      # Deployment steps...

Solution 3: Runtime URL Configuration

Load microfrontend URLs at runtime for more flexibility:

// container/src/config.js
const environments = {
  production: {
    productCatalog: 'https://catalog.example.com',
    productDetails: 'https://details.example.com',
    shoppingCart: 'https://cart.example.com',
  },
  staging: {
    productCatalog: 'https://staging-catalog.example.com',
    productDetails: 'https://staging-details.example.com',
    shoppingCart: 'https://staging-cart.example.com',
  },
  development: {
    productCatalog: 'http://localhost:3001',
    productDetails: 'http://localhost:3002',
    shoppingCart: 'http://localhost:3003',
  },
};
 
// Determine environment from hostname or env var
function getEnvironment() {
  const hostname = window.location.hostname;
  
  if (hostname === 'localhost') {
    return 'development';
  } else if (hostname.includes('staging')) {
    return 'staging';
  } else {
    return 'production';
  }
}
 
// Generate microfrontend URLs
export function getMicrofrontendUrls() {
  const env = getEnvironment();
  const urls = environments[env];
  
  // Return URLs with remoteEntry.js path
  return Object.entries(urls).reduce((acc, [name, url]) => {
    acc[name] = `${url}/remoteEntry.js`;
    return acc;
  }, {});
}
// container/src/bootstrap.js
import { getMicrofrontendUrls } from './config';
 
// Dynamically load microfrontends
async function loadMicrofrontend(name, url) {
  // Create script element for the remote entry
  const script = document.createElement('script');
  script.src = url;
  script.async = true;
  
  // Wait for script to load
  await new Promise((resolve, reject) => {
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
  
  // Access the global variable created by the remote entry
  // @ts-ignore
  const container = window[name];
  
  // Initialize the container
  await container.init(__webpack_share_scopes__.default);
  
  return container;
}
 
async function initApp() {
  const urls = getMicrofrontendUrls();
  
  try {
    // Load microfrontends
    const productCatalog = await loadMicrofrontend('productCatalog', urls.productCatalog);
    const ProductCatalogComponent = await productCatalog.get('./ProductCatalog');
    
    // Load other microfrontends...
    
    // Render the app once all microfrontends are loaded
    const root = document.getElementById('root');
    ReactDOM.render(<App />, root);
  } catch (error) {
    console.error('Failed to load microfrontends:', error);
    // Show error UI
  }
}
 
initApp();

Solution 4: Feature Flags

Use feature flags to coordinate the release of features that span multiple microfrontends:

// container/src/services/featureFlags.js
class FeatureFlagService {
  constructor() {
    this.flags = {};
    this.listeners = [];
  }
  
  async initialize() {
    try {
      const response = await fetch('/api/feature-flags');
      const data = await response.json();
      this.flags = data;
      this.notifyListeners();
    } catch (error) {
      console.error('Failed to load feature flags:', error);
      // Default to conservative values (features off)
      this.flags = {};
    }
  }
  
  isEnabled(flagName) {
    return !!this.flags[flagName];
  }
  
  subscribe(listener) {
    this.listeners.push(listener);
    listener(this.flags);
    
    return () => {
      this.listeners = this.listeners.filter(l => l !== listener);
    };
  }
  
  notifyListeners() {
    this.listeners.forEach(listener => listener(this.flags));
  }
}
 
export default new FeatureFlagService();
// container/src/hooks/useFeatureFlag.js
import { useState, useEffect } from 'react';
import featureFlagService from '../services/featureFlags';
 
export function useFeatureFlag(flagName, defaultValue = false) {
  const [isEnabled, setIsEnabled] = useState(
    () => featureFlagService.isEnabled(flagName) ?? defaultValue
  );
  
  useEffect(() => {
    return featureFlagService.subscribe(flags => {
      setIsEnabled(flags[flagName] ?? defaultValue);
    });
  }, [flagName, defaultValue]);
  
  return isEnabled;
}
// Usage in components
import { useFeatureFlag } from '../hooks/useFeatureFlag';
 
function NewFeature() {
  const isEnabled = useFeatureFlag('new-checkout-flow', false);
  
  if (!isEnabled) {
    return null; // Or return old version
  }
  
  return (
    <div>
      {/* New feature implementation */}
    </div>
  );
}

Conclusion

Building a successful microfrontend architecture requires careful consideration of numerous challenges. In this article, we've explored solutions to the most common issues:

  1. Consistent Styling and Design: Implement a shared design system to ensure visual consistency.

  2. State Management: Choose the right state sharing approach based on your needs, from context-based solutions to event-driven patterns.

  3. Navigation and Routing: Coordinate routing through shell-based routing, shared router instances, or dynamic route registration.

  4. Performance Optimization: Optimize bundle sizes, share dependencies, and implement intelligent loading strategies.

  5. Authentication and Authorization: Centralize auth logic while allowing microfrontends to respond appropriately.

  6. Error Handling and Resilience: Use error boundaries, circuit breakers, and health checks to build resilient applications.

  7. Deployment and Release Coordination: Use versioned URLs, environment configuration, and feature flags to coordinate releases.

With these solutions in your toolkit, you'll be well-equipped to build robust, scalable microfrontend architectures that enable team autonomy while providing a seamless user experience.

In the final part of our series, we'll look at testing, monitoring, and debugging microfrontend applications, as well as exploring advanced patterns for specific use cases.

A Question to Ponder

As you implement solutions to these microfrontend challenges, consider this:

In our quest for team independence through microfrontends, how do we balance the autonomy of individual teams with the need for a cohesive product experience and development standards?

This tension between autonomy and cohesion is at the heart of microfrontend architecture. Finding the right balance for your organization is key to success.

Stay tuned for Part 5, where we'll explore testing, monitoring, and advanced patterns for microfrontends.