visit
Demo app — end result
Before we discuss the step-by-step instructions, let’s get a quick overview of what makes up the demo app. This app is composed of four sub-apps:
mkdir single-spa-demo
cd single-spa-demo
mkdir single-spa-demo-root-config
cd single-spa-demo-root-config
npx create-single-spa
Great! Now, if you check out the
single-spa-demo-root-config
directory, you should see a skeleton root config app. We'll customize this in a bit, but first let's also use the CLI tool to create our other three micro-frontend apps.
cd ..
mkdir single-spa-demo-nav
cd single-spa-demo-nav
npx create-single-spa
Inside the
single-spa-demo-root-config
directory, in the activity-functions.js
file, we'll write the following activity functions for our three micro-frontend apps.export function prefix(location, ...prefixes) {
return prefixes.some(
prefix => location.href.indexOf(`${location.origin}/${prefix}`) !== -1
);
}
export function nav() {
// The nav is always active
return true;
}
export function page1(location) {
return prefix(location, 'page1');
}
export function page2(location) {
return prefix(location, 'page2');
}
Next, we need to register our three micro-frontend apps with single-spa. To do that, we use the
registerApplication
function. This function accepts a minimum of three arguments: the app name, a method to load the app, and an activity function to determine when the app is active.Inside the
single-spa-demo-root-config
directory, in the root-config.js
file, we'll add the following code to register our apps:import { registerApplication, start } from "single-spa";
import * as isActive from "./activity-functions";
registerApplication(
"@thawkin3/single-spa-demo-nav",
() => System.import("@thawkin3/single-spa-demo-nav"),
isActive.nav
);
registerApplication(
"@thawkin3/single-spa-demo-page-1",
() => System.import("@thawkin3/single-spa-demo-page-1"),
isActive.page1
);
registerApplication(
"@thawkin3/single-spa-demo-page-2",
() => System.import("@thawkin3/single-spa-demo-page-2"),
isActive.page2
);
start();
Now that we’ve set up the activity functions and registered our apps, the last step before we can get this running locally is to update the local import map inside the
index.ejs
file in the same directory. We'll add the following code inside the head
tag to specify where each app can be found when running locally:<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@thawkin3/root-config": "//localhost:9000/root-config.js",
"@thawkin3/single-spa-demo-nav": "//localhost:9001/thawkin3-single-spa-demo-nav.js",
"@thawkin3/single-spa-demo-page-1": "//localhost:9002/thawkin3-single-spa-demo-page-1.js",
"@thawkin3/single-spa-demo-page-2": "//localhost:9003/thawkin3-single-spa-demo-page-2.js"
}
}
</script>
<% } %>
single-spa-demo-root-config
directory: yarn start
(runs on port 9000 by default)single-spa-demo-nav
directory: yarn start --port 9001
single-spa-demo-page-1
directory: yarn start --port 9002
single-spa-demo-page-2
directory: yarn start --port 9003
Demo app — main page
On our main page, the navbar is showing because the navbar app is always active.Now, let’s navigate to As shown in our activity functions above, we’ve specified that the page 1 app should be active (shown) when the URL path begins with “page1.” So, this activates the page 1 app, and we should see the text for both the navbar and the page 1 app now.Demo app — page 1 route
One more time, let’s now navigate to As expected, this activates the page 2 app, so we should see the text for the navbar and the page 2 app now.Demo app — page 2 route
First, if you refresh your page over and over when viewing the app, you may notice that sometimes the apps load out of order, with the page app appearing above the navbar app. This is because we haven’t actually specified where each app should be mounted. The apps are simply loaded by , and then whichever app finishes loading fastest gets appended to the page first.
We can fix this by specifying a mount container for each app when we register them.In our
index.ejs
file that we worked in previously, let's add some HTML to serve as the main content containers for the page:<div id="nav-container"></div>
<main>
<div id="page-1-container"></div>
<div id="page-2-container"></div>
</main>
Then, in our
root-config.js
file where we've registered our apps, let's provide a fourth argument to each function call that includes the DOM element where we'd like to mount each app:import { registerApplication, start } from "single-spa";
import * as isActive from "./activity-functions";
registerApplication(
"@thawkin3/single-spa-demo-nav",
() => System.import("@thawkin3/single-spa-demo-nav"),
isActive.nav,
{ domElement: document.getElementById('nav-container') }
);
registerApplication(
"@thawkin3/single-spa-demo-page-1",
() => System.import("@thawkin3/single-spa-demo-page-1"),
isActive.page1,
{ domElement: document.getElementById('page-1-container') }
);
registerApplication(
"@thawkin3/single-spa-demo-page-2",
() => System.import("@thawkin3/single-spa-demo-page-2"),
isActive.page2,
{ domElement: document.getElementById('page-2-container') }
);
start();
In the
single-spa-demo-root-config
directory, in the index.ejs
file again, we can add some basic styles for the whole app by pasting the following CSS at the bottom of the head
tag:<style>
body, html { margin: 0; padding: 0; font-size: 16px; font-family: Arial, Helvetica, sans-serif; height: 100%; }
body { display: flex; flex-direction: column; }
* { box-sizing: border-box; }
</style>
Next, we can style our navbar app by finding the
single-spa-demo-nav
directory, creating a root.component.css
file, and adding the following CSS:.nav {
display: flex;
flex-direction: row;
padding: 20px;
background: #000;
color: #fff;
}
.link {
margin-right: 20px;
color: #fff;
text-decoration: none;
}
.link:hover,
.link:focus {
color: #1098f7;
}
We can then update the
root.component.js
file in the same directory to import the CSS file and apply those classes and styles to our HTML. We'll also change the navbar content to actually contain two links so we can navigate around the app by clicking the links instead of entering a new URL in the browser's address bar.import React from "react";
import "./root.component.css";
export default function Root() {
return (
<nav className="nav">
<a href="/page1" className="link">
Page 1
</a>
<a href="/page2" className="link">
Page 2
</a>
</nav>
);
}
We’ll follow a similar process for the page 1 and page 2 apps as well. We’ll create a
root.component.css
file for each app in their respective project directories and update the root.component.js
files for both apps too.For the page 1 app, the changes look like this:.container1 {
background: #1098f7;
color: white;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
font-size: 3rem;
}
import React from "react";
import "./root.component.css";
export default function Root() {
return (
<div className="container1">
<p>Page 1 App</p>
</div>
);
}
.container2 {
background: #9e4770;
color: white;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
font-size: 3rem;
}
import React from "react";
import "./root.component.css";
export default function Root() {
return (
<div className="container2">
<p>Page 2 App</p>
</div>
);
}
To use React Router, we’ll first need to install it. From the terminal, in the
single-spa-demo-nav
directory, we'll install React Router using yarn by entering yarn add react-router-dom
. (Or if you're using npm, you can enter npm install react-router-dom
.)Then, in the
single-spa-demo-nav
directory in the root.component.js
file, we'll replace our anchor tags with React Router's Link
components like so:import React from "react";
import { BrowserRouter, Link } from "react-router-dom";
import "./root.component.css";
export default function Root() {
return (
<BrowserRouter>
<nav className="nav">
<Link to="/page1" className="link">
Page 1
</Link>
<Link to="/page2" className="link">
Page 2
</Link>
</nav>
</BrowserRouter>
);
}
Demo app — styled and using React Router
AWS S3 bucket
Once we have our bucket created, it’s also important to make sure the bucket is public and that so that we can access and use our uploaded assets in our app. In the permissions for our bucket, we can add the following CORS configuration rules:<CORSConfiguration>
<CORSRule>
<AllowedOrigin>*</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
</CORSRule>
</CORSConfiguration>
CORS configuration
To configure Travis CI for any given project, we create a
.travis.yml
file in the project's root directory. Let's create that file in the single-spa-demo-root-config
directory and insert the following code:language: node_js
node_js:
- node
script:
- yarn build
- echo "Commit sha - $TRAVIS_COMMIT"
- mkdir -p dist/@thawkin3/root-config/$TRAVIS_COMMIT
- mv dist/*.* dist/@thawkin3/root-config/$TRAVIS_COMMIT/
deploy:
provider: s3
access_key_id: "$AWS_ACCESS_KEY_ID"
secret_access_key: "$AWS_SECRET_ACCESS_KEY"
bucket: "single-spa-demo"
region: "us-west-2"
cache-control: "max-age=31536000"
acl: "public_read"
local_dir: dist
skip_cleanup: true
on:
branch: master
Because we don’t want our AWS secrets exposed in our GitHub repo, we can store those as environment variables. You can place environment variables and their secret values within the Travis CI web console for anything that you want to keep private, so that’s where the
.travis.yml
file gets those values from.Now, when we commit and push new code to the master branch, the Travis CI job will run, which will build the JavaScript bundle for the app and then upload those assets to S3. To verify, we can check out the AWS console to see our newly uploaded files:Uploaded files as a result of a Travis CI job
Neat! So far so good. Now we need to implement the same Travis CI configuration for our other three micro-frontend apps, but swapping out the directory names in the
.travis.yml
file as needed. After following the same steps and merging our code, we now have four directories created in our S3 bucket, one for each repo.Four directories within our S3 bucket
If we look in the
single-spa-demo-root-config
directory, in the index.ejs
file, we see this line:<script type="systemjs-importmap" src="//storage.googleapis.com/react.microfrontends.app/importmap.json"></script>
{
"imports": {
"react": "//cdn.jsdelivr.net/npm/[email protected]/umd/react.production.min.js",
"react-dom": "//cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.production.min.js",
"single-spa": "//cdn.jsdelivr.net/npm/[email protected]/lib/system/single-spa.min.js",
"@react-mf/root-config": "//react.microfrontends.app/root-config/e129469347bb89b7ff74bcbebb53cc0bb4f5e27f/react-mf-root-config.js",
"@react-mf/navbar": "//react.microfrontends.app/navbar/631442f229de2401a1e7c7835dc7a56f7db606ea/react-mf-navbar.js",
"@react-mf/styleguide": "//react.microfrontends.app/styleguide/f965d7d74e99f032c27ba464e55051ae519b05dd/react-mf-styleguide.js",
"@react-mf/people": "//react.microfrontends.app/people/dd205282fbd60b09bb3a937180291f56e300d9db/react-mf-people.js",
"@react-mf/api": "//react.microfrontends.app/api/2966a1ca7799753466b7f4834ed6b4f2283123c5/react-mf-api.js",
"@react-mf/planets": "//react.microfrontends.app/planets/5f7fc62b71baeb7a0724d4d214565faedffd8f61/react-mf-planets.js",
"@react-mf/things": "//react.microfrontends.app/things/7f209a1ed9ac9690835c57a3a8eb59c17114bb1d/react-mf-things.js",
"rxjs": "//cdn.jsdelivr.net/npm/@esm-bundle/[email protected]/system/rxjs.min.js",
"rxjs/operators": "//cdn.jsdelivr.net/npm/@esm-bundle/[email protected]/system/rxjs-operators.min.js"
}
}
So, using the original import map as a template, we can create a new file called
importmap.json
, place it outside of our repos and add JSON that looks like this:{
"imports": {
"react": "//cdn.jsdelivr.net/npm/[email protected]/umd/react.production.min.js",
"react-dom": "//cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.production.min.js",
"single-spa": "//cdn.jsdelivr.net/npm/[email protected]/lib/system/single-spa.min.js",
"@thawkin3/root-config": "//single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/root-config/179ba4f2ce4d517bf461bee986d1026c34967141/root-config.js",
"@thawkin3/single-spa-demo-nav": "//single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/single-spa-demo-nav/f0e9d35392ea0da8385f6cd490d6c06577809f16/thawkin3-single-spa-demo-nav.js",
"@thawkin3/single-spa-demo-page-1": "//single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/single-spa-demo-page-1/4fd417ee3faf575fcc29d17d874e52c15e6f0780/thawkin3-single-spa-demo-page-1.js",
"@thawkin3/single-spa-demo-page-2": "//single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/single-spa-demo-page-2/8c58a825c1552aab823bcbd5bdd13faf2bd4f9dc/thawkin3-single-spa-demo-page-2.js"
}
}
Import map manually uploaded to the S3 bucket
Finally, we can now reference this new file in our
index.ejs
file instead of referencing the original import map.<script type="systemjs-importmap" src="//single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/importmap.json"></script>
First, in the
single-spa-demo-root-config
directory, we'll install express by running yarn add express
(or npm install express
). Next, we'll add a file called server.js
that contains a small amount of code for starting up an express server and serving our main index.html
file.const express = require("express");
const path = require("path");
const PORT = process.env.PORT || 5000;
express()
.use(express.static(path.join(__dirname, "dist")))
.get("*", (req, res) => {
res.sendFile("index.html", { root: "dist" });
})
.listen(PORT, () => console.log(`Listening on ${PORT}`));
Finally, we’ll update the NPM scripts in our
package.json
file to differentiate between running the server in development mode and running the server in production mode."scripts": {
"build": "webpack --mode=production",
"lint": "eslint src",
"prettier": "prettier --write './**'",
"start:dev": "webpack-dev-server --mode=development --port 9000 --env.isLocal=true",
"start": "node server.js",
"test": "jest"
}
single-spa-demo-root-config
directory: heroku create thawkin3-single-spa-demo
(changing that last argument to a unique name to be used for your Heroku app)git push heroku master
heroku open
And with that, we are up and running in production! Upon running the
heroku open
command, you should see your app open in your browser. Try navigating between pages using the nav links to see the different micro-frontend apps mount and unmount.Demo app — up and running in production
Let’s start by updating our
.travis.yml
file like so:language: node_js
node_js:
- node
env:
global:
# include $HOME/.local/bin for `aws`
- PATH=$HOME/.local/bin:$PATH
before_install:
- pyenv global 3.7.1
- pip install -U pip
- pip install awscli
script:
- yarn build
- echo "Commit sha - $TRAVIS_COMMIT"
- mkdir -p dist/@thawkin3/root-config/$TRAVIS_COMMIT
- mv dist/*.* dist/@thawkin3/root-config/$TRAVIS_COMMIT/
deploy:
provider: s3
access_key_id: "$AWS_ACCESS_KEY_ID"
secret_access_key: "$AWS_SECRET_ACCESS_KEY"
bucket: "single-spa-demo"
region: "us-west-2"
cache-control: "max-age=31536000"
acl: "public_read"
local_dir: dist
skip_cleanup: true
on:
branch: master
after_deploy:
- chmod +x after_deploy.sh
- "./after_deploy.sh"
The main changes here are adding a global environment variable, installing the AWS CLI, and adding an
after_deploy
script as part of the pipeline. This references an after_deploy.sh
file that we need to create. The contents will be:echo "Downloading import map from S3"
aws s3 cp s3://single-spa-demo/@thawkin3/importmap.json importmap.json
echo "Updating import map to point to new version of @thawkin3/root-config"
node update-importmap.mjs
echo "Uploading new import map to S3"
aws s3 cp importmap.json s3://single-spa-demo/@thawkin3/importmap.json --cache-control 'public, must-revalidate, max-age=0' --acl 'public-read'
echo "Deployment successful"
This file downloads the existing import map from S3, modifies it to reference the new build artifact, and then re-uploads the updated import map to S3. To handle the actual updating of the import map file’s contents, we use a custom script that we’ll add in a file called
update-importmap.mjs
.// Note that this file requires [email protected] or higher (or the --experimental-modules flag)
import fs from "fs";
import path from "path";
import https from "https";
const importMapFilePath = path.resolve(process.cwd(), "importmap.json");
const importMap = JSON.parse(fs.readFileSync(importMapFilePath));
const url = `//single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/root-config/${process.env.TRAVIS_COMMIT}/root-config.js`;
https
.get(url, res => {
// HTTP redirects (301, 302, etc) not currently supported, but could be added
if (res.statusCode >= 200 && res.statusCode < 300) {
if (
res.headers["content-type"] &&
res.headers["content-type"].toLowerCase().trim() ===
"application/javascript"
) {
const moduleName = `@thawkin3/root-config`;
importMap.imports[moduleName] = url;
fs.writeFileSync(importMapFilePath, JSON.stringify(importMap, null, 2));
console.log(
`Updated import map for module ${moduleName}. New url is ${url}.`
);
} else {
urlNotDownloadable(
url,
Error(`Content-Type response header must be application/javascript`)
);
}
} else {
urlNotDownloadable(
url,
Error(`HTTP response status was ${res.statusCode}`)
);
}
})
.on("error", err => {
urlNotDownloadable(url, err);
});
function urlNotDownloadable(url, err) {
throw Error(
`Refusing to update import map - could not download javascript file at url ${url}. Error was '${err.message}'`
);
}
In the
single-spa-demo-page-1
directory, in the root.component.js
file, let's change the text from "Page 1 App" to "Page 1 App - UPDATED!" Next, let's commit that change and push and merge it to master. This will kick off the Travis CI pipeline to build the new page 1 app artifact and then update the import map to reference that new file URL.If we then navigate in our browser to , we’ll now see… drum roll please… our updated app!Demo app — successfully updating one of the micro-frontend apps
I said it before, and I’ll say it again: Micro-frontends are the future of frontend web development. The benefits are massive, including independent deployments, independent areas of ownership, faster build and test times, and the ability to mix and match various frameworks if needed. There are some drawbacks, such as the initial set up cost and the complexity of maintaining a distributed architecture, but I strongly believe the benefits outweigh the costs.
Single-spa makes micro-frontend architecture easy. Now you, too, can go break up the monolith!