Micro frontend Architecture using Runtime Configuration

Micro frontends are a pattern of developing a web application where the UI is composed of several independent projects. They divide a monolithic application into multiple smaller apps and each of them is responsible for a distinct feature or a product. Each micro frontend is usually built and maintained by a separate engineering team which allows for increased efficiency and scalability. Furthermore, each team can choose their own stack without having an impact on other teams. However, there are are also some disadvantages to micro frontends, the main one being increased complexity and dependency on DevOps. It is important to carefully weigh the pros and cons of both monolithic and distributed architecture before making a decision on which approach to use.

Build-time vs Runtime configuration

There are two main approaches to implementing micro frontends: build-time configuration and runtime configuration. With build time configuration, the micro frontends are published as packages instead of being deployed as javascript bundles. They are installed as a dependencies inside the Container application which is then deployed. The main advantage of this approach is simplicity of setup (especially compared to runtime integration). However, even though the micro frontends can run independently, the versions still have to be updated in the Container application which can lead to communication overhead as it has to be redeployed each time a new version of a sub-app needs to be released.

An alternative solution is a runtime configuration. Using this approach, the code is deployed as a bundle at a specific url rather than published as a package. Once the user navigates to the main page, the Container app is loaded and it fetches micro frontend bundle files. One of the key benefits of this approach is that micro frontends can be deployed independently at any time, which offers a high degree of flexibility. Additionally, this solution appears to be particularly effective in terms of performance. However, implementing this approach does require a significant amount of initial setup and configuration.

Project Overview

The following project demonstrates the use of micro frontends to build a web application. The approach follows a runtime integration using a Container app. The application consists of four major parts: the Home page, which serves as the main landing page; the Pricing page, which offers an overview of the pricing models; the Sign In/Sign up page, which handles authentication and the Dashboard, which is the primary screen of the app. All of these sections make use of sample data and do not rely on the backend APIs.

project structure
Figure 1: Overview of the main pages of the app

The above pages can be grouped into separate micro frontends, depending on project requirements. The functionality can be split as below:

  • Marketing micro frontend - contains Home and Pricing Page
  • Authentication micro frontend - contains Sign In/Up pages
  • Container app - integration layer - holds each of the micro frontends, grouped together

Frontend Tech Stack

The micro frontends can be created using any framework, and the integration process is very similar. For the purposes of this application, the projects use Vue.js and React.js. The integration itself is achieved with the help of Module Federation Plugin from Webpack.

tech stack
Figure 2: The micro frontends in the project are built using React and Vue

The project has the following requirements:

  • No coupling between sub-projects - the micro frontends are independent of one another and there are no imports of functions/objects/classes or shared state. Shared libraries through Module Federation is permitted. The projects run in isolation which allows separation of concerns and self-containment for any updates in the future - including re-writes/switching of tech stack.
  • No coupling between the Container and sub-apps - the Container should not assume that a sub-app is using a particular framework. All necessary communication is done through callbacks and simple events.
  • CSS from one project should not affect the other projects.
  • Version control - approaches here could be a mono repository which contains all the other micro frontends or a separate repository for each. For simplicity, the project follows the first solution.
  • Container should be able to decide to always use the latest version of a micro frontend or specify an exact version. The former means that there won’t be a need to redeploy a Container and the latter that a redeploy is necessary.

Integrating Micro frontends with the Container App

Each micro frontend is integrated with the Container app by exposing a mount function. This function, which takes a reference to an HTML element, is designed to keep the coupling between the Container and sub-apps as generic as possible, rather than exposing a React or Vue component directly. A simple example of this mount function in a React project can be found in a sub-app'sbootstrap.js file (Marketing project):

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

const mount = (element) => {
  ReactDOM.render(<App />, element);
}
  
export { mount };

This can be then utilized in the Container app by creating a component, in this case MarketingApp.jsx:

import React, { useRef, useEffect } from 'react';
import { mount } from 'marketing/MarketingApp';

export default () => {
 const ref = useRef(null);
 
 useEffect(() => {
   mount(ref.current)
 }, []);

 return <div ref={ref} />
};

MarketingApp.jsx can then be used in the Container App like a normal component. In the case of the Dashboard micro frontend written in Vue, the process is very similar. The sub-app still exports a mount function, it’s just specific to Vue:

import { createApp } from 'vue';
import Dashboard from './components/Dashboard.vue';

const mount = (element) => {
  const app = createApp(Dashboard);
  app.mount(element);
}

export { mount };

The process of integrating it into the Container App is exactly the same as for the Marketing App.

Navigation

There are a number of ways to implement navigation in micro frontends. Some of the key features of this project when it comes to navigation are:

  • Container and individual sub-apps need access to routing features, so the Container, Marketing and Authentication apps each have their own independent copy of routing libraries.
  • Sub-apps may need to add new routes or pages in the future, so basic routing logic is added to the Container to decide which micro frontend to show based on the URL. Each sub-app decides which page to show within itself.
  • Off the self routing solutions should be used, however, some amount of custom code is acceptable.
  • Navigation features should work for the sub-apps both in isolation and hosted modes.
  • If different apps need to communicate information about routing, it should be done in as generic way as possible.

Routing libraries such as React-Router, Vue-Router, and Angular Router are composed of two parts: a history object and a router. The history object is used to determine the current path a user is visiting in the application and can be a browser history, memory history and hash history. Browser history determines the path by looking at the URL in the address bar whereas memory history determines the path by storing the current path in memory. The most common way of setting up navigation inside micro frontends is to make use of browser history inside the Container App and memory history inside each sub-app. The reason for it is that each of the navigation libraries such as React-Router, Vue-Router have their own implementation of browser and memory history. Even though they all have very similar names, each library has its own custom implementation. The different browser history objects take a look at the URL and most importantly change the URL as well. There is a possibility that multiple different objects would be trying to change the URL inside the address bar at the same time thus creating a race condition or changing the URL in different ways. The browser history inside of the Container is the only copy of history that can access the address bar and update it. The sub-apps will use memory history and have their own separate copy of what the current URL is. In order to get the navigation to work seamlessly between the Container and sub-apps, there is syncing required of the history object from the Container app to sub-apps and vice versa.

CI/CD Pipeline

In order to deploy micro frontends independently, including the Container app, the location of each sub-app'sremoteEntry.js files must be known at build time. To achieve this, a deployment solution that can handle multiple projects and a simple CI pipeline is required. This can be accomplished using Github Actions and AWS. The Github Action will monitor for any changes on the main branch and specific folders, such as the Container folder for the Container project, and trigger a build accordingly. The production version of the build will be created using Webpack and uploaded to an AWS S3 storage bucket. The uploaded files will be distributed using a CDN, in this case, Amazon CloudFront. The below illustration shows the deployment process for the Container application and process is analogous for the other micro frontends.

deployment
Figure 3: Deployment process of the Container App

Important thing to note is that a cache invalidation is needed after each deploy. This is due to how CloudFront works - it does not automatically update the files that have changed. In this case, there would be a an issue if index.html file changed as CloudFront would not refresh it. It’s not the case with .js files as they have hash value that changes each time. To fix this, either a manual or automatic cache invalidation can be performed. An automatic process can be easily integrated into the existing pipeline by using a command in the workflow file such as:

run: aws cloudfront create-invalidation --distribution-id ${{ secrets.DISTRIBUTION_ID }}