Micro Frontends at project44

5 min read Original article ↗

Nicole White

Like many startups, our frontend tech stack started out as a single page application using create-react-app. As the number of frontend engineers on the team grew, and as the size and build time of the monolith grew, developing against and deploying the monolith was slowing teams down.

Today, all new projects are built on the micro frontend framework. Each micro frontend is an independent application with its own test, build, and deploy pipeline, allowing teams to develop and release faster.

To the end user, our app looks like yet another single page application. Under the hood, we are using single-spa to do route-based code splitting, where each route has its own application. This is accomplished via two single-spa configurations: the layout file and the import map.

Layout File

The layout file tells single-spa how to render your application based on the current route. This can be as complicated or as simple as you want it to be. We have kept ours relatively simple; the top navigation bar is always rendered and the app that is rendered below it depends on the path:

{  "routes": [    { "type": "application", "name": "@project44/navigation-bar" },    {      "type": "route",      "path": "a",      "routes": [        { "type": "application", "name": "@project44/app-a" }      ]    },    {      "type": "route",      "path": "b",      "routes": [        { "type": "application", "name": "@project44/app-b" }      ]    },    {      "type": "route",      "default": true      "routes": [        { 
"type": "application",
"name": "@project44/not-found-page"
}
] } ]}

This means we are only loading the JavaScript for the necessary route: if a user lands at /a, we only load the code for @project44/navigation-bar and @project44/app-a:

Press enter or click to view image in full size

Import Map

Another important piece of configuration is the import map. The import map aliases “bare specifier” imports to a URL. For example import React from ‘react’ relies on “react” being in the import map:

{  "imports": {    "react": "https://myapps.com/react@17.0.1/react.min.js"  }}

When using single-spa, your import map ends up being a mixture of shared dependencies (in our case, react and react-dom) and the applications themselves, since the applications are marked as external in the webpack config and treated as in-browser modules instead. Continuing with the example above, our full import map would be:

{  "imports": {    "single-spa": "https://myapps.com/single-spa@5.9.0/single-spa.min.js",    "react": "https://myapps.com/react@17.0.1/react.min.js",
"react-dom": "https://myapps.com/react-dom@17.0.1/react-dom.min.js",
"@project44/app-a": "https://myapps.com/app-a/1.0.0/project44-app-a.js",
"@project44/app-b": "https://myapps.com/app-b/1.0.0/project44-app-b.js", "@project44/navigation-bar": "https://myapps.com/navigation-bar/1.0.0/project44-navigation-bar.js", "@project44/not-found-page": "https://myapps.com/not-found-page/1.0.0/project44-not-found-page.js" }}

This allows single-spa to resolve the name of an application in the layout file to its URL.

Local Development

The import map plays a crucial role in local development. When developing locally, a dev only needs to run the application they are working on. For example, if I want to make changes to app-a, I would navigate to its location and run yarn start. But instead of going to http://localhost:3000 in my browser like I would for a typical create-react-app application, I go to a dev environment where the apps are already deployed, e.g. https://dev.example.com/a, and use import map overrides to override the location for that app in the import map:

{  "imports": {    "@project44/app-a": "https://localhost:8080/project44-app-a.js"  }}

In this setup, you are running only the relevant application while the rest of the import map continues to point to the already-deployed applications in the dev environment.

Deploying

The import map is also the avenue through which new versions of an app are deployed. Recall the value for app-a in the import map above:

"https://myapps.com/app-a/1.0.0/project44-app-a.js"

For each new version of an application, its bundle is uploaded under, for example, “https://myapps.com/app-a/<version>/project44-app-a.js", so to deploy a new version of this application we simply need to bump this version.

All of our single-spa configuration, namely the layout file and the import map, is maintained in a Github repository, and any change to this configuration is deployed automatically on pushes to the main branch. This is effectively a GitOps approach to our micro frontend deployments.

For each environment (dev, stage, prod, etc.) we have a file structure like this:

layout.json <-- layout fileimportmap.json <-- only contains "static" deps like react, react-domversions/  app-a <-- file containing current version of app-a  app-b <-- file containing current version of app-b  navigation-bar  not-found-page

Within the versions/ directory is a file named after each application. The content of this file is the application’s current version. importmap.json consists of only the shared, static libraries, and within the deployment pipeline of this repository, we make the full import map by combining the base importmap.json with the applications by reading the contents in the versions/ directory.

Teams deploy their applications by merging pull requests that update their app’s version:

Press enter or click to view image in full size

These pull requests are opened automatically when a new version is available and include details of the changes that are being deployed as part of the version bump. The body of this pull request, for example, lists all the changes that were made to the navigation-bar-ui app between version 1.0.28 and 1.0.32:

Press enter or click to view image in full size

As of today, project44 has over 40 micro webapp applications across several teams using this framework. We average around 100 deploys per day using the GitOps approach described above.