Building Scalable and Modular Web Applications with React, Micro Frontends and Webpack Module Federation

Asif Vora
Simform Engineering
11 min readOct 5, 2023

A detailed guide to creating a compact front-end app that can be divided into smaller apps, with an explanation of its internal workings.

Building Scalable and Modular Web Applications with React, Micro Frontends and Webpack Module Federation

Over the years, web development has seen a major transformation towards a more flexible and scalable architecture. One fascinating advancement in this field is the idea of micro frontends. This groundbreaking technique enables development teams to create and maintain frontend modules independently while effortlessly integrating them into the user experience. An instrumental technology behind Micro Frontends is Webpack Module Federation, which empowers developers to break down monolithic frontend applications into smaller, more manageable pieces.

In this article, we will explore the concept of micro frontends in detail. We will also examine how Webpack Module Federation plays a vital role in constructing state-of-the-art web applications that are simple to develop, expand, and upkeep.

What are micro frontends?

The concept of Micro Frontends is similar to that of Microservices development. Instead of having one codebase for the frontend, the application is divided into separate and independent frontend modules. Each module can be developed, tested, deployed, and maintained individually using their preferred technologies, languages, or frameworks. This facilitates scalability, allows for easier development, and gives teams flexibility. Moreover, micro frontends make it easier to adopt technologies by allowing the incorporation of frameworks and libraries without impacting the entire application.

For more info, click here.

Benefits of micro frontends

  1. Independent Development: Micro frontends allow different teams to work on separate modules simultaneously. This speeds up development and deployment cycles and reduces the chances of code conflicts.
  2. Reusability: Micro frontends encourage the reuse of code components, styles, and assets across different projects, leading to more efficient development processes.
  3. Scaling: Each module can be individually scaled to accommodate varying levels of demand, ensuring optimal performance for different parts of the application.
  4. Technology Diversity: Teams can choose the best-suited technologies for their specific module, promoting innovation and flexibility within the development process.

Introducing Webpack Module Federation

Webpack Module Federation is a feature introduced in Webpack 5 that allows multiple applications to share code and dependencies at runtime. It enables seamless integration of micro frontends, making it a powerful tool for building scalable and modular applications. Webpack Module Federation facilitates the dynamic loading of remote modules into the main application, reducing initial loading times and improving the user experience.

Key Benefits of Webpack Module Federation

  1. Code Sharing: Webpack Module Federation allows developers to share JavaScript modules between micro frontends. This means common code, such as utilities, components, or even entire features, can be shared effortlessly, reducing duplication and overall bundle size.
  2. Runtime Independence: Unlike traditional monolithic applications, micro frontends can have their own build and deployment lifecycles. Webpack Module Federation ensures that the individual micro frontends can function independently at runtime, allowing updates to one micro frontend without affecting others.
  3. Flexibility and Scalability: With Webpack Module Federation, developers have the freedom to choose their preferred technology stack for each micro frontend. This means different teams can work with different frameworks (e.g., React, Angular, Vue) based on their expertise without affecting the overall application’s integrity.
  4. Simplified Development Workflow: Since micro frontends can be developed and deployed independently, development teams can work concurrently, leading to faster development cycles and quicker time-to-market.
  5. Seamless Integration: Webpack Module Federation abstracts the complexity of sharing code between micro frontends. Developers can use familiar import statements to include code from other micro frontends, making integration effortless.

How Webpack Module Federation Works

Webpack Module Federation operates on the principles of Remote Entry Points and Shared Modules.

  1. Remote Entry Points: In a micro frontend setup, each application is exposed as a remote entry point. Other micro frontends can consume the exposed modules by dynamically fetching them at runtime.
  2. Shared Modules: Webpack Module Federation allows developers to define shared modules, which are code dependencies that can be shared among multiple micro frontends. By doing so, you can reduce the size of individual bundles and improve the overall performance of the application.

Implementation:

Implementing micro frontends with Webpack Module Federation involves the following steps. We’re using workspace here. A workspace typically uses a monorepo structure to manage multiple packages or applications in a single repository. Here’s a step-by-step guide to creating such a setup:

  1. Create a Workspace: Create a new directory for your project and set it up as a Yarn workspace:
mkdir micro-frontend
cd micro-frontend

yarn init -y

2. Create the Root Workspace: Create a package.json file in your. workspace's root directory with the following content:

{
"name": "micro-frontend",
"version": "1.0.0",
"description": "A micro-frontend app",
"private": true,
"workspaces": ["packages/*"]
}

3. Install dependency: Install necessary devDependency on the root level so that it can be used later for other apps.

    yarn add -D @babel/core @babel/plugin-transform-runtime @babel/preset-env @babel/preset-react @babel/runtime  @types/react @types/react-dom babel-loader clean-webpack-plugin  html-webpack-plugin react-hot-loader ts-loader typescript webpack webpack-cli webpack-dev-server webpack-merge

Create .babelrc file at packages directory and add the following content:

{
"presets": ["@babel/preset-env"]
}

4. Setup React Applications : Inside the workspace directory, create a packages directory if it doesn't already exist:

mkdir packages
cd packages

Now, setup React Applications inside the packages directory:

Setup for app-1:

mkdir app1
cd app1
yarn init -y

A. Now install the necessary dependency for app1:

{
"name": "app1",
"version": "1.0.0",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}

B. Create .babelrc file and add the following files with content shown below:

{
"extends": "../.babelrc"
}

C. Create tsconfig.json file and add the following files with the content shown below:

{
"compilerOptions": {
"sourceMap": true,
"jsx": "react",
"target": "ES6"
}
}

D. Create src directory and add the following files with the content shown below:

App.tsx
bootstrap.tsx
index.ts
index.html

App.tsx

import * as React from "react";

const App = () => (
<fieldset>
<legend>MFE</legend>
<h1>
<center>App 1</center>
</h1>
</fieldset>
);

export default App;

bootstrap.tsx

import * as React from "react";
import { createRoot } from "react-dom/client";

import App from "./App";

const container = document.getElementById('app');
const root = createRoot(container);

root.render(<App />);

index.ts

import "./bootstrap"

index.html

<!DOCTYPE html>
<html>

<head>
<title>
<%= htmlWebpackPlugin.options.title %>
</title>
</head>

<body>
<div id="app">
</body>

</html>

E. Setup webpack and module federation configuration for app1 (You’ve to create build-utils folder within app1):

mkdir build-utils
cd build-utils

create following files

webpack.common.js
webpack.config.js
webpack.dev.js
webpack.prod.js

webpack.common.js

const path = require("path");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HotModuleReplacementPlugin = require("webpack").HotModuleReplacementPlugin;
const deps = require("../package.json").dependencies;

module.exports = {
entry: path.resolve(__dirname, "..", "./src/index.ts"),
module: {
rules: [
{
test: /\.(js)$/,
exclude: /node_modules/,
use: ["babel-loader"],
},
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: "ts-loader",
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
plugins: [
new CleanWebpackPlugin(),
new HotModuleReplacementPlugin(),
new ModuleFederationPlugin({
name: "app1", // Name of micro-frontend
library: { type: "var", name: "app1" },
filename: "remoteEntry.js", // Name of remote entry file
exposes: { // list of components exposed and source file mapping
"./App": "./src/App",
},
shared: { // List of dependencies shared accross micro-frontends
...deps,
react: {
eager: true,
import: "react", // the "react" package will be used a provided and fallback module
shareKey: "react", // under this name the shared module will be placed in the share scope
shareScope: "legacy", // share scope with this name will be used
singleton: true, // only a single version of the shared module is allowed
},
"react-dom": {
eager: true,
import: "react-dom", // the "react" package will be used a provided and fallback module
shareKey: "react-dom", // under this name the shared module will be placed in the share scope
shareScope: "legacy", // share scope with this name will be used
singleton: true, // only a single version of the shared module is allowed
},
},
}),
new HtmlWebpackPlugin({
title: "App 1",
template: path.resolve(__dirname, "..", "./src/index.html"),
}),
],
output: {
path: path.resolve(__dirname, "..", "./dist"),
filename: "bundle.js",
},
devServer: {
port: 3001,
static: path.resolve(__dirname, "..", "./dist"),
hot: false,
liveReload: true,
},
};

webpack.config.js

const { merge } = require("webpack-merge");

const commonConfig = require("./webpack.common.js");

module.exports = ({ env }) => {
const envConfig = require(`./webpack.${env}.js`);

return merge(commonConfig, envConfig);
};

webpack.dev.js

const { DefinePlugin } = require("webpack");

module.exports = {
mode: "development",
plugins: [
new DefinePlugin({
"process.env": {
"NODE_ENV": JSON.stringify("development"),
}
}),
],
devtool: "eval-source-map",
};

webpack.prod.js

const { DefinePlugin } = require("webpack");

module.exports = {
mode: "production",
plugins: [
new DefinePlugin({
"process.env": {
"NODE_ENV": JSON.stringify("production"),
}
}),
],
devtool: "source-map",
};

F. Now, add the script in package.json file for app1:

{
...
"scripts": {
"start": "webpack serve --config build-utils/webpack.config.js --env env=dev",
"build": "webpack --config build-utils/webpack.config.js --env env=prod"
},
...
}

Repeat these steps for app2, replacing “app1” with “app2” where appropriate.

5. Setup the core-ui application: Inside the packages directory, create a new directory for the core-ui application:

mkdir core-ui
cd core-ui
yarn init -y

A. Install the necessary dependency for core-ui:

{
"name": "core-ui",
"version": "1.0.0",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}

B. Create .babelrc file and add the following files with the content shown below:

{
"extends": "../.babelrc"
}

C. Create tsconfig.json file and add the following files with the content shown below:

{
"compilerOptions": {
"sourceMap": true,
"jsx": "react",
"target": "ES6"
}
}

D. Create src directory and add the following files with the content shown below:

App.tsx
./components/ErrorBoundry.tsx
./components/Fallback.tsx
bootstrap.tsx
index.ts
index.html

ErrorBoundry.tsx

import * as React from "react";

interface IProps {
children: React.ReactNode;
appName: string;
}

interface IState {
hasError: boolean;
}

class ErrorBoundary extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(): IState {
return { hasError: true };
}

public render() : React.ReactNode {
if (this.state.hasError) {
return <p>Failed to load {this.props.appName}</p>;
}

return <>{this.props.children}</>
}
}

export default ErrorBoundary;

Fallback.tsx

import * as React from "react";

const Fallback = () => {
const isDevelopment = process.env.NODE_ENV === "development";

return (
<fieldset>
<legend>MFE</legend>
<center>
<p>
{isDevelopment ? "Failed to load. Please check if dev-server is running." : 'This page failed to load'}
</p>
</center>
</fieldset>
)
};

export default Fallback;

App.tsx

import * as React from "react";
import ErrorBoundary from "./components/ErrorBoundry";

// @ts-ignore
const App1 = React.lazy(() => import("app1/App").catch(() => {
// @ts-ignore
return import("./components/Fallback");
}));

// @ts-ignore
const App2 = React.lazy(() => import("app2/App").catch(() => {
// @ts-ignore
return import("./components/Fallback");
}));

interface AppProps {
title: string
}

const App: React.FC<AppProps> = ({ title }) => (
<div>
<h1><center>{title}</center></h1>
<ErrorBoundary appName="App 1">
<React.Suspense fallback="Loading App1">
<App1 />
</React.Suspense>
</ErrorBoundary>
<ErrorBoundary appName="App 2">
<React.Suspense fallback="Loading App2">
<App2 />
</React.Suspense>
</ErrorBoundary>
</div>
);

export default App;

bootstrap.tsx

import * as React from "react";
import { createRoot } from "react-dom/client";

import App from "./App";

const title = "Building Scalable and Modular Web Applications with React, Micro Frontends and Webpack Module Federation";

const container = document.getElementById('app');
const root = createRoot(container);

root.render(<App title={title} />);

index.ts

import "./bootstrap"

index.html

<!DOCTYPE html>
<html>
<head>
<script src="<%= htmlWebpackPlugin.options.app2RemoteEntry %>"></script>
<script src="<%= htmlWebpackPlugin.options.app1RemoteEntry %>"></script>
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div>
<div id="app">
</div>
</body>
</html>

E. Setup webpack and module federation configuration for core-ui (You’ve to create build-utils folder within core-ui):

mkdir build-utils
cd build-utils

create following files

webpack.utils.js
webpack.common.js
webpack.config.js
webpack.dev.js
webpack.prod.js

webpack.utils.js

function getDevRemoteEntryUrl(port) {
return `//localhost:${port}/remoteEntry.js`;
}

function getProdRemoteEntryUrl(domain) {
return `${domain}/remoteEntry.js`;
}

module.exports = {
getDevRemoteEntryUrl,
getProdRemoteEntryUrl,
};

webpack.common.js

const path = require("path");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require("../package.json").dependencies;
const HotModuleReplacementPlugin =
require("webpack").HotModuleReplacementPlugin;

module.exports = {
entry: path.resolve(__dirname, "..", "./src/index.ts"),
module: {
rules: [
{
test: /\.(js)$/,
exclude: /node_modules/,
use: ["babel-loader"],
},
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: "ts-loader",
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
plugins: [
new CleanWebpackPlugin(),
new HotModuleReplacementPlugin(),
new ModuleFederationPlugin({
name: "core_ui", // Name of the core-ui micro-frontend
library: { type: "var", name: "core_ui" },
remotes: { // Remote entry point of other micro-frontend app's
app1: "app1", // app1
app2: "app2", // app2
},
shared: { // List of dependencies shared accross micro-frontends
...deps,
react: {
eager: true,
},
"react-dom": {
eager: true,
import: "react-dom", // the "react" package will be used a provided and fallback module
shareKey: "react-dom", // under this name the shared module will be placed in the share scope
shareScope: "legacy", // share scope with this name will be used
singleton: true, // only a single version of the shared module is allowed
},
},
})
],
output: {
path: path.resolve(__dirname, "..", "./dist"),
filename: "bundle.js",
},
devServer: {
port: 3000,
contentBase: path.resolve(__dirname, "..", "./dist"),
hot: false,
liveReload: true,
},
};

webpack.config.js

const { merge } = require("webpack-merge");

const commonConfig = require("./webpack.common.js");

module.exports = ({ env }) => {
const envConfig = require(`./webpack.${env}.js`);

return merge(commonConfig, envConfig);
};

webpack.dev.js

const path = require("path");
const { DefinePlugin } = require('webpack');
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { getDevRemoteEntryUrl } = require("./webpack.utils");

module.exports = {
mode: "development",
plugins: [
new DefinePlugin({
"process.env": {
"NODE_ENV": JSON.stringify("development"),
}
}),
new HtmlWebpackPlugin({
title: "Building Scalable and Modular Web Applications with React, Micro Frontends and Webpack Module Federation",
template: path.resolve(__dirname, "..", "./src/index.html"),
app1RemoteEntry: getDevRemoteEntryUrl(3001), // change this according you're needs
app2RemoteEntry: getDevRemoteEntryUrl(3002), // change this according you're needs
})
],
devtool: "eval-source-map",
};

webpack.prod.js

const path = require("path");
const { DefinePlugin } = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { getProdRemoteEntryUrl } = require("./webpack.utils");

module.exports = {
mode: "production",
plugins: [
new DefinePlugin({
"process.env": {
NODE_ENV: JSON.stringify("production"),
},
}),
new HtmlWebpackPlugin({
title: "Building Scalable and Modular Web Applications with Micro Frontends and Webpack Module Federation",
template: path.resolve(__dirname, "..", "./src/index.html"),
app1RemoteEntry: getProdRemoteEntryUrl("https://app1.xyz"), // change this according you're needs
app2RemoteEntry: getProdRemoteEntryUrl("https://app2.xyz"), // change this according you're needs
}),
],
devtool: "source-map",
};

F: Add the script in package.json file for core-ui:

{
...
"scripts": {
"start": "webpack serve --config build-utils/webpack.config.js --env env=dev",
"build": "webpack --config build-utils/webpack.config.js --env env=prod"
},
...
}

6. Start the Applications: Now, you can start all your applications.

For App1:

cd ../app1
yarn start

For App2:

cd ../app2
yarn start

For the CoreUI application:

cd ../core-ui
yarn start

Or, You can also make some changes on the root level package.json file for starting the apps:

  "scripts": {
"start:core-ui": "yarn workspace core-ui start",
"start:app1": "yarn workspace app1 start",
"start:app2": "yarn workspace app2 start"
},

Your CoreUI application should now be able to consume modules from App1 and App2 using Module Federation.

Architecture diagram between all the micro-apps

You can access the full source code on the GitHub repository using the following link: GitHub Repository 📌

Please note that this is a basic setup, and in a real-world scenario, you may need to configure your applications further based on your specific requirements and add more complex features like routing and state management. Additionally, make sure you have Node.js and Yarn installed on your system before starting this setup.

Conclusion

Micro frontends using module federation can be a powerful approach for building scalable and maintainable web applications, especially in large and complex projects. However, it’s essential to carefully plan and manage the architecture to mitigate the challenges associated with it. Choosing the right level of granularity for your micro frontends, implementing efficient communication strategies, and maintaining version control and dependency management are crucial factors in the success of this architectural pattern.

For more updates on the latest tools and technologies, follow the Simform Engineering blog.

Follow Us: Twitter | LinkedIn

--

--

Asif Vora
Simform Engineering

I’m JavaScript Developer 🚀 having experience in building Web and Mobile applications with JavaScript / Reactjs / React Native / Redux.