Micro-Frontends with Webpack 5 Module Federation

Photo by AltumCode on Unsplash

The pain of the “monster-lith”…

Most of us at some point in our careers have likely dealt with a single large application that houses most (if not all) of our product’s production code. While most products typically start off in this monolith-fashion, maintaining a single project as your product scales brings a fleet of issues. Shipping new features and bug fixes in the midst of a large team where dozens of other developers are also fighting to merge in can lead to pull requests taking hours (sometimes days) to finalize. Not to mention thinking your PR is next up only to find out you now have a breaking merge-conflict with master that causes you to rebase and to re-trigger the everlasting cycle all over again.

The World of Microservices

This microservice principle allows products or feature sets within an application to operate independently from one another. This is becoming increasingly more popular, especially in the realm of backend repositories and services. Typically, multiple services have a single orchestrator which is responsible for routing requests, but each service is able to be shipped and maintained entirely on its own. If something within a single service crashes, only that specific domain of the product is affected rather than the entire app. If done correctly, this can help lead to improved performance, uptime, and overall developer satisfaction. But how does this orchestration of services and applications work with a frontend application?

The Single SPA

In today’s world filled with optimized Javascript libraries and frameworks, single-page-applications are known for being performant, specifically in terms of route changes. With libraries like React, it’s typically encouraged to keep your entire frontend within the same application. This allows the browser to keep the bundled app at its beck and call and render the route change almost immediately, rather than going through the lifecycle of requesting an entirely new bundle from a separate server. While this is great from a performance standpoint, particularly for end users, it’s very difficult to maintain at scale. Attempting to maintain proper patterns, shared components, and features while reducing duplicated code and overall app bundle size when working on a team of 50+ engineers is near impossible. Sure, there are things like lint rules that can control how and where things are added, but there can only be so much control put in place before causing significant friction to each team trying to release something.

Enter Module Federation

While there are many implementations that aim to resolve this, the hopeful solution is a new spec released with Webpack 5 — Module Federation. This allows Javascript apps to dynamically run code from other bundles or builds, while also allowing individual builds to share code and resources. The magic lies within sharing dependencies and common bundled code between individual builds or repos. Let’s look at an example.

How does it work?

Let’s walk through the lifecycle to better understand what’s happening. On initial page load, whichever resource that’s being requested is assigned as the host. This is just a Webpack build that is initialized first during a page load (when the onLoad event is triggered). If our “home” page is the host, when navigating to the “about” page (the remote) the host dynamically imports a module from the remote application (the about page spa). Again, it doesn’t load the main entry point and the entire other application — only a few kilobytes of code. When the page is refreshed again, a new host is assigned and the lifecycle continues.

A real-life example

The best way to learn is by doing, so let’s jump in to some code. We’ll be using React, but the concept can apply to other JS frameworks and libraries. We’ll start by outlining our application and microservice architecture.

container (orchestrator)
about_page
design_system

The Rundown

Here are the basic steps needed to enable each of these applications to co-mingle:

  1. Add the entry point file refs to the head of your application
  2. Reconfigure index.js to use a bootstrap.js file
  3. Import remote components using React.lazy
src
App.js
routes.js
Navigation.js
Home.js
index.js
index.html
bootstrap.js
package.json
webpack.config.json
//others remitted for instructional purposes
const HtmlWebPackPlugin = require('html-webpack-plugin');
const path = require('path');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const htmlPlugin = new HtmlWebPackPlugin({
template: './src/index.html',
filename: './index.html'
});
module.exports = {
mode: 'development',
devServer: {
contentBase: path.join(__dirname, 'dist'),
port: 3000
},
output: {
publicPath: 'http://localhost:3000/'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
plugins: [
new ModuleFederationPlugin({
name: 'federation_demo_container',
library: { type: 'var', name: 'federation_demo_container' },
filename: 'remoteEntry.js',
exposes: {
'./Home': './src/Home'
},
shared: ['react', 'react-dom']
}),
htmlPlugin
]
};
new ModuleFederationPlugin({
name: 'federation_demo_container',
library: { type: 'var', name: 'federation_demo_container' },
filename: 'remoteEntry.js',
exposes: {
'./Home': './src/Home'
},
shared: ['react', 'react-dom']
})
  • filename — the named bundle entry point file into our application. This is the main bulk of where our build will land for consumer apps, notice the ending bundle size (31kb)
courtesy of giphy
  • shared — here we specify and shared node modules or dependencies. For now we’ll just use react and react-dom
//bootstrap.jsimport React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
import('./bootstrap');

Adding a route

Now it’s time to introduce our second application — the about page (this will run on port 3002). We’ll start with our same boilerplate webpack, but we’ll update the plugin config to accommodate our needs:

//webpack.config.jsonnew ModuleFederationPlugin({
name: 'federation_demo_about',
library: { type: 'var', name: 'federation_demo_about' },
filename: 'remoteEntry.js',
remotes: {
federation_demo_container: 'federation_demo_container'
},
exposes: {
'./routes': './src/routes'
},
shared: ['react', 'react-dom']
})
//index.html<html>
<head>
<title>Federation Demo - About</title>
<script src="http://localhost:3000/remoteEntry.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
//routes.jsimport React from 'react';const About = React.lazy(() => import('./About'));const routes = [
{
path: '/about',
component: About
}
];
export default routes;
//webpack.config.jsonnew ModuleFederationPlugin({
name: 'federation_demo_container',
library: { type: 'var', name: 'federation_demo_container' },
filename: 'remoteEntry.js',
remotes: {
federation_demo_about: 'federation_demo_about'
},

exposes: {
'./Home': './src/Home'
},
shared: ['react', 'react-dom']
})
<script src="http://localhost:3002/remoteEntry.js"></script>
import React, { lazy, Suspense } from 'react';
import { HashRouter, Route, Switch } from 'react-router-dom';
import Navigation from './Navigation';
import localRoutes from './routes';
import remoteRoutes from 'federation_demo_about/routes';
const routes = [...localRoutes, ...remoteRoutes];const App = () => (
<HashRouter>
<div>
<Navigation />
<Suspense fallback={<div>Loading...</div>}>
<Switch>
{routes.map((route) => (
<Route
key={route.path}
path={route.path}
component={route.component}
exact={route.exact}
/>
))}
</Switch>
</Suspense>
</div>
</HashRouter>
);
export default App;
The Home page
The About page

Introducing a third package — the design system

What if we have a shared UI component library that we want all our applications to make use of? This is an extremely common case nowadays, especially with larger-scale apps. We’ve created our third repo following similar suit to the previous ones. Here’s our plugin config this time around:

new ModuleFederationPlugin({
name: 'federation_demo_design',
library: { type: 'var', name: 'federation_demo_design' },
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button'
},
shared: ['react', 'react-dom']
})
federation_demo_design: 'federation_demo_design'
<script src="http://localhost:3001/remoteEntry.js"></script>
//Home.jsimport React, { lazy, Suspense } from 'react';const Button = lazy(() => import('federation_demo_design/Button'));const Home = () => (
<div style={{ padding: '16px' }}>
<h1 style={{ color: '#264653' }}>Home Page</h1>
<Suspense fallback="Loading Button...">
<Button>Click me on home!</Button>
</Suspense>

</div>
);
export default Home;
Home Page
About page

Closing

Although this is a much simplified example using only local applications, you can get the idea of the power and benefit Modular Federation can add to your product. Micro-frontends are sure to be a thing of the future — allowing companies to rapidly scale individualized products and ship features independently, as is being done with many backend services across the world. In case you missed it, you can find the links to each of the apps used in the example below:

About Brian

I’m a Utah-based software engineer, currently working for Podium — where we build awesome tools to allow businesses to interact with their customers over text. I’m an avid fan of food and love to cook. I also enjoy playing hockey and snowboarding and hanging out with my 3 crazy children. Find me online at any of the places below!