Better Programming

Advice for programmers.

Follow publication

Micro Frontends Using Single-SPA and Module Federation

Let’s build an app with micro frontends together

Manato
Better Programming
Published in
12 min readJan 11, 2021

Image by Author

In this article, I will walk you through how to implement a micro frontends app with single-spa and module federation in Webpack.

Micro Frontends

Micro frontends have been around since 2016 in front-end developments. In a nutshell, the idea of micro frontends is to break down the monolith app into smaller pieces to make it easier to maintain.

Micro frontend allows you to:

  • Deploy independently
  • Use multiple UI frameworks (React, Vue.js, and Angular) in one place
  • Decouple a piece of UI components from a large codebase

There are also drawbacks such as the complexity of the initial setup and performance issues caused by duplicated code, but single-spa and module federation can resolve them

single-spa

single-spa is a framework that enables you to quickly set up a micro front-ends app.

By abstracting lifecycles for the entire application, single-spa allows you to use multiple UI frameworks on the same page.

It also gives the ability to:

  • Deploy independently
  • Perform lazy loading
  • Introduce new frameworks without any modification for an existing app

With single-spa, you have two options for choosing the ecosystem for sharing dependencies:

The idea of import maps is to map the URL of the JavaScript import statement to libraries. For example, if you import moment to your code,

import moment from "moment";

You will have to supply the browser with the following maps:

<script type="importmap">
{
"imports": {
"moment": "/node_modules/moment/src/moment.js"
}
}
</script>

This can control what URLs get fetched by the import.

With this new feature and webpack externals, which can exclude some libraries, it will generate the bundled without duplicated modules in an app.

But we won’t be using this in our app. Instead, we’ll take advantage of new technology in Webpack 5.

Module Federation in Webpack

Module federation was introduced in Webpack 5. The main purpose of it is to share code and libraries between applications.

With module federation, any JavaScript code — such as business logic or libraries, and state management code — can be shared between applications. It can make multiple micro frontend apps work together as if you develop a monolith app.

You may be wondering what the difference between micro frontend and module federation is. They actually cover a lot of the same ground. But there is a difference.

The only purpose of micro front-ends is to share UI between applications, not JavaScript logic. So, the bundled code can include duplicated code.

The purpose of module federation is to share JavaScript code between applications, not UI. So, the shared code can be used in an app.

They are actually complementary. So, with module federation in a micro-frontend app, you can avoid duplicated code and provide shared UI components across the entire app.

Overview of the app

By now, we’ve covered the basic idea of micro frontends and module federation.

Next, we’ll walk through implementing a simple demo app to understand how they work together.

The demo app

Let’s take a look at an overview of what makes up the app:

  • A home app that handles all of the micro-frontend apps.
  • A navigation app that shows header and footer UI made by React.
  • A body app that shows the body element of the page made by Vue.js.

Set Up Root package.json

Before implementing the micro-frontends app, we create a package.json at the root. The three apps are included in one repo as monorepo, so we’ll use yarn workspaces to share some packages across the entire app.

Create a package.json at root, like so:

{
"name": "micro-frontend",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"start": "concurrently \"wsrun --parallel start\""
},
"devDependencies": {
"concurrently": "^5.3.0",
"wsrun": "^5.2.4",
"@types/node": "^14.14.17",
"@typescript-eslint/eslint-plugin": "^4.11.1",
"@typescript-eslint/parser": "^4.11.1",
"babel-eslint": "^10.1.0",
"eslint": "7.16.0",
"eslint-config-prettier": "^7.1.0",
"eslint-config-react-app": "^6.0.0",
"eslint-formatter-friendly": "^7.0.0",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-flowtype": "5.2.0",
"eslint-plugin-import": "2.22.1",
"eslint-plugin-jsx-a11y": "6.4.1",
"eslint-plugin-prettier": "^3.3.0",
"eslint-plugin-react": "7.22.0",
"eslint-plugin-react-hooks": "4.2.0",
"prettier": "^2.2.1",
"typescript": "4.1.3",
"single-spa": "^5.8.3",
"webpack": "^5.72.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.8.1",
"html-webpack-plugin": "^5.5.0"
}
}

Then, install the dependencies.

yarn

A Home App

Next, we create a home app that coordinates each micro-frontend app. A home handles the task that renders the HTML page and the JavaScript that registers the applications.

Let’s take a look at the home structure:

├── packages
│ └── home
│ ├── package.json
│ ├── public
│ │ └── index.html
│ ├── src
│ │ └── index.ts
│ ├── tsconfig.json
│ └── webpack.config.js

Then, we create these files step-by-step.

First, create a package.json:

{
"name": "home",
"scripts": {
"start": "webpack serve --port 3000",
"build": "webpack --mode=production"
},
"version": "1.0.0",
"private": true,
"devDependencies": {
"@babel/core": "^7.8.6",
"@babel/preset-typescript": "^7.12.7",
"babel-loader": "^8.2.2"
}
}

And create a webpack.config.js:

const HtmlWebpackPlugin = require('html-webpack-plugin')
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')
const path = require('path')
const outputPath = path.resolve(__dirname, 'dist')

module.exports = {
entry: './src/index',
cache: false,

mode: 'development',
devtool: 'source-map',

optimization: {
minimize: false,
},

output: {
publicPath: 'http://localhost:3000/',
},

resolve: {
extensions: ['.jsx', '.js', '.json', '.ts', '.tsx'],
},

devServer: {
static: {
directory: outputPath,
},
},

module: {
rules: [
{
test: /\.tsx?$/,
loader: require.resolve('babel-loader'),
options: {
presets: [require.resolve('@babel/preset-typescript')],
},
},
],
},

plugins: [
new ModuleFederationPlugin({
name: 'home',
library: { type: 'var', name: 'home' },
filename: 'remoteEntry.js',
remotes: {},
exposes: {},
shared: [],
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
}

This is a basic configuration for TypeScipt, but you can find the module federation imported in the plugins section.

To understand how it works, we’ll cover the three essential concepts.

remotes

The remotes fields take the name of the federated micro-frontend app to consume the code. In this case, the home app will use a navigation app and a body app so we need to specify the name of them in the remotes field, like so:

remotes: {
navigation: 'navigation',
body: 'body',
},

We will get into a lot more detail when implementing a navigation and body app.

exposes

The exposes field is used to export files to other applications. The key should be the exported name that is consumed in other applications. For example, if you export the component, Button from your code, then write it like this:

exposes: {
Button: './src/Button',
},

shared

Ths shared is a list of all of the dependencies shared in support of exported files. For instance, if you export the Vue.js component, you will have to list vue in the shared section, like so:

shared: ['vue'],

This will prevent an app from including duplicate libraries and will share a single instance across apps, even though the webpack compiler is completely separate.

Next, we will create public/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="https://cdn.rawgit.com/filipelinhares/ress/master/dist/ress.min.css" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Oswald:300,400,500,600,700|Roboto:400,700&display=swap"
/>
<style>
html, body {
scroll-behavior: smooth;
width: 100%;
height: 100%;
}

body {
font-family: 'Roboto';
}

table {
border-collapse: collapse;
border-spacing: 0;
}

ul {
list-style-type: none;
}

main > * > * {
height: 100%;
}
</style>
</head>
<body>
<div style="height: 100%; display: flex; flex-direction: column;">
</div>
</body>
</html>

And create a src/index.ts:

import { start } from 'single-spa'

start()

This file will be used to register the micro-frontends app consumed in the home app. For now, we just execute the start function to boost the app.

A Navigation App

Now that we’ve set up the home app, let’s implement the navigation app.

Here’s the structure of the navigation app:

├── packages
│ ├── home
│ │ ├── package.json
│ │ ├── public
│ │ │ └── index.html
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ └── webpack.config.js
│ └── navigation
│ ├── package.json
│ ├── src
│ │ ├── Footer.tsx
│ │ ├── Header.tsx
│ │ └── index.ts
│ ├── tsconfig.json
│ ├── webpack.config.js

We will export the Footer and Header component to the home app.

To do that, we first create those files. Create a navigation/package.json:

{
"name": "navigation",
"scripts": {
"start": "webpack serve --port 3001",
"build": "webpack --mode=production"
},
"version": "1.0.0",
"private": true,
"devDependencies": {
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@babel/core": "^7.8.6",
"@babel/preset-react": "^7.12.10",
"@babel/preset-typescript": "^7.12.7",
"babel-loader": "^8.2.2",
"single-spa-react": "^3.2.0"
},
"dependencies": {
"react": "^17.0.1",
"react-dom": "^17.0.1"
}
}

A navigation app will be made by React, so install the dependencies and create a navigation/webpack.config.js:

const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')
const path = require('path')
const outputPath = path.resolve(__dirname, 'dist')

module.exports = {
entry: './src/index',
cache: false,

mode: 'development',
devtool: 'source-map',

optimization: {
minimize: false,
},

output: {
publicPath: 'http://localhost:3001/',
},

resolve: {
extensions: ['.jsx', '.js', '.json', '.ts', '.tsx'],
},

devServer: {
static: {
directory: outputPath,
},
},

module: {
rules: [
{
test: /\.tsx?$/,
loader: require.resolve('babel-loader'),
options: {
presets: [
require.resolve('@babel/preset-react'),
require.resolve('@babel/preset-typescript'),
],
},
},
],
},

plugins: [
new ModuleFederationPlugin({
name: 'navigation',
library: { type: 'var', name: 'navigation' },
filename: 'remoteEntry.js',
remotes: {},
exposes: {
'./Header': './src/Header',
'./Footer': './src/Footer',
},
shared: ['react', 'react-dom', 'single-spa-react'],
}),
],
}

Check out the publicPath and ModuleFederationPlugin.

publicPath is the base name of the remote URL which will be used in the home app. In this case, the navigation app will be served at http://localshot:3001.

The exposes field takes two components, Header and Footer. This will export them to the home app.

As stated before, we will have to list the shared libraries in shared section. In this case, we need to write react, react-dom, and single-spa-react.

Next, create a src/Header.tsx and src/Footer.tsx:

import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react'

const Header: React.VFC = () => {
return (
<header
style={{
width: '100%',
background: '#20232a',
color: '#61dafb',
padding: '2rem',
fontWeight: 'bold',
}}
>
Header from React
</header>
)
}

const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: Header,
})

export const bootstrap = lifecycles.bootstrap
export const mount = lifecycles.mount
export const unmount = lifecycles.unmount
import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react'

const Footer: React.VFC = () => {
return (
<footer
style={{
width: '100%',
background: '#20232a',
color: '#61dafb',
padding: '2rem',
minHeight: '100px',
fontWeight: 'bold',
}}
>
Footer from React
</footer>
)
}

const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: Footer,
})

export const bootstrap = lifecycles.bootstrap
export const mount = lifecycles.mount
export const unmount = lifecycles.unmount

Register for the Navigation App

Now we’re ready to start exporting the navigation app.

Next, we register it in the home app. To register the micro-frontends app, the following steps are required:

  • Include script tag
  • List in remotes
  • Add a registration
  • Include a container div

Let’s go through these steps.

Include script tag

First, to consume the code from the navigation app, we have to include it in the HTML file.

Go to home/public/index.html and include the script tag.

<!DOCTYPE html>
<html lang="en">
<head>
+ <script src="http://localhost:3001/remoteEntry.js"></script>
</head>

As stated before, the publicPath of a navigation app is http://localhost:3001 and the file name is remoteEntry.js.

List in remotes

Next, go to home/webpack.config.js and specify the remotes section:

remotes: {
+ 'home-nav': 'navigation',
},

navigation is the name of the configuration in navigation/webpack.config.js:

plugins: [
new ModuleFederationPlugin({
+ name: 'navigation',
library: { type: 'var', name: 'navigation' },
filename: 'remoteEntry.js',
remotes: {},
exposes: {
'./Header: './src/Header',
'./Footer': './src/Footer',
},
shared: ['react', 'react-dom', 'single-spa-react'],
}),
],

The home-nav is the name used in the home app.

You can change the name of the import by changing the key to whatever you want.

Add a registration

Next, we register the navigation app in home/src/index.ts:

import { registerApplication, start } from 'single-spa'

registerApplication(
'header',
// @ts-ignore
() => import('home-nav/Header'),
(location) => location.pathname.startsWith('/'),
)

registerApplication(
'footer',
// @ts-ignore
() => import('home-nav/Footer'),
(location) => location.pathname.startsWith('/'),
)

start()

The registerApplication takes three things:

  • An application name
  • A function to load the application’s code
  • A function that determines when the application is active or inactive

The import function specifies the name of the remotes key as stated above.

() => import('home-nav/Header'),

Include a container div

Lastly, we write a div container that is used to include a Header and Footer component.

Go to home/public/index.html and add them:

<body>
<div style="height: 100%; display: flex; flex-direction: column;">
<div id="single-spa-application:header"></div>
<main>
body
</main>
<div id="single-spa-application:footer"></div>
</div>
</body>

By default, with no option, single-spa will go find single-spa-application:{app name} and render the HTML below.

In this case, we’ve already registered the Header and Footer as header and footer so it will find the id of single-spa-application:header and single-spa-application:footer.

Let’s try this out.

Before that, make sure to install the dependencies:

yarn

And start the server at the root:

yarn start

So, navigate to http://localhost:3000 and you will find that two React components rendered successfully.

It seems to work well.

A Body App

Next, we will create a body app. A body app will be made by Vue.js but the process of implementation is almost the same as the one in the navigation app.

Let’s do it quickly.

The structure of the body app is:

├── packages
│ ├── body
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── App.vue
│ │ │ ├── app.js
│ │ │ └── index.js
│ │ ├── tsconfig.json
│ │ └── webpack.config.js

Create a body/package.json:

{
"name": "body",
"scripts": {
"start": "webpack serve --port 3002",
"build": "webpack --mode=production"
},
"version": "1.0.0",
"private": true,
"devDependencies": {
"vue-loader": "16.0.0-beta.7",
"@babel/core": "^7.8.6",
"@babel/preset-env": "^7.10.3",
"@vue/compiler-sfc": "^3.0.0-rc.10",
"babel-loader": "^8.2.2",
"css-loader": "^3.5.3",
"postcss-loader": "^4.1.0",
"style-loader": "2.0.0",
"node-sass": "^5.0.0",
"vue-style-loader": "^4.1.2",
"sass-loader": "^10.1.0"
},
"dependencies": {
"autoprefixer": "^10.1.0",
"postcss": "^8.2.1",
"vue": "^3.0.0",
"single-spa-vue": "^2.1.0"
}
}

And create a body/webpack.config.js:

const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')
const path = require('path')
const outputPath = path.resolve(__dirname, 'dist')
const { VueLoaderPlugin } = require('vue-loader')

module.exports = {
entry: './src/index',
cache: false,

mode: 'development',
devtool: 'source-map',

optimization: {
minimize: false,
},

output: {
publicPath: 'http://localhost:3002/',
},

resolve: {
extensions: ['.jsx', '.js', '.json', '.ts', '.tsx', '.vue'],
},

devServer: {
static: {
directory: outputPath,
},
},

module: {
rules: [
{
test: /\.s[ac]ss$/i,
use: [
'vue-style-loader',
'style-loader',
'css-loader',
'postcss-loader',
'sass-loader',
],
},
{
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.js$/,
loader: 'babel-loader',
},
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
},
exclude: /node_modules/,
},
],
},

plugins: [
new VueLoaderPlugin(),
new ModuleFederationPlugin({
name: 'body',
library: { type: 'var', name: 'body' },
filename: 'remoteEntry.js',
remotes: {},
exposes: {
Body: './src/app',
},
shared: ['vue', 'single-spa', 'single-spa-vue'],
}),
],
}

Create a body/src/App.vue and body/src/app.js:

<template>
<div class="body">
<div class="inner">
Body from Vue.js
</div>
</div>
</template>

<style scoped lang="scss">
.body {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}

.inner {
width: 300px;
height: 300px;
display: flex;
justify-content: center;
align-items: center;
background: #42b983;
color: #ffffff;
}
</style>
import singleSpaVue from 'single-spa-vue'
import { h, createApp } from 'vue'
import App from './App.vue'

const lifecycles = singleSpaVue({
createApp,
appOptions: {
render() {
return h(App, {
props: {
// single-spa props are available on the "this" object. Forward them to your component as needed.
// https://single-spa.js.org/docs/building-applications#lifecyle-props
name: this.name,
mountParcel: this.mountParcel,
singleSpa: this.singleSpa,
},
})
},
},
})

export const bootstrap = lifecycles.bootstrap
export const mount = lifecycles.mount
export const unmount = lifecycles.unmount

Register for the Body App

Next, we will register the body in the home app as we did before for the navigation.

Go to home/public/index.html and add a script tag:

<head>
<script src="http://localhost:3001/remoteEntry.js"></script>
+ <script src="http://localhost:3002/remoteEntry.js"></script>
</head>

Then go to home/webpack.config.js and add home-body:

remotes: {
'home-nav': 'navigation',
+ 'home-body': 'body',
},

Then, go to home/src/index.ts to register it:

import { registerApplication, start } from 'single-spa'

registerApplication(
'header',
// @ts-ignore
() => import('home-nav/Header'),
(location) => location.pathname.startsWith('/'),
)

registerApplication(
'footer',
// @ts-ignore
() => import('home-nav/Footer'),
(location) => location.pathname.startsWith('/'),
)

registerApplication(
'body',
// @ts-ignore
() => import('home-body/Body'),
(location) => location.pathname.startsWith('/'),
)

start()

Lastly, add a div container for that in home/public/index.html:

<body>
<div style="height: 100%; display: flex; flex-direction: column;">
<div id="single-spa-application:header"></div>
<main style="display: flex; flex: 1;">
<div id="single-spa-application:body" style="width: 100%"></div>
</main>
<div id="single-spa-application:footer"></div>
</div>
</body>

Let’s test this out.

Run install dependencies:

yarn

And start the server at the root:

yarn start

Navigate to http://localhost:3000:

Great — you can see that the Vue component was rendered successfully.

Conclusion

In this article, we’ve covered how to implement a micro-frontends app using Single SPA and Module Federation in Webpack.

Micro-frontends benefit team development especially larger team in terms of flexibility, extensibility, and maintainability because it provides you with independent deploying, the use of mixed frameworks, and separation of concerns.

Although there are downsides, such as the complexity of configuration and learning costs, I think that the advantages stated above outweigh the drawbacks.

I hope this article has got you interested in micro frontends!

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Responses (8)

Write a response