Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Micro Frontends with Module Federation in Monorepo

Large codes base and teams that work on them, face an additional set of challenges unknown or unidentified as an issue in smaller setups. Despite many of those challenges verge on project management, there's a set of intrinsic technical issues with the scale of the project. Slowly but steadily, your CI/CD pipeline is getting longer, your development build is not as fast as it used to be, fans are spooling up when IDE starts indexing, and so on.

If that's your reality, you can pat yourself on the back because you're part of the team working on a successful product. All that to say, you probably don't need to bother with a more complex setup if you're just starting your project and considering micro frontends. Right now, your time is better invested elsewhere.

The core idea behind micro frontends is that each team can work independently of each other on a part of the application that they are responsible for. This is supposed to mitigate many of the challenges that come with large codebases. Independent doesn't mean that now teams can be collectively irresponsible. Building one application using a dozen of different frameworks that your users have to download, and your organization has to maintain is still a bad idea. Unfortunately, this is how sometimes micro frontends are portrayed, but this is not what this blog post is about.

The goal here is to create a setup in which multiple independent teams can develop a single application, test and deploy each package separately. All this, while ripping the benefits of coherence that comes from working in a single repository, sharing configuration files, and keeping dependencies up-to-date and in sync.

Checkout the example on GitHub which contains all the code covered here that you can run and experiment with. Use it as a reference for your own setup.

Structure of the monorepo

Let's start with the basic structure of the monorepo, so you have a better understanding of where different pieces fall into place.

Monorepo structure

Each independent part of the application is a package that can be developed by an autonomous team.

The shell package has a special role as an entrypoint for the entire application. It a backbone that provides the root index.html file, loads micro frontends, and, in this example, also defines top-level routing. The shell is always loaded, so it's bundling shared dependencies required by each application.

The ds package is where the design system lives and is an example of a shared package that isn't a micro frontend, but a module, a typical library package from which you can statically import. This is why it doesn't need a build step, and so doesn't have a webpack config.

The packages billing and products are examples of packages that are owned by product teams and correspond to their area of ownership.

.
├── .prettierrc
├── babel.config.json
├── lerna.json
├── package.json
├── packages
│   ├── billing
│   │   ├── package.json
│   │   ├── src
│   │   └── webpack.config.js
│   ├── ds
│   │   ├── package.json
│   │   └── src
│   ├── products
│   │   ├── package.json
│   │   ├── src
│   │   └── webpack.config.js
│   └── shell
│       ├── package.json
│       ├── src
│       └── webpack.config.js
├── webpack
└── yarn.lock

Yarn vs npm

You can see I use yarn, but you can use npm v7 which also introduced support for workspaces. Workspaces are crucial to alleviate the pain of hoisting and linking dependencies. The example on GitHub includes instructions for both yarn and npm.

For convenience, I also use Lerna. Although it's not strictly necessary since we have workspaces, Lerna makes it easier to publish packages and run scripts across different packages.

Module Federation

Module Federation is a Webpack 5 super power plugin which offers an improved approach to micro frontends in both developer experience and application performance. The setup is rather straightforward and enables dynamic imports from other micro frontends in runtime.

The shell configuration has two main objectives. Define on which remotes the shell depends and specify which dependencies are shared by the shell.

new ModuleFederationPlugin({
  name: "app_shell",
  library: { type: "var", name: "app_shell" },
  remotes: {
    app_billing: "app_billing",
    app_products: "app_products",
  },
  shared: [
    {
      react: { eager: true, singleton: true },
      "react-dom": { eager: true, singleton: true },
      "react-router-dom": { eager: true, singleton: true },
    },
  ],
})

The products package is a micro frontend from which we can import dependencies in runtime. These dependencies are listed under exposes property. The filename is the name of the remote entry. It's best to have a unique remote entry file name, so we can easily host all micro frontend builds on the same remote.

new ModuleFederationPlugin({
  name: "app_products",
  library: { type: "var", name: "app_products" },
  filename: "productsRemoteEntry.js",
  exposes: {
    "./ProductsPage": "./src/ProductsPage",
  },
  remotes: {
    app_billing: "app_billing",
  },
  shared: ["react", "react-dom", "react-router-dom"],
})

The billing configuration is analogous to products config except for the ability to set the dependencies as eager. Eager dependencies are included in the main chunk which isn't what we normally want in production, but in development, it makes it possible to use the billing package as a standalone application.

The BillingPage component can be rendered outside the shell when you want to develop in isolation from the rest of the application. This feature of the setup is called previews which correspond to a similar concept in the development of SwiftUI views.

new ModuleFederationPlugin({
  name: "app_billing",
  library: { type: "var", name: "app_billing" },
  filename: "billingRemoteEntry.js",
  exposes: {
    "./BillingPage": "./src/BillingPage",
    "./UpsellModal": "./src/UpsellModal",
  },
  // make dependencies eager for preview to work
  shared: [
    {
      react: { eager: process.env.WITH_PREVIEWS === "1", singleton: true },
      "react-dom": { eager: process.env.WITH_PREVIEWS === "1", singleton: true },
      "react-router-dom": { eager: process.env.WITH_PREVIEWS === "1", singleton: true },
    },
  ],
})

Consider creating a single module for the list of shared dependencies that you can just import in each webpack config to simply the maintenance.

Putting it all together

The shell's index.html loads each remote entry and then the main shell chunk. A remote entry file is basically a mapping of imports to corresponding chunks that can be lazy-loaded. Remote entry files are fairly small, but their name cannot change. Since they don't have content hash in the file name, remote entries don't support long-term cashing.

<!doctype html>
<html>
    <head>
        <meta charset="UTF-8"/>
        <script src="/billingRemoteEntry.js" defer="defer"></script>
        <script src="/productsRemoteEntry.js" defer="defer"></script>
        <script src="/index.0ce200ad67e476bbabf7.js" defer="defer"></script>
    </head>
    <body>
        <div id="root"></div>
    </body>
</html>

The shell's App is defining routing and using React.Suspense and React.lazy, it lazy-loads the page that is currently matched by the router.

import React from "react";
import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom";

const ProductsPage = React.lazy(() => import("app_products/ProductsPage"));
const BillingPage = React.lazy(() => import("app_billing/BillingPage"));

export function App() {
  return (
      <Router>
        <Switch>
          <Route path="/billing">
            <React.Suspense fallback="loading">
              <BillingPage />
            </React.Suspense>
          </Route>
          <Route path="/">
            <React.Suspense fallback="loading">
              <ProductsPage />
            </React.Suspense>
          </Route>
        </Switch>
    </Router>
  );
}

The ProducPage statically imports the Button component from the design system package. This doesn't have anything to do with Module Federation. We can easily link packages as dependencies within the monorepo setup as not everything has to be lazy-loaded. The UpsellModal is a component that a billing team provided for other teams, so they can upsell customers with premium features. The billing package is configured to expose not only the BillingPage, but also the UpsellModal component that can be lazy-loaded by other micro frontends, not only by the shell.

import React, { useState } from "react";
import { Button } from "@michalzalecki/ds/components/Button";

const UpsellModal = React.lazy(() => import("app_billing/UpsellModal"));

export default function ProductsPage() {
  const [showUpsell, setShowUpsell] = useState();

  return (
    <div>
      <h1>Products Page</h1>
      <Button onClick={() => setShowUpsell(true)}>Business</Button>
      {showUpsell && (
        <React.Suspense fallback="LOADING">
          <div>
            <UpsellModal />
          </div>
        </React.Suspense>
      )}
    </div>
  );
}

Those are JavaScript files that load upon entering the homepage and clicking the "Business" button. The majority of the code in this simple app is React and React Router. There isn't much overhead just because we use Module Federation.

NAME                                         SIZE (gzip)
billingRemoteEntry.js                        3.4 kB
productsRemoteEntry.js                       3.6 kB
(shell) index.0ce200ad67e476bbabf7.js        55.2 kB
(ProductsPage) 313.45312c8a640a4652055e.js   1.4 kB
(UpsellModal) 946.45cff819721fe662b7c0.js    629 B

Deployment

Remote entries file names are static because Module Federation enables us to deploy one micro frontend at a time and doesn't require the full new build. When you rebuild the micro frontend, remote entry content changes to point to new chunks. You deploy a new remote entry under the same name, so next time the shell is loaded you get new chunks without the need to rebuild the shell itself. This is a mighty feature and can drastically reduce your CI/CD time.

Building the entire application is, in fact, building each micro frontend into its build directory. Due to content hashes, all files have unique names. It's possible to just put all the files in a single directory and serve them as any other single page application.

<script src="/billingRemoteEntry.js"></script>
<script src="/productsRemoteEntry.js"></script>

Another solution is to enable each team to deploy to a different remote server. This is how the shell would load remote entries from different servers.

<script src="https://billing.example.com/remoteEntry.js"></script>
<script src="https://products.example.com/remoteEntry.js"></script>

The example on GitHub supports both ways of loading remote entries. Follow the instructions in the README to learn more. It also includes a Docker setup. I've published another article on using Docker for frontend development that explains more about optimizations and isolating host's node modules from the container.

Photo by Alexander Andrews on Unsplash.