Micro frontends take the idea of microservices to frontend development. Instead of developing the application or a site as a monolith, the point is to split it as smaller portions programmed separately that are then tied together during runtime.
With the approach, you can use different technologies to develop other parts of the application and have separate teams developing them. The reasoning is that splitting up development this way avoids the maintenance costs associated with a traditional monolith.
As a side effect, it enables new types of collaboration between backend and frontend developers as they can focus on a specific slice of an application as a cohesive team. For example, you could have a team focusing only on the search functionality or other business-critical portion around a core feature.
Starting from webpack 5, there's built-in functionality to develop micro frontends. Module federation and gives you enough functionality to tackle the workflow required by the micro frontend approach.
To learn more about module federation, [see module federation examples](https://github.com/module-federation/module-federation-examples/) and [Zack Jackson's article about the topic](https://medium.com/swlh/webpack-5-module-federation-a-game-changer-to-javascript-architecture-bcdd30e02669).
To get started with module federation, let's build a small application that we'll then split into specific bundles loaded using the technique. The basic requirements of the application are as follows:
Above could be modeled as HTML markup along this:
<body>
<h1>Demo</h1>
<aside>
<ul>
<li><button>Hello world</button></li>
<li><button>Hello federation</button></li>
<li><button>Hello webpack</button></li>
</ul>
</aside>
<main>
The content should change based on what's clicked.
</main>
</body>
The idea is that as any button is clicked, the content is updated to match the text.
To be semantically correct, you could wrap the `h1` inside a `header`.
Set up webpack configuration for the project as follows:
webpack.mf.js
const path = require("path");
const { mode } = require("webpack-nano/argv");
const { merge } = require("webpack-merge");
const parts = require("./webpack.parts");
const commonConfig = merge([
{
entry: [path.join(__dirname, "src", "mf.js")],
output: { publicPath: "/" },
},
parts.loadJavaScript(),
parts.loadImages(),
parts.page(),
parts.extractCSS({ loaders: [parts.tailwind()] }),
]);
const configs = {
development: merge(
{ entry: ["webpack-plugin-serve/client"] },
parts.devServer()
),
production: {},
};
module.exports = merge(commonConfig, configs[mode], { mode });
The configuration is a subset of what we've used in the book so far. It relies on the following .babelrc
:
.babelrc
{
"presets": [
"@babel/preset-react",
["@babel/preset-env", { "modules": false }]
]
}
Set up npm scripts as follows:
package.json
{
"scripts": {
"build:mf": "wp --config webpack.mf.js --mode production",
"start:mf": "wp --config webpack.mf.js --mode development"
}
}
The idea is to have one script to run the project and one to build it.
If you want to improve the setup further, add Hot Module Replacement to it, as discussed in the related chapter.
If you haven't completed the book examples, [check out the demonstration from GitHub](https://github.com/survivejs-demos/webpack-demo) to find the configuration.
To avoid manual work with the DOM, we can use React to develop the application quickly. Make sure you have both react and react-dom installed.
src/mf.js
import ReactDOM from "react-dom";
import React from "react";
import "./main.css";
function App() {
const options = ["Hello world", "Hello fed", "Hello webpack"];
const [content, setContent] = React.useState("Changes on click.");
return (
<main className="max-w-md mx-auto space-y-8">
<h1 className="text-xl">Demo</h1>
<aside>
<ul className="flex space-x-8">
{options.map((option) => (
<li key={option}>
<button
className="rounded bg-blue-500 text-white p-2"
onClick={() => setContent(option)}
>
{option}
</button>
</li>
))}
</ul>
</aside>
<article>{content}</article>
</main>
);
}
const container = document.createElement("div");
document.body.appendChild(container);
ReactDOM.render(<App />, container);
The styling portion uses Tailwind setup from the Eliminating Unused CSS chapter for styling so we can make the demonstration look better.
If you npm run start:mf
, you should see the application running. In case you click on any of the buttons, the selection should change.
The next step is breaking the monolith into separate modules. In practice, these portions can be different projects and developed in various technologies.
As a first step, we should use webpack's ModuleFederationPlugin
and load the application asynchronously. The change in loading is due to the way module federation works. As it's a runtime operation, a small bootstrap is needed.
Add a bootstrap file to the project like this:
src/bootstrap.js
import("./mf");
It's using the syntax you likely remember from the Code Splitting chapter. Although it feels trivial, we need to do this step as otherwise, the application would emit an error while loading with ModuleFederationPlugin
.
To test the new bootstrap and the plugin, adjust webpack configuration as follows:
const { ModuleFederationPlugin } = require("webpack").container;
...
const commonConfig = merge([
{
entry: [path.join(__dirname, "src", "mf.js")],
entry: [path.join(__dirname, "src", "bootstrap.js")],
output: { publicPath: "/" },
},
...
{
plugins: [
new ModuleFederationPlugin({
name: "app",
remotes: {},
shared: {
react: { singleton: true },
"react-dom": { singleton: true },
},
}),
],
},
]);
...
If you run the application (npm run start:mf
), it should still look the same.
In case you change the entry to point at the original file, you'll receive an Uncaught Error: Shared module is not available for eager consumption
error in the browser.
To get started, let's split the header section of the application into a module of its own and load it during runtime through module federation.
Note the singleton
bits in the code above. In this case, we'll treat the current code as a host and mark react and react-dom as a singleton for each federated module to ensure each is using the same version to avoid problems with React rendering.
Now we're in a spot where we can begin breaking the monolith. Set up a file with the header code as follows:
src/header.js
import React from "react";
const Header = () => <h1 className="text-xl">Demo</h1>;
export default Header;
We should also alter the application to use the new component. We'll go through a custom namespace, mf
, which we'll manage through module federation:
src/mf.js
...
import Header from "mf/header";
function App() {
...
return (
<main className="max-w-md mx-auto space-y-8">
<h1 className="text-xl">Demo</h1>
<Header />
...
</main>
);
}
Next, we should connect the federated module with our configuration. It's here where things get more complicated as we have to either run webpack in multi-compiler mode (array of configurations) or compile modules separately. I've gone with the latter approach, as it works better with the current configuration.
It's possible to make the setup work in a multi-compiler setup as well. In that case, you should either use **webpack-dev-server** or run **webpack-plugin-serve** in a server mode. [See the full example](https://github.com/shellscape/webpack-plugin-serve/blob/master/test/fixtures/multi/webpack.config.js) at their documentation.
To make the changes more manageable, we should define a configuration part encapsulating the module federation concern and then consume that:
webpack.parts.js
const { ModuleFederationPlugin } = require("webpack").container;
exports.federateModule = ({
name,
filename,
exposes,
remotes,
shared,
}) => ({
plugins: [
new ModuleFederationPlugin({
name,
filename,
exposes,
remotes,
shared,
}),
],
});
The next step is more involved, as we'll have to set up two builds. We'll have to reuse the current target and pass --component
parameter to it to define which one to compile. That gives enough flexibility for the project.
Change the webpack configuration as below:
webpack.mf.js
const { mode } = require("webpack-nano/argv");
const { ModuleFederationPlugin } = require("webpack").container;
const { component, mode } = require("webpack-nano/argv");
const commonConfig = merge([
{
entry: [path.join(__dirname, "src", "bootstrap.js")],
output: { publicPath: "/" },
}
...
parts.extractCSS({ loaders: [parts.tailwind()] }),
{
plugins: [
new ModuleFederationPlugin({
name: "app",
remotes: {},
shared: {
react: { singleton: true },
"react-dom": { singleton: true },
},
}),
],
},
]);
const shared = {
react: { singleton: true },
"react-dom": { singleton: true },
};
const componentConfigs = {
app: merge(
{
entry: [path.join(__dirname, "src", "bootstrap.js")],
},
parts.page(),
parts.federateModule({
name: "app",
remotes: { mf: "mf@/mf.js" },
shared,
})
),
header: merge(
{
entry: [path.join(__dirname, "src", "header.js")],
},
parts.federateModule({
name: "mf",
filename: "mf.js",
exposes: { "./header": "./src/header" },
shared,
})
),
};
if (!component) throw new Error("Missing component name");
module.exports = merge(commonConfig, configs[mode], { mode });
module.exports = merge(
commonConfig,
configs[mode],
{ mode },
componentConfigs[component]
);
To test, compile the header component first using npm run build:mf -- --component header
. Then, to run the built module against the shell, use npm run start:mf -- --component app
.
If everything went well, you should still get the same outcome.
You could say our build process is a notch more complex now, so what did we gain? Using the setup, we've essentially split our application into two parts that can be developed independently. The configuration doesn't have to exist in the same repository, and the code could be created using different technologies.
Given module federation is a runtime process, it provides a degree of flexibility that would be hard to achieve otherwise. For example, you could run experiments and see what happens if a piece of functionality is replaced without rebuilding your entire project.
On a team level, the approach lets you have feature teams that work only a specific portion of the application. A monolith may still be a good option for a single developer unless you find the possibility to AB test and to defer compilation valuable.
Consider the following resources to learn more:
Module federation, introduced in webpack 5, provides an infrastructure-level solution for developing micro frontends.
To recap:
ModuleFederationPlugin
is the technical implementation of the solutionThis book is available through Leanpub (digital), Amazon (paperback), and Kindle (digital). By purchasing the book you support the development of further content. A part of profit (~30%) goes to Tobias Koppers, the author of webpack.