Micro-Frontends: Architecture and Implementation

Micro-frontends extend microservice architecture principles to the browser. Instead of a monolithic single-page application, you split the frontend into smaller, independently deployable units owned...

Key Insights

  • Micro-frontends enable independent deployment and team autonomy but introduce complexity in shared dependencies, routing, and state management that monolithic SPAs avoid
  • Webpack Module Federation has become the de facto standard for runtime integration, allowing dynamic loading of remote modules with intelligent dependency sharing that prevents duplication
  • Only adopt micro-frontends when you have multiple teams working on distinct business domains—the architectural overhead isn’t justified for small teams or tightly coupled features

Introduction to Micro-Frontends

Micro-frontends extend microservice architecture principles to the browser. Instead of a monolithic single-page application, you split the frontend into smaller, independently deployable units owned by different teams. Each team controls a complete vertical slice—from database to UI—for their business domain.

The core promise is organizational scalability. When you have eight teams fighting over the same codebase, merge conflicts and coordination overhead kill velocity. Micro-frontends let each team ship independently using their preferred framework version, testing strategy, and deployment schedule.

But this isn’t free. You’re trading monolith complexity for distributed system complexity. You’ll deal with version mismatches, duplicate dependencies, cross-app communication, and deployment orchestration. Use this pattern when team independence justifies the overhead—typically organizations with 5+ frontend teams working on clearly separated domains.

Core Architecture Patterns

There are four main approaches to composing micro-frontends, each with distinct trade-offs.

Build-time integration packages micro-frontends as npm libraries. The shell application imports them at compile time. This is the simplest approach but defeats the purpose—you’ve created a distributed monolith that requires coordinated deployments.

Server-side composition assembles HTML fragments on the server using technologies like Edge Side Includes (ESI) or Tailor. This provides excellent initial load performance and works without JavaScript, but limits interactivity and complicates state management.

Client-side composition loads micro-frontends in the browser at runtime. This is the most flexible approach, supporting independent deployments and framework diversity. The downside is coordination complexity and potential performance impact.

Edge-side composition uses CDN edge workers to assemble applications closer to users. This combines SSR benefits with independent deployment but requires edge computing infrastructure.

Most production implementations use client-side composition with Module Federation or iframe isolation:

// Webpack Module Federation - Modern approach
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'checkout',
      filename: 'remoteEntry.js',
      exposes: {
        './CheckoutApp': './src/CheckoutApp'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

// Iframe approach - Maximum isolation
<iframe 
  src="https://checkout.example.com" 
  sandbox="allow-scripts allow-same-origin"
/>

Module Federation provides better UX and easier integration. Iframes offer stronger isolation but complicate routing, styling, and communication.

Implementation with Module Federation

Webpack 5’s Module Federation is the current best practice for runtime integration. It allows applications to dynamically load code from other independently deployed applications while intelligently sharing dependencies.

Here’s a complete implementation with a host shell and a remote product catalog:

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

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        catalog: 'catalog@http://localhost:3001/remoteEntry.js',
        checkout: 'checkout@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 }
      }
    })
  ]
};

// apps/catalog/webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'catalog',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductList': './src/components/ProductList',
        './ProductDetail': './src/components/ProductDetail'
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
      }
    })
  ]
};

The shell application dynamically imports remote components:

// apps/shell/src/App.jsx
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const ProductList = lazy(() => import('catalog/ProductList'));
const ProductDetail = lazy(() => import('catalog/ProductDetail'));
const CheckoutApp = lazy(() => import('checkout/CheckoutApp'));

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/products" element={<ProductList />} />
          <Route path="/products/:id" element={<ProductDetail />} />
          <Route path="/checkout/*" element={<CheckoutApp />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

The shared configuration ensures React is loaded once despite being a dependency of multiple micro-frontends. The singleton: true flag enforces this, preventing runtime errors from version mismatches.

Routing and Navigation

Routing across micro-frontends requires coordination. Each micro-frontend might have internal routes, but you need a top-level router that delegates to the appropriate application.

Single-spa provides a battle-tested solution:

// apps/shell/src/index.js
import { registerApplication, start } from 'single-spa';

registerApplication({
  name: 'catalog',
  app: () => import('catalog/ProductList'),
  activeWhen: ['/products']
});

registerApplication({
  name: 'checkout',
  app: () => import('checkout/CheckoutApp'),
  activeWhen: ['/checkout']
});

start();

For custom routing, implement a router orchestrator:

// apps/shell/src/routing/AppRouter.jsx
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';

const routeConfig = {
  '/products': { app: 'catalog', component: 'ProductList' },
  '/checkout': { app: 'checkout', component: 'CheckoutApp' }
};

export function AppRouter() {
  const location = useLocation();
  const [ActiveComponent, setActiveComponent] = useState(null);

  useEffect(() => {
    const route = Object.keys(routeConfig).find(path => 
      location.pathname.startsWith(path)
    );
    
    if (route) {
      const { app, component } = routeConfig[route];
      import(`${app}/${component}`).then(module => {
        setActiveComponent(() => module.default);
      });
    }
  }, [location.pathname]);

  return ActiveComponent ? <ActiveComponent /> : null;
}

Always use a single instance of the router library (via Module Federation shared config) to prevent history management conflicts.

Shared State and Communication

Micro-frontends should minimize shared state, but some communication is inevitable. Here are the primary patterns:

Custom events provide loose coupling:

// Event bus implementation
class EventBus {
  constructor() {
    this.events = {};
  }

  publish(event, data) {
    if (!this.events[event]) return;
    this.events[event].forEach(callback => callback(data));
  }

  subscribe(event, callback) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event].push(callback);
    
    return () => {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    };
  }
}

export const eventBus = new EventBus();

// In catalog micro-frontend
import { eventBus } from '@shared/event-bus';

function ProductDetail({ productId }) {
  const handleAddToCart = (product) => {
    eventBus.publish('cart:item-added', product);
  };
}

// In checkout micro-frontend
useEffect(() => {
  const unsubscribe = eventBus.subscribe('cart:item-added', (product) => {
    setCartItems(items => [...items, product]);
  });
  return unsubscribe;
}, []);

Shared Redux store works when micro-frontends use the same state management:

// apps/shared/src/store.js
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './slices/cartSlice';

let store;

export function getStore() {
  if (!store) {
    store = configureStore({
      reducer: {
        cart: cartReducer
      }
    });
  }
  return store;
}

// In each micro-frontend
import { Provider } from 'react-redux';
import { getStore } from '@shared/store';

export default function App() {
  return (
    <Provider store={getStore()}>
      {/* app content */}
    </Provider>
  );
}

Prefer events over shared stores. Events maintain independence; shared stores create coupling.

Deployment and CI/CD Considerations

Independent deployment is the primary benefit of micro-frontends. Each team should deploy without coordinating with others.

# .github/workflows/deploy-catalog.yml
name: Deploy Catalog
on:
  push:
    branches: [main]
    paths:
      - 'apps/catalog/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Build
        run: |
          cd apps/catalog
          npm ci
          npm run build
                    
      - name: Version
        id: version
        run: |
          VERSION=$(date +%Y%m%d-%H%M%S)-${GITHUB_SHA::7}
          echo "version=$VERSION" >> $GITHUB_OUTPUT
                    
      - name: Deploy to S3
        run: |
          aws s3 sync ./apps/catalog/dist \
            s3://cdn.example.com/catalog/${{ steps.version.outputs.version }}
                    
      - name: Update version manifest
        run: |
          echo '{"version":"${{ steps.version.outputs.version }}"}' | \
            aws s3 cp - s3://cdn.example.com/catalog/latest.json          

The shell application fetches version manifests to load the latest remote:

async function loadRemoteEntry(scope, url) {
  const manifest = await fetch(`${url}/latest.json`).then(r => r.json());
  const script = document.createElement('script');
  script.src = `${url}/${manifest.version}/remoteEntry.js`;
  document.head.appendChild(script);
  
  return new Promise((resolve, reject) => {
    script.onload = () => resolve(window[scope]);
    script.onerror = reject;
  });
}

This enables rollbacks by updating the manifest without redeploying code.

Trade-offs and Best Practices

Micro-frontends aren’t a universal solution. The architecture makes sense when:

  • You have 5+ teams working on distinct business domains
  • Teams need different release schedules
  • You’re migrating from a legacy monolith incrementally
  • Different parts of your app have vastly different scaling needs

Don’t use micro-frontends when:

  • You have a small team (under 10 developers)
  • Your features are tightly coupled
  • Performance is critical (e-commerce checkout flows)
  • You’re building a greenfield MVP

Performance considerations: Every remote adds network overhead and parsing time. Measure your bundle sizes aggressively. Use Module Federation’s shared dependencies to prevent loading React five times. Consider server-side rendering for initial load performance.

Dependency management: Lock shared dependency versions strictly. A micro-frontend using React 18 can’t safely share context with one using React 17. Use semantic versioning ranges cautiously—^18.0.0 might seem safe until 18.3 breaks your app.

Team organization: Align micro-frontends with team ownership, not technical layers. The “catalog team” owns product browsing end-to-end, not a “product list component team.”

Testing strategy: Test micro-frontends in isolation and integration. Each should have comprehensive unit tests, but you also need end-to-end tests that exercise cross-app interactions.

Micro-frontends solve organizational problems, not technical ones. If your challenge is coordinating multiple teams with conflicting priorities, this architecture can help. If you’re trying to make your code “more modular,” stick with a well-structured monolith.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.