DEV Community

Cover image for Micro frontends using webpack’s module federation with create-react-app
Mojca Rojko
Mojca Rojko

Posted on • Originally published at Medium

Micro frontends using webpack’s module federation with create-react-app

Module federation enables us to use multiple separate builds to form a single application.

It was was introduced in Webpack 5 and it is truly a game changer in the world of frontend development. At the very least, it enables component sharing between different frontends for the same product - e.g. admin panel, user website. But further than that - it enables us to embed frontends into other frontends, giving us the ability to have a separate library stack, different code style, structure, et cetera.

The official docs say it best:

Use cases

Separate builds per page

Each page of a Single Page Application is exposed from container build in a separate build. The application shell is also a separate build referencing all pages as remote modules. This way each page can be separately deployed. The application shell is deployed when routes are updated or new routes are added. The application shell defines commonly used libraries as shared modules to avoid duplication of them in the page builds.

Components library as container

Many applications share a common components library which could be built as a container with each component exposed. Each application consumes components from the components library container. Changes to the components library can be separately deployed without the need to re-deploy all applications. The application automatically uses the up-to-date version of the components library.

Module federation gives us a truly painless experience with distributing your code over different packages and the amount of configuration is minimal. This makes your code more adaptable and more easily maintanable between different teams.


Let's build an example application

We'll build an application, that consists of three distinct modules:

  • library - contains shared components
  • app2 - a standalone app that makes use of components and is also exposed to be used elsewhere (we'll use it in app1)
  • app1 - a container app that makes use of components and also includes app2

Our goal is to use create-react-app, because chances are, you're already using it for all your react projects. We want module federation to complement our already great setup. And surely, we do not wish to eject the create-react-app configuration.

Since we'll be adding some webpack plugins, we'll need craco too. Craco is the Create React App Configuration Override, an easy and comprehensible configuration layer. For details, see https://github.com/dilanx/craco.

There are two webpack plugins that will make this possible:

It enables us to use module federation in our CRA application.

Provides us with typesafe development - meaning our apps are not full of ts-ignores and we have an automatic system for sharing typings between the apps. This is necessary for any real-world development scenario.

Configuration

There's very little configuration involved. There is however two important files in each of the modules:

  • modulefedration.config.js
  • craco.config.js

The craco.config.js will be standard for all packages:

const { join } = require("path");
const cracoModuleFederationPlugin = require("craco-mf");
const { ModuleFederationTypesPlugin } = require( '@cloudbeds/webpack-module-federation-types-plugin' );

module.exports = {
  webpack: {
    plugins: {
      add: [
        new ModuleFederationTypesPlugin({
          downloadTypesWhenIdleIntervalInSeconds: 1,
        }),
      ]
    },
    configure: (webpackConfig) => {
      webpackConfig.devServer = { static: {} };
      webpackConfig.devServer.static.directory = join(process.cwd(), "public");
      return webpackConfig;
    },
  },
  plugins: [
    {
      plugin: cracoModuleFederationPlugin,
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

In the modulefederation.config.js we will configure which packages are shared between the different apps, which components are exposed to other apps and which other apps we wish to reach in our app and where they reside.
For the library app, we'll use this:

module.exports = {
  name: "library",
  exposes: {
    "./NameContextProvider": "./src/components/NameContextProvider.ts",
    "./Button": "./src/components/Button",
    "./Logo": "./src/components/Logo",
  },
  filename: "remoteEntry.js",
  shared: {
    react: {
      singleton: true,
      requiredVersion: deps["react"],
    },
    "react-dom": {
      singleton: true,
      requiredVersion: deps["react-dom"],
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

The config tells us that we'll share one context, a button component and a logo component to the other two apps. It also tells us that we'll share react and react-dom between the three modules.

For app2, we'll use this:

module.exports = {
  name: "app2",
  exposes: {
    './App2Index': './src/Homepage',
  },
  filename: "remoteEntry.js",
  remotes: {
    library: `library@http://localhost:3003/remoteEntry.js`,
  },
  shared: {
    react: {
      singleton: true,
      requiredVersion: deps["react"],
    },
    "react-dom": {
      singleton: true,
      requiredVersion: deps["react-dom"],
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

This config tells us we'll expose the homepage of app2 to other apps (we'll use it in app1) and also that we'll be using the library remote in this application. This will enable us to use the button and the logo.

For app1, we'll use the following:

module.exports = {
  name: "app1",
  exposes: {
  },
  remotes: {
    app2: `app2@http://localhost:3002/remoteEntry.js`,
    library: `library@http://localhost:3003/remoteEntry.js`,
  },
  filename: "remoteEntry.js",
  shared: {
    ...deps,
    react: {
      singleton: true,
      requiredVersion: deps["react"],
    },
    "react-dom": {
      singleton: true,
      requiredVersion: deps["react-dom"],
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

This config enables us to use the library components and app2.

Essentially, this is all the configuration we'll need. The only other special thing about this setup is using routing. If we're using app2 as a standalone app, we'll need our own routing wrapper (e.g. BrowserRouter from react-router-dom), but if we're embedding app2 into app1, we'll use the routing wrapper from app1.

Library module

We'll skip the part on how to create the button and the logo that are exposed from the library. It's all standard react.
We'll expose a simple context too:

import React from "react";

export default React.createContext({
  name: "Mr.Noname" as string,
  setName: (name: string) => {},
});
Enter fullscreen mode Exit fullscreen mode

We'll share one context, a button component and a logo component to the other two apps.

App2 module

The fun part begins in app2! App2 can function as a standalone app or can be included in an another app. Let's see the index component:

App.tsx:

import NameContextProvider from "library/NameContextProvider";
import { useState } from "react";
import { BrowserRouter } from "react-router-dom";
import Homepage from "./Homepage";

function App2() {
  const [name, setName] = useState("Mojca");

  return (
    <BrowserRouter>
      <NameContextProvider.Provider value={{ name, setName }}>
        <Homepage />
      </NameContextProvider.Provider>
    </BrowserRouter>
  );
}

export default App2;
Enter fullscreen mode Exit fullscreen mode

Note that we're using out own routing wrapper here, and we're also using our own provider, which is why we do not expose this component to app1, but the homepage component below. App1 will have its own name provider, and when we're embedding app2 into app1, the context will be shared. Magic!

Homepage.tsx:

import Button from "library/Button";

import NameContextProvider from "library/NameContextProvider";
import React from "react";
import { Route, Routes } from "react-router-dom";

function Homepage() {
  const ctx = React.useContext(NameContextProvider);

  return (
    <Routes>
      <Route
        path="/"
        element={
          <div>
            <div style={{ marginBottom: 20 }}>
              Hello again {ctx.name}. This is app2. The button &amp; context
              used is from components app.
            </div>
            <div>
              <Button
                text="Change name from app2"
                onClick={() => ctx.setName("Jozica")}
              />
            </div>
          </div>
        }
        index
      />
    </Routes>
  );
}

export default Homepage;
Enter fullscreen mode Exit fullscreen mode

App1

App1 is the container app that will make our entire project come to life. Let's see the index component:

App.tsx:

import "./App.css";

import App2 from "app2/App2Index";

import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import Homepage from "./Homepage";

const app2RoutingPrefix = "app2";

function App1() {
  return (
    <div className="App">
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Homepage />}>
            <Route index element={<Navigate to={`/${app2RoutingPrefix}`} />} />
            <Route path={`/${app2RoutingPrefix}/*`} element={<App2 />} />
          </Route>
        </Routes>
      </BrowserRouter>
    </div>
  );
}

export default App1;
Enter fullscreen mode Exit fullscreen mode

It holds all the main routing and includes app2.

Homepage.tsx:

import React, { useState } from "react";
import "./App.css";

import Logo from "library/Logo";
import NameContextProvider from "library/NameContextProvider";

import Button from "library/Button";
import { Outlet } from "react-router-dom";

function Homepage() {
  const [name, setName] = useState("Mojca");

  return (
    <div>
      <NameContextProvider.Provider value={{ name, setName }}>
        <React.Suspense fallback="loading">
          <div style={{ marginBottom: 20, marginTop: 20 }}>
            <Logo style={{ width: 90 }} />
          </div>
          <div style={{ marginBottom: 20 }}>
            Hello {name}. This is app1 - container app. The button &amp; context
            used is from the components app.
          </div>
          <div style={{ marginBottom: 60 }}>
            <Button
              text="Change name from app1"
              onClick={() => setName("Lojza")}
            />
          </div>
          <Outlet />
        </React.Suspense>
      </NameContextProvider.Provider>
    </div>
  );
}

export default Homepage;
Enter fullscreen mode Exit fullscreen mode

In the homepage component we provide the context, and use some shared libraries, and provide an outlet for the child routes - app2 is one of those child routes and it will be rendered in its place.

That's all we need!

Running in development

We have two options:

  • We can use lerna, which will run yarn run start in each of the packages for us
  • Each of the apps can also be ran individually by running yarn run start in their respective repos

Building for production

If we were to deploy the applications to S3, we would deploy each module separately to its own respective S3 bucket.
The only configuration needed is to use the correct remote URLs in modulefederation.config.js files in each of the packages, which would need to point to S3 URLs instead of localhost URLs.

Full source code

The full source code for this example can also be found on github under https://github.com/xtrinch/create-react-app-module-federation-example

Top comments (0)