What is Micro Frontend?
The micro frontend is an architecture pattern where a single monolith front-end app is divided into multiple, smaller apps, each of which is developed and tested independently and is responsible for a distinct feature. The concept is similar to microservices, but for frontend codebases.
Why?
Monolithic codebases are difficult to test, upgrade, scale, and maintain while limiting the team’s ability to work independently on different parts of the app and use heterogenous toolsets. Breaking a monolithic app into smaller, manageable, decoupled micro frontends enables multiple teams to work autonomously and use their preferred frontend framework and build tools to work on incremental upgrades.
How?
There are primarily three ways to integrate micro frontend modules with the container app.
Server integration
Each micro frontend is hosted on a (preferably separate) webserver that is responsible for rendering and serving the relevant markup. Upon receiving the request from the browser, the container requests the markup by making a server call to the relevant micro frontend server.
This is not an ideal approach as it involves making multiple server calls for rendering content on the page and requires the implementation of a well-thought caching strategy to decrease latency. This is a divergence from the principles of modern web development or single-page apps.
Compile time integration
The container gets access to the micro frontend’s code during development/compile time. One way to achieve this is to publish micro frontends as npm packages and import them as dependencies in the container app.
While this is simple to set up and implement, it introduces tight coupling between the container and micro frontends. Each time a micro frontend is updated, the container needs to be redeployed to integrate the update.
Run time integration
The container gets access to the micro frontend’s code while it is running in the browser. One way to achieve this is using Webpack’s Module Federation plugin that takes care of bundling, exposing, and consuming dependencies at runtime. We will explore this in more detail in this article.
It eliminates coupling between the container and micro frontends. Because the integration happens at runtime, each micro frontend can be developed and deployed independently, without redeploying the container app. However, it is more complex to setup as compared to compile-time integration.
Webpack Module Federation Plugin
The module federation plugin is introduced in Webpack 5.0 that enables the development of micro frontend applications with runtime federation by dynamically loading code from micro frontend apps (aka remote apps) into the container app (aka host app). It also enables sharing dependencies between the remote and host apps to avoid duplicate code and minimize build size.
In this article, we will look at an example involving two simple micro frontends and integrate them into a host app that will mount each of the remote apps at runtime using the module federation plugin.
Creating a first remote app
Create a folder called remote1. Change directory to this folder and create a package.json file by running the following npm command
npm init -y
Open the folder in the code editor of your choice. Add following dependencies in the package.json file,
- webpack, and webpack-CLI - For using webpack and webpack CLI commands
- html-webpack-plugin and webpack-devserver - Local development server with live reloading
Add webpack serve script under the scripts section to serve the app in the browser.
Your package.json should look like this,
{
"name": "remote1",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "webpack serve"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"html-webpack-plugin": "^5.2.0",
"webpack": "^5.4.0",
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^4.0.0"
}
}
Save the package.json file and run npm install to install the dependencies.
Create a new folder called public and add an index.html file inside the public folder. Add the HTML markup inside the index.html file that contains the container div where the app output is mounted in development mode.
<!DOCTYPE html>
<html>
<head> </head>
<body>
<div id="dev-remote1"></div>
</body>
</html>
Create a new folder called src and add index.js and startup.js files inside this folder. We will come back to these files in a moment.
Create a new file inside the app root called webpack.config.js to store the webpack configuration. Add HTML Webpack and Module Federation plugins to the config file,
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
mode: 'development',
devServer: {
port: 7001,
},
plugins: [
new ModuleFederationPlugin({
name: 'remote1',
filename: 'remoteEntry.js',
exposes: {
'./RemoteApp1': './src/startup',
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
};
remoteEntry.js file contains a list of files exposed by the remote app along with directions on how to load them. This will be used while integrating the remote app with the host app.
index.js is the entry point of our remote app. However, to prevent eager loading (where the remote app code runs after the entire host app code is loaded), we split the startup code into a separate js file – startup.js in our case. Hence index.js contains just one dynamic import statement,
import('./startup');
Inside the startup.js file, we export a mount function that takes the element where the app output needs to be mounted. In development mode, the output can be mount to the container div inside the local index.html file. In hosted mode, the host can use the mount function to pass the container where remote app output should be rendered.
const mount = (el) => {
el.innerHTML = '<div>Remote 1 Content</div>';
};
if (process.env.NODE_ENV === 'development') {
const el = document.querySelector('#dev-remote1');
if (el) {
mount(el);
}
}
export { mount };
Save all the files and run npm start to start the local dev server. Browse the app URL to see the output of the remote app in the browser.
Creating a second remote app
Repeat similar steps to create another remote app called remote2.
package.json for remote2
{
"name": "remote2",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "webpack serve"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"html-webpack-plugin": "^5.2.0",
"webpack": "^5.4.0",
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^4.0.0"
}
}
index.html for remote2
<!DOCTYPE html>
<html>
<head> </head>
<body>
<div id="dev-remote2"></div>
</body>
</html>
webpack.config.js for remote2
Ensure to update port number and module federation plugin settings.
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
mode: 'development',
devServer: {
port: 7002,
},
plugins: [
new ModuleFederationPlugin({
name: 'remote2',
filename: 'remoteEntry.js',
exposes: {
'./RemoteApp2': './src/startup',
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
};
index.js for remote2,
import('./startup');
startup.js for remote2,
const mount = (el) => {
el.innerHTML = '<div>Remote 2 Content</div>';
};
if (process.env.NODE_ENV === 'development') {
const el = document.querySelector('#dev-remote2');
if (el) {
mount(el);
}
}
export { mount };
Browse the app by running npm start to ensure there are no console errors.
Creating host app and integrating with remote apps
Create a new folder called a container. Change directory to the newly created folder and create a package.json file by running npm init -y. Open the app in the code editor of your choice.
Update dependencies in package.json and run npm install to install the dependencies.
{
"name": "container",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "webpack serve"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"html-webpack-plugin": "^5.2.0",
"webpack": "^5.4.0",
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^4.0.0"
}
}
Create a public folder inside app root and index.html file inside the public folder. Have container div(s) inside the HTML file to host contents from each of the remote apps.
<!DOCTYPE html>
<html>
<head> </head>
<body>
<div id="remote1-app"></div>
<div id="remote2-app"></div>
</body>
</html>
Create an src folder inside app root and add index.js and startup.js files inside the src folder. We will come back to these files in a moment.
Create a webpack.config.js file with the following configuration.
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
mode: 'development',
devServer: {
port: 7000,
},
plugins: [
new ModuleFederationPlugin({
name: 'container',
remotes: {
remote1: 'remote1@http://localhost:7001/remoteEntry.js',
remote2: 'remote2@http://localhost:7002/remoteEntry.js',
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
};
The Module Federation plugin’s remote setting defines the remote apps that the host can access. It contains key-value pairs where the key can be any string and value is a string containing the name of the remote app as defined in the module federation plugin configuration of the remote app, followed by the “@” symbol, followed by the URL where the remote app’s remoteEntry.js is hosted.
Same as we did for remote apps, in the index.js file of the host app, add the dynamic import for startup.js file
import('./startup');
In the startup.js file import the remote apps and mount them to the host’s index.html file
import { mount as remote1Mount } from 'remote1/RemoteApp1';
import { mount as remote2Mount } from 'remote2/RemoteApp2';
remote1Mount(document.querySelector('#remote1-app'));
remote2Mount(document.querySelector('#remote2-app'));
Run the host app using npm start. You should be able to see output from the remote apps inside the host app
Important Considerations
Module federation is available with Webpack 5 and above. At the time of writing this article, the scaffolding packages for front-end frameworks like create-react-app still use Webpack 4. Hence to make use of the Module Federation plugin, either setup the app from scratch without using the scaffolding npm packages or eject the scaffolded app and upgrade the webpack version
You can share dependencies between host and remote app by adding them in the module federation plugin settings for both host and remote app.
new ModuleFederationPlugin({
name: 'remote1',
filename: 'remoteEntry.js',
exposes: {
'./RemoteApp1': './src/startup',
},
shared:['react', 'react-dom']
}),
There can often be styling (CSS) conflicts between remote and host app styles. Hence prefer to use a component library that provides namespacing and CSS-in-JS features to avoid conflicts, like StylesProvider in material-ui or component scoped styles in Angular
Communication between remote and host apps can be done through callbacks in such a way that it does not introduce tight coupling.
Navigation changes should be synchronized between remote and host apps using callbacks, browser history, and memory history
References
- https://martinfowler.com/articles/micro-frontends.html
- https://webpack.js.org/concepts/module-federation/
- https://medium.com/swlh/webpack-5-module-federation-a-game-changer-to-javascript-architecture-bcdd30e02669
- https://www.udemy.com/course/microfrontend-course/