Module Federation

A new way to share code between microfrontends
Cover Image for Module Federation
Joey Gough
Joey Gough

By the time you read this, a lot of the information in this article might be out of date.

Module Federation is a new feature in Webpack 5. Intended as a language or build tool agnostic approach for sharing code between applications, Module Federation is currently supported by Webpack and is primarily used for sharing modules between Javascript applications. Typically, webapps load chunks of code from a single application bundle. Module Federation allows different apps to share chunks at runtime. Essentially one running app can import a module from another running app at a different domain at runtime. The reason this is possible is because Module Federation uses a new Webpack feature called container which allows you to create a bundle that can be loaded at runtime. This is a new way to share code between microfrontends.

Some terminology: Remote Entry file

When we wish to enable module federation in our app, so that other apps can import our modules, we deploy a file with our app bundle that contains instructions on how to fetch our modules.. This file is canonically named the remote entry file.

What does it look like in practice?

Here is a most basic example of configuring module federation between two apps. In this example the remote is exposing a javascriopt module for consumption by the host.

Remote

// webpack.config.js
const HtmlWebPackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

const deps = require("./package.json").dependencies;
module.exports = {
  output: {
    publicPath: "http://localhost:8081/",
  },

  resolve: {
    extensions: [".tsx", ".ts", ".jsx", ".js", ".json"],
  },

  devServer: {
    port: 8081,
    historyApiFallback: true,
  },

  module: {
    rules: [
      {
        test: /\.m?js/,
        type: "javascript/auto",
        resolve: {
          fullySpecified: false,
        },
      },
      {
        test: /\.(css|s[ac]ss)$/i,
        use: ["style-loader", "css-loader", "postcss-loader"],
      },
      {
        test: /\.(ts|tsx|js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
        },
      },
    ],
  },

  plugins: [
    new ModuleFederationPlugin({
      name: "remote",
      filename: "remoteEntry.js",
      remotes: {},
      exposes: {
        "./Module": "./src/modules/ModuleA",
      },
      shared: {
        ...deps,
        react: {
          singleton: true,
          requiredVersion: deps.react,
        },
        "react-dom": {
          singleton: true,
          requiredVersion: deps["react-dom"],
        },
      },
    }),
    new HtmlWebPackPlugin({
      template: "./src/index.html",
    }),
  ],
};

Host

// webpack.config.js
const HtmlWebPackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

const deps = require("./package.json").dependencies;
module.exports = {
  output: {
    publicPath: "http://localhost:8080/",
  },

  resolve: {
    extensions: [".tsx", ".ts", ".jsx", ".js", ".json"],
  },

  devServer: {
    port: 8080,
    historyApiFallback: true,
  },

  module: {
    rules: [
      {
        test: /\.m?js/,
        type: "javascript/auto",
        resolve: {
          fullySpecified: false,
        },
      },
      {
        test: /\.(css|s[ac]ss)$/i,
        use: ["style-loader", "css-loader", "postcss-loader"],
      },
      {
        test: /\.(ts|tsx|js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
        },
      },
    ],
  },

  plugins: [
    new ModuleFederationPlugin({
      name: "host",
      filename: "remoteEntry.js",
      remotes: {
        remote: "remote@http://localhost:8081/remoteEntry.js",
      },
      exposes: {},
      shared: {
        ...deps,
        react: {
          singleton: true,
          requiredVersion: deps.react,
        },
        "react-dom": {
          singleton: true,
          requiredVersion: deps["react-dom"],
        },
      },
    }),
    new HtmlWebPackPlugin({
      template: "./src/index.html",
    }),
  ],
};

Here we have the most basic example of how to configure Module Federation. As we see it is very easy to setup with just a handful of lines of code. On the top we have the “remote”, which is exposing a module to be consumed by another application, and on the bottom we have the host, which will be consuming the remote module. As we can see both of these apps are running on different ports. The remote module has named it’s file “remoteEntry.js” and it is specifying that it is exposing a module named “Module”. To do this, we give the relative file path for the module we would like to be exposed, and whatever is exported from this file is exposed for consumption. The host is specifying that is has one remote, named “remote”. The remote port is that same as the remote. And the url is pointing to the remoteEntryFile.js. Because the key in the remotes object is “remote”, when we try to import from “remote” in our host app, it will resolve to this running app at port 8081. It is important to note that apps can be omnidirectional and circular dependencies are allowed.

Consuming the remote module

// src/RemoteComponent.tsx
import React from "react";
import { ErrorBoundary } from "../../components";

const LazyComponent = React.lazy(() => import("remote/Module"));

export const RemoteComponent = () => (
  <ErrorBoundary>
    <React.Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </React.Suspense>
  </ErrorBoundary>
);

export default RemoteComponent;

So within the host, when we want to consume the remote. Because it is fetched over the network, just like code splitting, we use the import function. We use the react suspense api to provide a fallback while it is loading, and we provide a typical react error boundary in case the remote server is down or anything else goes wrong with the remote server.

Sharing dependencies

shared dependencies

Shared libraries are only imported once. Dependencies that only required by remotes are only imported when the remote module is loaded. If a remote requires a library that is shared with the host then it typically uses the host’s version of the library. It does not download its own copy. If it uses a library that is only used by another remote, then remotes can share dependencies even if the host does not require them. So it truly only loads remotes that are necessary. It is important to note that this prevents tree shaking - as the host cannot predict what parts of a library remotes will need, we need to prevent tree shaking and keep the entire library in our dist.

Organization structure

Implementing microfrontends can allow us to break up our frontend teams just like with microservices. In large organizations, this can allow us to achieve an organization structure something like this:

organisation-structure

This organization structure facilitates product verticals. Each product vertical can have its own team, and each team can have its own microfrontend. This allows for a more agile development process.

Conclusion

In this article, we have seen how to setup Module Federation and how to consume remote modules. We have also seen how to share dependencies between the host and the remote. We have also seen how to structure our organization to take advantage of Module Federation.