Breaking the Frontend Monolith: A Practical Guide to Module Federation

SS Saurav Sitaula

Our React e-commerce app took 12 minutes to build, and teams were constantly stepping on each other's toes. We needed boundaries. Here is how we broke our monolith into microfrontends using Webpack 5 Module Federation, with a simple, practical example you can actually understand.

The Monolith Problem

Three years into a major React project, the cracks started showing.

Our e-commerce platform was housed in a single, massive repository. It contained the product catalog, the user dashboard, the checkout flow, the search engine, and the cart logic. It was a classic Frontend Monolith.

The symptoms were painful:

  1. The build took forever. A 12-minute Webpack compilation just to test a button color change in the cart.
  2. The bundle was massive. Users downloading a 1.5MB JavaScript payload on a 3G connection were abandoning the site before it even rendered.
  3. Merge conflict hell. Multiple teams working in the same codebase meant daily arguments over package.json updates and conflicting routes.

I thought the solution was simply aggressive code-splitting. We lazy-loaded routes. We dynamic-imported heavy libraries. It helped the initial load time, but the fundamental problem remained: everything was still compiled together at build time.

Then we discovered Webpack 5 and a feature called Module Federation.

What is Module Federation?

Imagine you have two separate React applications. HostApp (the main shell of your website) needs a <ProductCard /> component from ProductApp.

Before Module Federation, you’d either:

  • Duplicate the <ProductCard /> code in both apps.
  • Extract the component into an npm package, publish it, and npm install it in HostApp. Every time ProductApp updates the card, HostApp has to bump the version, rebuild, and redeploy.

Module Federation lets HostApp dynamically load the <ProductCard /> component from ProductApp at runtime. ProductApp updates the card and deploys. HostApp automatically gets the new version on the next page refresh. No npm packages. No rebuilding HostApp.

It sounded like magic. We decided to use it to rebuild our monolith into a fleet of microfrontends.

A Practical Example: The E-commerce Split

Let’s break down how you actually configure this. We decided to split our monolith into three distinct applications:

  1. host-app: The main layout (header, footer, routing shell).
  2. product-app: Handles everything related to displaying products.
  3. cart-app: Handles the shopping cart drawer and checkout logic.

Each of these is a completely separate repository with its own package.json and its own Webpack configuration.

1. Exposing the Product App (The Remote)

The product-app’s job is to expose components that other apps can use. In its webpack.config.js, we use the ModuleFederationPlugin to declare what it’s sharing with the world:

// product-app/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  // ... other webpack config ...
  plugins: [
    new ModuleFederationPlugin({
      name: "productApp", // The global name of this microfrontend
      filename: "remoteEntry.js", // The file Webpack will generate
      exposes: {
        // We are exposing the ProductList component
        "./ProductList": "./src/components/ProductList",
      },
      shared: {
        // We tell Webpack: "If the host already has React, use theirs. Don't download it twice."
        react: { singleton: true, requiredVersion: "^18.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
      },
    }),
  ],
};

When we build product-app, Webpack generates a special file called remoteEntry.js. This is a tiny manifest file that tells other apps how to fetch the exposed ./ProductList code.

2. Consuming the Product App (The Host)

Now, let’s look at the host-app. This is the application the user actually visits in their browser.

In the host’s webpack.config.js, we tell it where to find the product-app:

// host-app/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "hostApp",
      remotes: {
        // Tell the host where to find "productApp"
        // (Assuming product-app is running locally on port 3001)
        productApp: "productApp@http://localhost:3001/remoteEntry.js",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^18.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
      },
    }),
  ],
};

3. Rendering the Remote Component

With the Webpack configs wired up, using the remote component in our host-app’s React code is shockingly simple. We just use standard React lazy loading:

// host-app/src/App.jsx
import React, { Suspense } from 'react';

// Dynamically import the component from the remote app!
const RemoteProductList = React.lazy(() => import('productApp/ProductList'));

function App() {
  return (
    <div>
      <h1>My Awesome Store</h1>
      
      {/* We need Suspense because the code is fetched over the network at runtime */}
      <Suspense fallback={<div>Loading products...</div>}>
        <RemoteProductList />
      </Suspense>
    </div>
  );
}

export default App;

When a user visits the site, the browser fetches the entry JavaScript for the host-app. When React reaches the <Suspense> boundary, it pauses, reaches out to http://localhost:3001/remoteEntry.js, downloads the code for the ProductList component, and renders it.

The Magic of Shared Dependencies

The biggest fear with microfrontends is duplication. If I have a host app, a product app, and a cart app all on the same page, does the user download React three times?

No. Look back at the Webpack configs. Both apps have this block:

shared: {
  react: { singleton: true, requiredVersion: "^18.0.0" },
  "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
}

The singleton: true flag is the magic. When the host-app loads, it initializes its copy of React. When it fetches the ProductList from the remote product-app, the remote asks the host: “Hey, do you already have React loaded?”

The host says: “Yes, I have version 18.2.0.”

The remote says: “Great, I won’t download my copy. I’ll just use yours.”

This means our feature bundles shrank to almost nothing. The product-app bundle only contains the business logic for displaying products. All the heavy lifting—React, ReactDOM, styled-components—is parsed once and shared in browser memory.

What I Wish I’d Known Earlier

  1. Shared state across remotes is tricky. If two microfrontends accidentally load different versions of React, context providers break. The singleton: true rule is not optional—it is required to keep React working properly across boundaries.
  2. Webpack configurations become your life. Module Federation is powerful, but it requires deep Webpack knowledge. You aren’t just writing React anymore; you’re writing infrastructure.
  3. Top-level async boundaries. In the host app, your main entry point (usually index.js) must use a dynamic import('./bootstrap') pattern to give Webpack time to negotiate shared dependencies before evaluating the rest of the application code.

Breaking the monolith wasn’t just a technical upgrade; it changed how we organized our teams. We finally had real boundaries. The Product team could work on their microfrontend without caring what the Cart team was doing.

But with independent applications came new challenges: how do you deploy a remote app without breaking the host? And how do you manage versioning across different environments? That’s exactly what we had to solve next.


P.S. — The first time I saw a component update in a host application without triggering a rebuild of that host, I literally refreshed the page three times because I didn’t believe it. Runtime integration feels like you’re cheating the system.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism