Micro frontends with Module Federation
This article explains how our team has implemented a micro frontend architecture using Webpack Module Federation. If you're interested in understanding why we decided to use a micro frontend approach read THIS post written by my teammates.
In short, Module Federations lets you combine separate builds to form a single application. This lets you develop and deploy modules independently and combine them at runtime. Since modules combine to a single application you can share bundles for third party libraries, events propagate nicely and all parts of the applications have equal references to global browser objects.
If you want to learn more about the details of Module Federation i recommend starting with this article. Webpack 5 Federation: A game changer in JavaScript architecture
Let's get the terminology right
host
The currently running application that is hosting federated remote modules.
remote
A reference to an external federated module.
expose:
A module that is exported from an application is “exposed".
module:
The smallest unit of shareable code. Can be a single file.
bi-directional host:
A webpack build that is both a host consuming remotes and a remote being consumed by other hosts.
Mission
To put things in context: we work in the tele- and datacom industry, providing a large number of subscription based services to our users. Our digital platform is facing enterprise customers, enabling them to fully manage their business and services.
Our mission was to merge two large monolithic React applications, two product development teams and two large customer segments to create a simpler solution for the end users and the developers on our team.
Goals
After deciding that we indeed wanted to implement our new platform using micro frontends, we started looking into ways of doing so.
Keep in mind that our goal was to achieve:
- Independent deployments
- Smooth interaction between apps (it should feel like a SPA)
- Enable shared state and client cache across apps
- Make it easier to work on a specific part of the application (disregarding the rest)
- Reducing the size of the project you need to run locally
- Easy code sharing between micro frontends
- Technology agnostic
Fast forward. We decided to try out the new feature of Webpack 5, Module Federation.
This was in June 2020, and at the time Webpack 5 was still in beta. We've encountered our fair share of undocumented, un-stackoverflowed errors. With time Webpack 5 became stable and was released, and so was our system of micro frontends.
Implementation
Our system consists of a main federated module, and all other micro frontends in a mono repo.
The main federated module has the responsibility of setting the global layout of the user interface, such as the global navigation menu and top bar. It also renders appropriate micro frontends on the appropriate route. From there each micro frontend handles its routing internally.
main renders modules from the remote menu and a module from the appropriate micro frontend, in this illustration subscriptions
We've also used the main federated module as a shared library for all of our applications. Eg. to share layout components or formatting utilities. This is a part of our architecture which we're not completely sure scales well. We're considering separating the responsibilities of main into an app shell for stitching things together and a shared library.
main also authenticates the user, sets up an apollo client for data fetching and provides the global state to the entire system. Such as presentational language and router.
The other micro frontends typically have a bidirectional relationship to the main federated module. When used as a host, a micro frontend renders the root of our system, a module exposed from main. The exposed module from main in turn renders exposed modules from the micro frontends on the appropriate paths. This is true for micro frontends that require the user to be authenticated, need to fetch data from our graphql backend or access global state. Which is currently all of our apps.
Notice that micro frontend "abc" does not import App from main, or have main as a remote. This is done to underline the fact that you can have a standalone app in this system consisting of many bi-directional host/remotes apps. This standalone federated module could for example be an isolated part of your system that should be rendered on some path on the same domain, but doesn't care about the rest. Or it could be a shared library of utilities and UI components. This is a solution we are considering for our shared library.
Alright. Let's put it to an example. Say we have a micro frontend called subscriptions and the main federated module. subscriptions is a remote in main, and main is a remote in subscriptions. They are what we call bi-directional hosts. subscriptions exposes a module (typically some routes as a React component), call this module SubscriptionsRouter
. It is imported by main, and the React component exported from the SubscriptionsRouter
module is then rendered on /path-to-subscriptions
in main. Similarly, main exposes its root which is imported by subscriptions. If we simplify the above illustration the relationship between main and subscriptions micro frontend looks something like this.
Bear with me here, and let's look at it in code.
Simplified, the main federated module root component can look like this. This is the root of our system
// main/src/App.tsx
import React, { Suspense, lazy } from "react";
import { BrowserRouter, Switch, Route } from "react-router-dom";
import { AuthenticationProvider } from "./auth";
const LandingPage = lazy(() => import("./pages/LandingPage"));
// import the router from the subscriptions micro frontend
const SubscriptionsRouter = lazy(() => import("subscriptions/SubscriptionsRouter"));
const App = () => (
<AuthenticationProvider>
<BrowserRouter>
<Suspense fallback={<div>Loading...<div>}>
<Switch>
<Route exact={true} path="/" component={LandingPage} />
{/* Render the micro frontend on its path */}
<Route path="/path-to-subscriptions" component={SubscriptionsRouter} />
</Switch>
</Suspense>
</BrowserRouter>
</AuthenticationProvider>
);
export default App;
And the Module Federation config looks like this
// main/webpack.config.js
...
new ModuleFederationPlugin({
name: "main",
filename: "remoteEntry.js",
remotes: {
subscriptions: "subscriptions",
},
exposes: {
"./App": "./src/App",
},
shared: [
{ react: { requiredVersion: deps.react, singleton: true } },
{ "react-dom": { requiredVersion: deps["react-dom"], singleton: true }},
{ "react-router-dom": { requiredVersion: deps["react-router-dom"], singleton: true }},
],
}),
The SubscriptionsRouter
component in the subscriptions micro frontend looks like this. It handles internal routing in subscriptions and will be exposed from the subscriptions federated module.
// subscriptions/src/SubscriptionsRouter.tsx
import React, { lazy, Suspense } from 'react';
import { Route, Switch } from 'react-router-dom';
const Overview = lazy(() => import('./pages/Overview'));
const Subscription = lazy(() => import('./pages/Subscription'));
const SubscriptionsRouter = () => {
return (
<Suspense fallback={null}>
<Switch>
<Route exact path="/path-to-subscriptions" component={Overview} />
<Route path="/path-to-subscriptions/:id" component={Subscription} />
</Switch>
</Suspense>
);
};
export default SubscriptionsRouter;
The subscriptions app root component can look like this. It import the system root from main. This will be rendered only if subscriptions is the host of our system. For example when you run subscriptions locally.
// subscriptions/src/App.tsx
import React, { Suspense, lazy } from 'react';
// the main microfrontend. It will import and render the subscription router.
const Main = lazy(() => import('main/App'));
const App = () => (
<Suspense fallback={null}>
<Main />
</Suspense>
);
export default App;
And the Module Federation config looks like this. Note that we expose SubscriptionsRouter.tsx not App.tsx
// subscriptions/webpack.config.js
...
new ModuleFederationPlugin({
name: "subscriptions",
filename: "remoteEntry.js",
remotes: {
main: "main",
},
exposes: {
"./SubscriptionsRouter": "./src/SubscriptionsRouter",
},
shared: [
{ react: { requiredVersion: deps.react, singleton: true } },
{ "react-dom": { requiredVersion: deps["react-dom"], singleton: true }},
{ "react-router-dom": { requiredVersion: deps["react-router-dom"], singleton: true }},
],
}),
index.html
for the subscriptions app looks like this
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
// this loads the main app with a relative path. See comment below
<script src="/main/remoteEntry.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
Once the main federated module is loaded on the client, entry files for other micro frontends are loaded dynamically in main. These are also loaded using relative paths. Our builds are stored in AWS S3 with the same relative locations as in the repository. (More on this in an upcoming article about our independent deployments)
As mentioned; when a micro frontend is the host, it renders the root of our system, a module exposed from main. The root exposed from main in turn renders the exposed module from the micro frontend.
This enables any app to be the host for our system, and regardless of which app is the host the rendered UI will be the same. In practice this means that you can run any single app, but get the context of the whole system (remotes are fetched from our staging environment). It is experienced as working with the entire system, as if it were a monolithic frontend, but you only run a small chunk of the code — the code you are actually working on.
By using relative paths it is quite simple to set up a proxy for fetching micro frontends from our staging environment or from your localhost.
Our architecture is in fact also configured to support running multiple micro frontends locally. So you can decide which apps you want to fetch from your local dev server and which to fetch from stage. This can be useful when you want to modify a component which is exposed and used as a remote module by another application. Again, you only have to run the code you're working on and can disregard the rest.
This bidirectional dependency between main and a micro frontend is not a requirement. Rather it's a pattern we use for our micro frontends that require authentication, data fetching or global state. If we were to implement a shared library with module federation for our micro frontends, it would consist of stand alone components and have a one directional relationship with any app the wants to utilize its content. This integrates seamlessly with the rest of the system.
You might find that our implementation does not follow the traditional concept micro frontends. This was a conscious decision on our part, and we're currently satisfied with the architecture and how it supports our goals. However, we iterate whenever we se fit. Please reach out if you have any thoughts on the topic or want to start a discussion on it! 💡