Banner Background

Building Scalable Micro-Frontends with Module Federation

Learn how to build and scale micro-frontend architectures using Webpack Module Federation, sharing code efficiently across independent applications.

Introduction

Micro-frontends have revolutionized how we build large-scale web applications. By breaking down monolithic frontends into smaller, manageable pieces, teams can work independently, deploy faster, and scale more effectively.

What are Micro-Frontends?

Micro-frontends extend the concepts of microservices to the frontend world. Each team can develop, test, and deploy their features independently while maintaining a cohesive user experience.

Why Module Federation?

Webpack's Module Federation is a game-changer for micro-frontend architectures. It allows JavaScript applications to dynamically load code from another application at runtime.

Key Benefits

  1. Runtime Integration - Load remote modules on demand
  2. Code Sharing - Share dependencies between applications
  3. Independent Deployments - Deploy micro-frontends independently
  4. Version Management - Handle multiple versions gracefully
  5. Performance - Optimize bundle sizes automatically

Consider Your Team Size

Micro-frontends add complexity. They're best suited for large teams working on complex applications. For smaller projects, a monolithic approach might be more efficient.

Architecture Overview

Let's explore a typical micro-frontend architecture using Module Federation.

Host Application

The host (or shell) application orchestrates the micro-frontends. It provides:

  • Shared layout and navigation
  • Authentication and authorization
  • Shared state management
  • Common UI components

Remote Applications

Remote applications are independent micro-frontends that:

  • Own specific feature domains
  • Have their own build pipeline
  • Can be deployed separately
  • Expose components/modules to the host

Shared Dependencies

Configure shared libraries to avoid duplication:

  • React/Vue/Angular
  • UI component libraries
  • Utility libraries
  • State management tools

Implementation Guide

Project Structure

webpack.config.js
bootstrap.js
index.js

Configuration

host/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        productApp: 'productApp@http://localhost:3001/remoteEntry.js',
        cartApp: 'cartApp@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        'react-router-dom': { singleton: true },
      },
    }),
  ],
};
remote-product/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'productApp',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductList': './src/ProductList',
        './ProductDetail': './src/ProductDetail',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};
host/src/types/remotes.d.ts
declare module 'productApp/ProductList' {
  const ProductList: React.ComponentType;
  export default ProductList;
}

declare module 'productApp/ProductDetail' {
  const ProductDetail: React.ComponentType<{ id: string }>;
  export default ProductDetail;
}

declare module 'cartApp/Cart' {
  const Cart: React.ComponentType;
  export default Cart;
}

Loading Remote Components

Dynamic Imports

App.tsx
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Dynamic imports for remote components
const ProductList = lazy(() => import('productApp/ProductList'));
const ProductDetail = lazy(() => import('productApp/ProductDetail'));
const Cart = lazy(() => import('cartApp/Cart'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/products" element={<ProductList />} />
          <Route path="/products/:id" element={<ProductDetail />} />
          <Route path="/cart" element={<Cart />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}
ErrorBoundary.tsx
import React, { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
}

class MicroFrontendErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('Micro-frontend failed to load:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <div>Failed to load module</div>;
    }

    return this.props.children;
  }
}

export default MicroFrontendErrorBoundary;
LoadingFallback.tsx
export const LoadingSpinner = () => (
  <div className="flex items-center justify-center min-h-screen">
    <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
  </div>
);

export const ModuleLoader = () => (
  <div className="p-8 text-center">
    <div className="mb-4">
      <LoadingSpinner />
    </div>
    <p className="text-gray-600">Loading module...</p>
  </div>
);

Error Handling Best Practice

Always wrap remote components in error boundaries. Network issues or deployment mismatches can cause runtime errors when loading remote modules.

State Management Strategies

Managing shared state across micro-frontends requires careful planning:

Browser Events

Use custom events for loose coupling:

// Publishing events
const event = new CustomEvent('cart:updated', {
  detail: { itemCount: 5 }
});
window.dispatchEvent(event);

// Subscribing to events
window.addEventListener('cart:updated', (e) => {
  console.log('Cart updated:', e.detail);
});

Shared Store

For tightly coupled state, use a shared store:

// shared/store.ts
import create from 'zustand';

export const useGlobalStore = create((set) => ({
  user: null,
  setUser: (user) => set({ user }),
  cart: [],
  addToCart: (item) => set((state) => ({ 
    cart: [...state.cart, item] 
  })),
}));

Props Drilling

For simple parent-child communication:

<RemoteComponent 
  user={currentUser}
  onUpdate={handleUpdate}
/>

Deployment Strategies

Independent Deployment Pipeline

Build and Test

Each micro-frontend has its own CI/CD pipeline:

  • Run unit tests
  • Run integration tests
  • Build production bundle
  • Run E2E tests

Version and Deploy

Deploy with versioning strategy:

  • Semantic versioning for remotes
  • Update remote URLs in host
  • Canary deployments supported
  • Easy rollbacks

Monitor and Observe

Track performance and errors:

  • Module loading times
  • Failed chunk loading
  • Error rates per micro-frontend
  • User experience metrics

Version Compatibility

Test version compatibility carefully. A breaking change in a shared dependency can affect all micro-frontends. Consider using dependency version ranges wisely.

Performance Optimization

Code Splitting Strategies

// Split by routes - load only what's needed
const routes = [
  {
    path: '/products',
    component: lazy(() => import('productApp/ProductPage')),
  },
  {
    path: '/cart',
    component: lazy(() => import('cartApp/CartPage')),
  },
];
// Split heavy components separately
const HeavyChart = lazy(() => import('./HeavyChart'));
const DataTable = lazy(() => import('./DataTable'));

function Dashboard() {
  return (
    <div>
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart data={chartData} />
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <DataTable data={tableData} />
      </Suspense>
    </div>
  );
}
// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
  },
};

Shared Dependencies

Optimize what you share to reduce bundle duplication:

shared: {
  react: { 
    singleton: true, 
    eager: false,
    requiredVersion: '^18.0.0'
  },
  'react-dom': { 
    singleton: true, 
    eager: false,
    requiredVersion: '^18.0.0'
  },
  // Share only what's necessary
  'lodash': {
    singleton: false, // Allow multiple versions if needed
    requiredVersion: false,
  },
}

Eager vs Lazy Loading

Use eager: true for critical shared modules that are always needed. Use eager: false (default) for modules loaded on demand to optimize initial bundle size.

Common Challenges & Solutions

Challenge 1: Styling Conflicts

Problem: CSS from different micro-frontends can conflict.

Solutions:

  • Use CSS Modules or CSS-in-JS
  • Adopt BEM naming convention
  • Implement Shadow DOM
  • Use scoped styles with frameworks

Challenge 2: Shared Dependencies Version Mismatch

Problem: Different micro-frontends need different library versions.

Solutions:

  • Establish version governance
  • Use singleton: false when necessary
  • Implement graceful fallbacks
  • Regular dependency updates

Challenge 3: Authentication and Authorization

Problem: Managing auth state across micro-frontends.

Solutions:

  • Centralized auth in host app
  • Share auth context via Module Federation
  • Use JWT tokens in localStorage
  • Implement refresh token rotation

Testing Strategies

Unit Testing

Test components in isolation:

npm test -- ProductList.test.tsx

Integration Testing

Test micro-frontend integration:

// Test remote loading
test('loads product micro-frontend', async () => {
  render(<App />);
  await waitFor(() => {
    expect(screen.getByText('Products')).toBeInTheDocument();
  });
});

End-to-End Testing

Test complete user flows:

// Cypress E2E test
describe('Purchase Flow', () => {
  it('completes purchase across micro-frontends', () => {
    cy.visit('/products');
    cy.get('[data-testid="product-1"]').click();
    cy.get('[data-testid="add-to-cart"]').click();
    cy.visit('/cart');
    cy.get('[data-testid="checkout"]').click();
  });
});

Best Practices

  1. Keep Micro-Frontends Small - Each should have a clear, focused responsibility
  2. Minimize Coupling - Reduce dependencies between micro-frontends
  3. Share Strategically - Only share what's truly common
  4. Version Everything - Use semantic versioning for all remotes
  5. Monitor Performance - Track loading times and bundle sizes
  6. Document APIs - Clear contracts between micro-frontends
  7. Automate Testing - Comprehensive test coverage across boundaries
  8. Plan for Failure - Error boundaries and fallbacks everywhere

Start Small

Begin with a pilot project. Convert one feature to a micro-frontend, learn from the experience, then scale the approach across your application.

Conclusion

Micro-frontends with Module Federation offer a powerful architecture for scaling large applications. While they introduce complexity, the benefits of independent deployments, team autonomy, and improved scalability make them worthwhile for the right use cases.

Remember: micro-frontends are a tool, not a goal. Evaluate whether your team size and application complexity justify the additional overhead.

Resources


Have questions about implementing micro-frontends? Let's discuss in the comments below!