π·ββοΈ Building a Micro-Frontend With Single-SPA
Have you ever wondered why many people prefer frameworks over reinventing the wheel by writing everything in their vanilla/raw form? The answer is simple, simplicity and conformity.
That's exactly what Single-Spa does in the micro frontend ecosystem. Single-Spa removes the complexity in setting up a micro frontend app by providing boilerplates and scaffolding the entire process.
So you can get your micro frontend apps up and running in a short period of time with the help of Single-Spa!
Single-Spa is basically a Javascript router for front-end micro frontends. It allows us to build and connect many Single Page Applications (SPAs) micro-frontends and provide a means of communication for them so that they can function seamlessly together.
There are many limitations that we face when building our frontend applications. Some of these problems include the inability to deploy some part of the frontend application. Without deploying the whole application.
π€· Why use Single-Spa?
Other micro frontend frameworks exist, but below are some reasons why we'll be using Single-Spa to manage our micro-frontend application.
- Use many Javascript frameworks in one application
This is a general feature of micro-frontends. Single-spa being a micro-frontend tool enables you to use different JavaScript frameworks in the same application.
- Deploy your micro-frontends independently
With Single-spa, you can easily deploy each part of your application independently with deploying the whole application. This can be very handy as it promotes agile development.
- Lazy loading for improved load time
Single-spa loads only essential components of your application on the browser. The other units can be loaded later on as per use. This practice will improve the loading speed of webpages.
- It supports routing between micro-frontends and shared resources
Single-Spa serves basically as a router between micro-frontend and shared dependencies. It accomplishes this task with its importmap function.
State Management in Micro Frontends
State management is quite tricky in the micro frontend ecosystem. Using a single global state manager like redux, mobx, and other global state management libraries is not a recommended approach by the Single-Spa team.
Instead, if you wish to use a state manager in your application, it's advisable to use a state management tool to manage the state of a single micro frontend instead of a single store for all of your micro frontends.
This is so, because using a global store to manage the state of an application makes it difficult to deploy our application independently. Hence, use local component state for your application components, or a store for each of your micro frontends.
For more details on state management, visit Inter-app communication.
Module Federation
Module Federation is a feature introduced in Webpack 5, which allows you to share code between different applications running in separate environments, such as (micro frontends).
It enables applications to consume code from other applications as if it were locally available, without the need for a build step or a separate server. This allows you to build modular, scalable applications that can be developed and deployed independently.
π§ Example : you could have an e-commerce application with a separate checkout application, and use Module Federation to share code between them, such as the shopping cart module.
π» The tech under the hood : Module Federation works by creating a remote entry point in one application, and a remote container in another. The remote entry point exposes modules that can be consumed by other applications, and the remote container consumes those modules as if they were locally available.
Single-Spa Setup and Usage
We've been talking about singles. It's now time to get our feet wet by setting up a micro frontend application with Single-Spa. The process is rigorous. We're going to cover everything from scratch, so you don't have to worry about files that we didn't create.
Overview of the Application We're Building
We've so far covered what Single-Spa and Module Federation are. It's time to build our first micro frontend project application. I know you're wondering what this application does. Below is a breakdown of the Single-Spa and Module Federation application we're going to build in this section.
- A Home app that combines and uses our micro frontend apps.
- A React Navigation app that holds header and footer UI.
- A Vue.js Body app that shows the body element of the page.
The aforementioned is the overview of the full application we'll be building. You can check out the source code at : https://github.com/xotoboil/xotoboil-simple-multifront
Letβs start the building process ‡οΈ
1. Setting up the root folder
π¦ The package.json
Before implementing the micro-frontends app, we need a package.json
file at the root. The three apps are included in one repo as a monorepo, so weβll use yarn workspaces to share some packages across the entire app.
Create the package.json
at project root
Before we get started with our micro-frontends app creation, create a package.json
file at the root. I'll be using a yarn workspace.
Add the code below to package.json
:
{
"name": "xotoboil-simple-multifront",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"start": "concurrently \"wsrun --parallel start\""
},
"devDependencies": {
"concurrently": "^5.3.0",
"html-webpack-plugin": "5.5.0",
"single-spa": "^5.8.3",
"typescript": "5.0.2",
"webpack": "latest",
"webpack-cli": "^4.3.0",
"webpack-dev-server": "^3.11.1",
"wsrun": "^5.2.4"
},
"dependencies": {
"@types/single-spa-react": "^4.0.0"
}
}
Run the code below to install all dependencies :
yarn
2. Building the Home App
This Home App holds other micro frontends applications. This App is where all the other micro frontend applications are used together. It performs the task of rendering HTML pages and the JavaScript that registers the applications.
Our Home folder structure will look like this :
βββ packages
β βββ home
β βββ public
β β βββ index.html
β βββ src
β β βββ index.ts
β βββ package.json
β βββ tsconfig.json
β βββ webpack.config.js
We'll create these files one after the other.
- Create the
packages
folder at the root of the project. Also create ahome
folder inside it.mkdir ./packages/home
- Create the
package.json
file inside the packages folder. Add the following code to it :
{
"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"
}
}
- Next, create the
tsconfig.json
file and add the following code :
{
"compilerOptions": {
"target": "es5",
"moduleResolution": "node",
"module": "ESNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
- Finally, create the
webpack.config.js
file and add the codes below :
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')
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: {
contentBase: 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: [ 'react' ],
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
}
This is a basic webpack configuration. You'll understand how everything works better in the following section using remotes
, exposes
, and shared
parameters.
1οΈβ£ remotes
The remotes field takes the name of the federated micro-frontend app to consume the code. Our home app will use a navigation app and body app in this case. Hence, we need to specify the name of them in the remotes
field. Here's how the remotes parameter will soon look like for our Home App.
remotes: {
navigation: 'navigation',
body: 'body',
}
2οΈβ£ exposes
The exposes
parameter is used to export files to other applications. For example, if you want other applications to access button component, you can do it this way.
exposes: {
Button: './src/Button',
}
βWe arenβt going to build a Button component for now, so keep the exposes object blank."
3οΈβ£ shared
This parameter has a list of all shared
dependencies in support of exported files. For instance, if you export React's components, you will have to list them containing React.
shared: ['react']
This prevents duplication of packages and libraries in our micro frontend application.
- Next, create
public/index.html
file and add the code below :
<!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" />
</head>
<body>
<main>
body
</main>
</body>
</html>
- And finally create a
src/index.ts
file and add the codes below :
import { start } from 'single-spa'
start()
Weβll soon add more to index.ts
file when we register a new application.
The files we created so far will be used to register our micro-frontends app in the home app.
3. Building the Navigation App
We're done with the setup of our home app, letβs implement the navigation app in React.
Below is the folder structure of our micro frontend application that weβll get after adding 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
In this, we are going to create two components β Header and Footer. We will export the components to the home app that we built earlier.
To do that, we first have to create navigation app files.
- Create a folder called
navigation
inside the packages folder
- Create a
package.json
file inside the navigation folder and add the code below :
{
"name": "navigation",
"scripts": {
"start": "webpack serve --port 3001",
"build": "webpack --mode=production"
},
"version": "1.0.0",
"private": true,
"devDependencies": {
"@babel/core": "^7.8.6",
"@babel/preset-react": "^7.12.10",
"@babel/preset-typescript": "^7.12.7",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"babel-loader": "^8.2.2",
"single-spa-react": "^3.2.0"
},
"dependencies": {
"@babel/preset-env": "^7.20.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"ts-loader": "^9.4.2"
}
}
We will use React in building our navigation app. So we will be installing it as a dependency as you can see above.
- Next, create a
tsconfig.json
file inside the navigation folder. Add this code inside it :
{
"compilerOptions": {
"jsx": "react",
"target": "es5",
"moduleResolution": "node",
"module": "ESNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
- Finally, create a
webpack.config.js
inside navigation folder and add the code below :
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const path = require("path");
const outputPath = path.resolve(__dirname, "dist");
module.exports = {
entry: "./src/index.ts",
cache: false,
mode: "development",
devtool: "source-map",
optimization: {
minimize: false,
},
output: {
publicPath: "http://localhost:3001/",
},
resolve: {
extensions: [".jsx", ".js", ".json", ".ts", ".tsx"],
},
devServer: {
contentBase: outputPath,
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: ["babel-loader"],
},
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: ["ts-loader"],
},
],
},
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"],
}),
],
};
π·ββοΈ More about the above webpack.config.js
If you go through the codes, you'll see a line containing publicPath
inside output
output: {
publicPath: "http://localhost:3001/",
}
publicPath
is the base name of the remote URL that our home app will use. In this case, the navigation app will be served at http://localshot:3001
Header
and Footer
components will be exported to the Home app as they're in the values of exposes parameter.
exposes: {
"./Header": "./src/Header",
"./Footer": "./src/Footer",
},
Shared has a list of libraries that we are sharing. In this case, we need to write react
, react-dom
, and single-spa-react
. As we're building our application with React library.
shared: ["react", "react-dom", "single-spa-react"]
4. Creating Header and Footer Components
- Create a
src/Header.tsx
file and insert the code below :
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: "#6565bf",
color: "#FFFFFFFF",
padding: "1rem",
minHeight: "50px",
}}
>
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;
- Create a
src/Footer.tsx
file and insert the code below :
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: "#6565bf",
color: "#FFFFFFFF",
padding: "1rem",
minHeight: "50px",
}}
>
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;
- Finally, create a
src/index.ts
file and add the following code :
import "./Footer"
import "./Header"
5. Registering the Navigation Application
We're done with our Navigation App, It's now time to register it in the Home application.
To register the micro-frontend app, the following steps are required :
- Include the
script
tag - List in remotes
- Register the app
- Include a container div
We'll take the steps one after the other.
- Include the script tag in
home/public/index.html
file
In order to use the code from the navigation app, we have to include it in the HTML file.
To do so, go to home/public/index.html
and add the publicPath
of the navigation app in the script tag . Include the script tag as shown in the code below :
<!DOCTYPE html>
<html lang="en">
<head>
<script src="<http://localhost:3001/remoteEntry.js>"></script>
...
</head>
- List in remotes
Next, go to home/webpack.config.js
and specify the navigation app in the remotes section like below :
remotes: {
home-nav: 'navigation',
},
We are using navigation
as it is the name we used in the configuration in navigation/webpack.config.js
file.
The home-nav
is the name used in the home app to refer to the navigation app.
- Register the app
Next, go to the home/src/index.ts
file to register a navigation application. Change the code so that it looks like below :
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();
- Include a DIV container
We are almost done. It's now time to add a DIV container to hold the footer and header 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>
π€ Single-Spa by default will search for the id single-spa-application:{app name}
and render the HTML there.
In this case, weβve already registered the Header and Footer as βheaderβ and βfooterβ so it will find the id β single-spa-application:header
and single-spa-application:footer
- Letβs run this Application
Run the code below again, to install all dependencies :
yarn
And start the server from the root folder :
yarn start
Navigate to http://localhost:3000
and you will find that two React components rendered successfully.
β€οΈβπ₯ Congratulations on your new app. Let's keep the fire burning ‡οΈ
6. Building the Home Application
We're almost done. It's now time to build our Body App. The process is very similar to the one we did for the Navigation Application.
The folder structure of our Body App will look like this :
βββ packages
β βββ body
β β βββ package.json
β β βββ src
β β β βββ App.vue
β β β βββ app.js
β β β βββ index.js
β β βββ tsconfig.json
β β βββ webpack.config.js
- Create a folder
body
inside the packages folder
mkdir packages/body
- Create
body/package.json
and add the code below :
{
"name": "body",
"scripts": {
"start": "webpack serve --port 3002",
"build": "webpack --mode=production"
},
"version": "1.0.0",
"private": true,
"devDependencies": {
"@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",
"sass": "^1.60.0",
"sass-loader": "^10.1.0",
"style-loader": "2.0.0",
"vue-loader": "16.0.0-beta.7",
"vue-style-loader": "^4.1.2"
},
"dependencies": {
"autoprefixer": "^10.1.0",
"postcss": "^8.2.1",
"single-spa-vue": "^2.1.0",
"vue": "^3.0.0"
}
}
From the above file, you can see that we're using Vuejs
as one of the dependencies instead of React for this app.
- Next, create a
tsconfig.json
and add the code below :
{
"compilerOptions": {
"target": "es5",
"moduleResolution": "node",
"module": "ESNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
- Finally, create a
body/webpack.config.js
and add the code below :
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.ts",
cache: false,
mode: "development",
devtool: "source-map",
optimization: {
minimize: false,
},
output: {
publicPath: "http://localhost:3002/",
},
resolve: {
extensions: [".jsx", ".js", ".json", ".ts", ".tsx", ".vue"],
},
devServer: {
contentBase: 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"],
}),
],
};
As you can see, weβre exposing the Body
component from this app.
- Next, letβs add the Vue component
Create a body/src/App.vue
and add the code below :
<template>
<div class="body">
<div class="body--container">
Body from Vue.js
</div>
</div>
</template>
<style scoped lang="scss">
.body {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
&--container {
width: 100%;
height: 300px;
display: flex;
padding: 1rem;
color: white;
align-items: center;
background: #42b983;
}
}
</style>
Next, create a body/src/app.ts
and insert the following code :
import singleSpaVue, { AppOptions } from "single-spa-vue";
import { h, createApp, render } from "vue";
import App from "./App.vue";
const lifecycles = singleSpaVue({
createApp,
async appOptions(props: AppOptions) {
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: props.name,
mountParcel: props.mountParcel,
singleSpa: props.singleSpa,
},
});
},
});
export const bootstrap = lifecycles.bootstrap;
export const mount = lifecycles.mount;
export const unmount = lifecycles.unmount;
Then create a index.ts
which imports the app component.
import "./app"
Finally, create a src/declarations/shims-vue.d.ts
file and add the code below :
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export {component}
}
7. Registering The Body Application
Next, letβs register the body in the home app as we did before for the navigation app.
- Go to
home/public/index.html
and add another script tag for the Body App :
<head>
<script src="<http://localhost:3001/remoteEntry.js>"></script>
<script src="<http://localhost:3002/remoteEntry.js>"></script>
...
</head>
http://localhost:3002/remoteEntry.js
is the publicPath for our Body component as mentioned in the webpack.config.js
of the Body app.
- Then go to
home/webpack.config.js
file and addhome-body
to the remotes object :
remotes: {
'home-nav': 'navigation',
'home-body': 'body',
}
- Finally, 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();
We added another registerApplication
function call for the body app.
8. Running Our Full Application
Once again, install all dependencies at the root :
yarn
And start the server :
yarn start
Navigate to http://localhost:3000 to view the application.
π€ Congratulations ! youβve created your first micro frontend application with Single-Spa, React, and Vue !
9. In Conclusion
In conclusion, Single-spa is a powerful tool for building complex web applications using multiple frameworks and micro services.
Its modular and flexible architecture allows developers to create seamless and efficient user experiences while maintaining independence between the different parts of the application.
With its extensive documentation and active community, Single-spa is a great choice for developers and large companies looking to streamline and scale their development process to deliver high-quality applications without the need to rely on any single framework.
β‘οΈ To learn more about Single-Spa, visit the Single-Spa example page and for the articles source code feel free to checkout : https://github.com/xotoboil/xotoboil-simple-multifront.
Thank and see you on the next one π !