Skip to main content
  1. Frontends/
  2. React Guides/

Scaling React: The Ultimate Guide to Micro-Frontends with Module Federation

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

The era of the monolithic frontend is officially behind us. If you are managing a React application in 2025 with more than three distinct teams contributing to the same codebase, you’ve likely hit the “wall.” Build times degrade, regression testing becomes a nightmare, and coordinating releases feels like air traffic control during a storm.

Enter Micro-Frontends.

While the concept isn’t new, the tooling has matured significantly. We aren’t hacking together iframes or dealing with complex build-time integration anymore. We are using Module Federation, a capability fundamentally baked into Webpack (and now Rspack) that allows JavaScript applications to dynamically load code from another application—at runtime.

This isn’t just about splitting code; it’s about splitting organizational complexity. In this deep dive, we are going to architect a scalable Micro-Frontend system using React and Module Federation. We’ll look at the setup, the “gotchas” that documentation often skips, and how to handle shared state without creating a distributed mess.

Why Module Federation Wins in 2025
#

Before we touch the code, let’s clarify why we are choosing this specific architecture. For years, we had two bad options:

  1. NPM Packages: Versioning hell. You update a button component, and every consumer app needs a rebuild and redeploy.
  2. Iframes: Total isolation, but terrible UX, accessibility issues, and context switching overhead.

Module Federation sits in the “Goldilocks” zone. It allows independent deployments (like iframes) but shares a runtime environment (like a monolith).

The Architecture Visualization
#

Here is how the browser orchestrates the pieces. We have a Host (Shell) and a Remote (e.g., a Dashboard Widget).

sequenceDiagram participant User participant Browser participant HostServer as Host Server (Shell) participant RemoteServer as Remote Server (Widget) User->>Browser: Access Application Browser->>HostServer: Request index.html / main.js HostServer-->>Browser: Returns Host Bundle Note over Browser: Host App Initializes Browser->>RemoteServer: Request remoteEntry.js (Manifest) RemoteServer-->>Browser: Returns remoteEntry.js Note over Browser: Webpack Runtime Resolution Browser->>RemoteServer: Request Widget Chunk (src_Widget_js) RemoteServer-->>Browser: Returns Component Code Note over Browser: React Hydrates/Renders Remote Component

Prerequisites and Environment
#

To follow this implementation, you need to be comfortable with advanced React patterns and Webpack configuration.

Development Environment:

  • Node.js: v20.x or higher (LTS).
  • Package Manager: pnpm (highly recommended for workspaces) or yarn.
  • React: v18.3 or v19.
  • Webpack: v5.

We will simulate a monorepo structure for this tutorial, though in a real-world scenario, these would likely live in separate repositories and deploy pipelines.

Step 1: The Workspace Structure
#

Let’s set up a clean workspace. We need a “Shell” (the main container) and a “Shop” (a remote domain domain).

mkdir micro-fe-demo
cd micro-fe-demo
npm init -y

Update your root package.json to handle workspaces:

{
  "name": "micro-fe-demo",
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}

Create the folders:

mkdir -p packages/host
mkdir -p packages/shop

Step 2: Configuring the Remote Application (“Shop”)
#

The “Shop” application is a standalone React app, but it exposes a module to the world.

Navigate to packages/shop and initialize it. You’ll need the standard React dependencies plus Webpack.

Installation (run inside packages/shop):

npm init -y
npm install react react-dom
npm install -D webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/core @babel/preset-react

The Component to Expose
#

Create a simple product list component at src/ProductList.js:

import React from 'react';

const ProductList = () => {
  const products = [
    { id: 1, name: 'Ergonomic Keyboard', price: '$150' },
    { id: 2, name: '4K Monitor', price: '$400' },
    { id: 3, name: 'Standing Desk', price: '$600' }
  ];

  return (
    <div style={{ border: '1px solid #e1e1e1', padding: '1rem', borderRadius: '8px' }}>
      <h3 style={{ marginTop: 0 }}>Shop Products (Remote)</h3>
      <ul style={{ paddingLeft: '20px' }}>
        {products.map(p => (
          <li key={p.id} style={{ marginBottom: '0.5rem' }}>
            <strong>{p.name}</strong> - {p.price}
          </li>
        ))}
      </ul>
      <small style={{ color: '#666' }}>Bundle: Shop Remote</small>
    </div>
  );
};

export default ProductList;

The Webpack Config (The Magic)
#

This is where Module Federation happens. Create webpack.config.js:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');

module.exports = {
  entry: './src/index.js',
  mode: 'development',
  devServer: {
    static: path.join(__dirname, 'dist'),
    port: 3001, // Remote runs on port 3001
    historyApiFallback: true,
  },
  output: {
    publicPath: 'auto', // Critical for MF to find assets
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['@babel/preset-react'],
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'shop', // The unique name of this container
      filename: 'remoteEntry.js', // The manifest file name
      exposes: {
        './ProductList': './src/ProductList', // Key: public path, Value: local path
      },
      shared: {
        react: { 
          singleton: true, // Only one copy of React
          requiredVersion: false, 
          eager: false 
        },
        'react-dom': { 
          singleton: true, 
          requiredVersion: false,
          eager: false
        },
      },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
};

Architectural Note: Notice singleton: true. React acts poorly if there are two instances of the Virtual DOM library running on the same page (context issues, hook errors). Singleton forces the remote to use the Host’s React instance if versions are compatible.

The Async Boundary (Bootstrap Pattern)
#

This is the number one trap developers fall into. When utilizing shared dependencies like React, Webpack needs time to negotiate which version to use before the application boots.

If you import App.js directly in index.js, Webpack executes immediately, likely crashing because the shared React instance isn’t ready.

The Fix:

  1. Create src/bootstrap.js:
    import React from 'react';
    import { createRoot } from 'react-dom/client';
    import App from './App';
    
    const root = createRoot(document.getElementById('root'));
    root.render(<App />);
  2. Update src/index.js to be an asynchronous import:
    import('./bootstrap');

This gives Webpack the “tick” it needs to handle the handshake for shared modules.

Step 3: Configuring the Host Application (“Shell”)
#

Now, let’s build the container that consumes the Shop. Move to packages/host and install the same dependencies.

The Host Webpack Config
#

The config is similar, but instead of exposes, we use remotes.

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');

module.exports = {
  entry: './src/index.js',
  mode: 'development',
  devServer: {
    static: path.join(__dirname, 'dist'),
    port: 3000, // Host runs on port 3000
    historyApiFallback: true,
  },
  output: {
    publicPath: 'auto',
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['@babel/preset-react'],
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        // 'shop' refers to the `name` defined in the remote's config
        // 'shop@...' points to the URL of the remoteEntry.js
        shop: 'shop@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, eager: true, requiredVersion: false },
        'react-dom': { singleton: true, eager: true, requiredVersion: false },
      },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
};

Note on eager: true: In the Host app, we often mark shared libs as eager because this is the app initializing the page. If the host waits for React to negotiate, nothing renders.

Consuming the Remote
#

In the Host application, we treat the remote module as a standard asynchronous component. We use React.lazy and Suspense.

File: packages/host/src/App.js

import React, { Suspense } from 'react';

// The import path matches the 'remotes' key in webpack.config.js
// plus the 'exposes' key from the remote config.
const RemoteProductList = React.lazy(() => import('shop/ProductList'));

const App = () => {
  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', maxWidth: '800px', margin: '2rem auto' }}>
      <header style={{ background: '#222', color: '#fff', padding: '1rem', borderRadius: '8px' }}>
        <h1>Main Dashboard (Host)</h1>
      </header>

      <main style={{ marginTop: '2rem' }}>
        <p>This is the host application. Below, we load a component from port 3001.</p>
        
        <div style={{ marginTop: '2rem' }}>
          {/* Error Boundaries are mandatory for Micro-Frontends */}
          <ErrorBoundary>
            <Suspense fallback={<div style={{padding: '20px', background: '#f0f0f0'}}>Loading Shop...</div>}>
              <RemoteProductList />
            </Suspense>
          </ErrorBoundary>
        </div>
      </main>
    </div>
  );
};

// Simple Error Boundary implementation
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

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

  componentDidCatch(error, errorInfo) {
    console.error("Federation Error:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <div style={{color: 'red', border: '1px dashed red', padding: '1rem'}}>
        ⚠️ The Shop module is currently unavailable.
      </div>;
    }
    return this.props.children; 
  }
}

export default App;

Don’t forget the bootstrap.js pattern in the Host as well, just to be safe and consistent, though eager: true usually mitigates the need for it on the critical path.

The Challenge of State Management
#

One of the most heated debates in the Micro-Frontend community is: How do we share state?

If your Host app has a “Cart” count in the header, and the Remote “Shop” app has an “Add to Cart” button, they need to communicate.

Approaches Comparison
#

Strategy Pros Cons Best For
Window Custom Events Zero dependencies, highly decoupled. No strict typing, global scope pollution. Simple triggers (e.g., “Item Added”).
Shared Context/Redux “Feels” like a monolith, full reactivity. High coupling. Host and Remote must share exact library versions. Tightly coupled fragments.
URL Parameters The “Web” way. Deep linkable. Limited payload size. Routing state, Filters.
RxJS / Observable Powerful, decoupled reactivity. Learning curve, extra bundle size. Complex event streams.

Recommended: The Custom Hook Event Bus #

For 90% of use cases, a lightweight event listener is superior to sharing a Redux store. It keeps the bundle boundaries clean.

In the Host (Shell):

// A simple custom event listener
useEffect(() => {
  const handleAddToCart = (e) => {
    setCartCount(prev => prev + e.detail.qty);
  };
  
  window.addEventListener('SHOP_ADD_TO_CART', handleAddToCart);
  return () => window.removeEventListener('SHOP_ADD_TO_CART', handleAddToCart);
}, []);

In the Remote (Shop):

const addToCart = () => {
  const event = new CustomEvent('SHOP_ADD_TO_CART', { 
    detail: { productId: 1, qty: 1 } 
  });
  window.dispatchEvent(event);
};

This ensures that if the Host changes its state management library from Redux to Zustand, the Remote app doesn’t care. It just fires an event.

Production Considerations and Pitfalls
#

You’ve got it running on localhost. Now you need to deploy. This is where the abstraction leaks if you aren’t careful.

1. The URL Problem
#

In webpack.config.js, we hardcoded shop@http://localhost:3001/remoteEntry.js. In production, this URL is dynamic.

Solution: External Remotes or “Promise-based Remotes.” Instead of a string, you can inject a script tag dynamically at runtime to load the remote config.

// webpack.config.js (Host)
remotes: {
  shop: `promise new Promise(resolve => {
    // Logic to fetch the URL from a config service or env variable
    const remoteUrl = window.MY_ENV_CONFIG.SHOP_URL + '/remoteEntry.js';
    const script = document.createElement('script');
    script.src = remoteUrl;
    script.onload = () => {
      const proxy = {
        get: (request) => window.shop.get(request),
        init: (arg) => {
          try {
            return window.shop.init(arg);
          } catch(e) {
            console.log('Remote container already initialized');
          }
        }
      }
      resolve(proxy);
    }
    document.head.appendChild(script);
  })`
}

2. CSS Collision
#

Since both apps load into the same document, global CSS (.button { color: red }) in the Remote will bleed into the Host.

Solution:

  • CSS Modules: (Recommended) Scopes styles automatically.
  • CSS-in-JS: (Styled Components/Emotion) ensure unique class names.
  • Shadow DOM: Provides hard isolation, but breaks React events (retargeting) and global modals. Use with caution.

3. Caching (The “Forever Old” Bug)
#

If you deploy a new version of the “Shop” remote, but the user’s browser has cached remoteEntry.js, they will try to load old chunks that might no longer exist on the server (404 errors).

Solution: Cache-busting the manifest. Configure your web server (Nginx/CloudFront/Vercel) to never cache remoteEntry.js.

  • remoteEntry.js: Cache-Control: no-cache, no-store, must-revalidate
  • src_Widget_js_chunk.js (hashed files): Cache-Control: public, max-age=31536000

Conclusion
#

Module Federation has shifted the paradigm. We are no longer building applications; we are building systems of applications.

By decoupling your “Shop” logic from your “Shell” logic, you allow the Checkout Team to deploy on Tuesday without breaking the Marketing Homepage deployed on Friday.

However, complexity doesn’t disappear; it moves. It moves from code complexity to infrastructure complexity. You now have to manage multiple CI/CD pipelines, coordinate shared dependencies, and handle runtime failures gracefully.

Key Takeaways:

  • Use the Bootstrap Pattern (import('./bootstrap')) to handle shared dependencies asynchronously.
  • Wrap remote components in Error Boundaries.
  • Prefer Loosely Coupled Events over Shared State for communication.
  • Never cache remoteEntry.js in production.

This architecture isn’t for a blog or a simple CRUD app. But if you are scaling enterprise software in 2025, this is the blueprint for survival.


Further Reading
#