Foundations of micro-frontend architecture

Introduction

A micro-frontend architecture is a solution that enables a product to be split into a number of smaller applications, each of which can be deployed independently of one another.

Using micro-frontends can enable:

  • Iterating in parallel with quicker turnaround within individual product areas
  • Onboarding new team members faster within individual product areas
  • Reducing the risk that bad changes within one product area can break the entire product
  • Compartmentalizing the long-term or cross-cutting impact of product area implementation decisions
  • Conducting future routine udpates or larger-scale upgrades piece-by-piece within individual product areas

Given the benefits above, we must also consider that micro-frontends can be difficult to build, and that the architecture presents distinct challenges for performance and user experience. For some teams and organizations, micro-frontends can enable iterating quickly and safely. For others, they introduce unnecessary complexity. Successfully adopting a micro-frontend architecture requires the technical and non-technical appetite to help teams unlock its benefits – micro-frontends may or may not be right for a given organization.

This article will describe how to create your own micro-frontends from the ground up, focusing on the core concepts of micro-frontends in the context of an example micro-frontend architecture. We will explore specific techniques to apply these concepts and create your own micro-frontends.

Architecture

The following architecture illustrates the typical components of a micro-frontend product. This architecture consists of a webserver, host application, a asset system, and the individual micro-frontend applications that then run on the micro-frontend platform to deliver portions of the product’s user-facing functionality.

Micro-frontend architecture diagram showing relationship of webserver, host application, and asset system components

Application interfaces

Rather than dive into each distinct component of this architecture, let’s focus on what makes micro-frontends what they are. At its core, a micro-frontend architecture requires that we have different product area applications dynamically loaded as a user navigates around our product.

To make this work, our host application will be responsible for orchestrating micro-frontend applications through interfaces that allow mounting and unmounting applications from the page.

Let’s start by defining these MicroFrontend interfaces:

// @example/mfe-interfaces

type MountFn = (props: { host: HTMLElement }) => Promise<void>;

type UnmountFn = (props: { host: HTMLElement }) => Promise<void>;

export type MicroFrontend = {
  mount: MountFn;
  unmount: UnmountFn;
};

Each micro-frontend application must implement the MicroFrontend interfaces:

// mfe.ts -> mfe.mjs

import { AppRoot } from '~/AppRoot';
import { createRoot, Root } from 'react-dom/client';
import { MicroFrontend } from '@example/mfe-interfaces';

let root: Root | undefined;

export const mfe: MicroFrontend = {
  mount: async ({ host }) => {
    root = createRoot(host);
    root.render(<AppRoot />);
  },
  unmount: async () => {
    root?.unmount();
  },
};

Note: We should avoid defining cross-application micro-frontend primitives or contracts based on any specific rendering framework. Even though it’s beneficial to standardize our teams on a technology stack, we will defend against breaking changes if our applications are decoupled and implemented against technology-agnostic interfaces, as shown above.

Loading applications

Now that we have application interfaces, we can turn our attention to loading applications. Our host will import an application’s built entrypoint file by URL. In other words, each application’s entrypoint is a module identified by its deployed URL, and exports implementations of the interfaces we defined above.

Micro-frontend module loading diagram showing host importing application by entrypoint URL with a micro-frontend module loader, and application importing internal chunks with its own internal module loader

The host and application agree on a module format (e.g. ESM, SystemJS, or AMD) as part of their implementation contract. The host will provide and call into the corresponding module loader. For example, if using ESM, the host calls into the browser’s built-in ESM loader using import(). Whichever loader the host provides is our micro-frontend module loader for loading applications. Separately, each application will use its own internal module loader (e.g. webpack) to load its internal modules. From the perspective of the micro-frontend architecture, each individual application is a single opaque module.

To load an application, the host will query our version discovery service to locate the entrypoint URL for the application’s current deployed version. With this URL, the host will import the application using a module loader, and then mount the application.

// host.ts -> host.mjs

const mountAppById = async (host: HTMLElement, appId: string) => {
  const { appVersion } = await resolveApp(appId);
  const { mount } = (await import(`https://cdn.example.com/${appVersion}/mfe.js`)) as MicroFrontend;
};

Server-side rendering

If we choose to support server-side rendering, we can create a similar interface as the above. In this case, each application will deploys an additional mfe.ssr.mjs entrypoint alongside its main mfe.mjs entrypoint, our webserver can use this implementation to drive server-side rendering by using Node’s vm.Script APIs to load and execute the server-side entrypoint. Once delivered to the page, the application’s client-side entrypoint can hydrate the rendered DOM using APIs like React’s hydrateRoot.

Sharing dependencies across applications

Given the mechanisms described above, each application by default is responsible for statically integrating its own versions of any dependencies at build time. This is a reasonable practice, with benefits to operational safety, and minimal downsides to performance in most cases. However, build-time static integration is just one dependency resolution strategy. In practice, we may want to choose a different strategy for certain dependencies:

  • Statically integrate the dependency into each application at build time
  • Pass the dependency from the host application to each micro-frontend application at runtime
  • Use the micro-frontend module loader to resolve and load the dependency by URL
  • Use a module federation mechanism to dynamically resolve and load dependencies across applications

There are two times when it is most relevant to pursue a dynamic strategy to share a specific dependency:

  • When it would carry a significant performance cost if duplicated across applications
  • When it would degrade user experience quality given a different version deployed across applications

Conclusion

Defining application interfaces and loading applications as modules are the two key concepts to a micro-frontend architecture. With an understanding of these concepts, it is possible to implement a micro-frontend product. The remaining work is in building the infrastructure and services to support this architecture, and providing supporting capabilities to drive the product requirements and operational requirements of our applications.