Micro frontend architecture is a design approach for building web-based applications. It’s an extension of the microservices idea to the frontend world. In a similar way that microservices help to break up the backend into smaller, manageable parts, micro frontends help to decompose the frontend (or user interface) layer into smaller, easier to maintain pieces.

In a classical web application, this can be achieved in a number of ways, for example by designing Self-contained systems and by the use of Web Components for Micro-Frontends.

This article will focus on how we implemented a micro frontend architecture for our client using Capacitor and Ionic. It will detail the problems we encountered and the solutions we applied. The discussion will not primarily cover when a micro frontend approach is appropriate or its design specifics.

In this blog post we will refer to a single micro frontend (as part of the app as a whole) as a single frontend.

Microfrontends: The Downsides

Microfrontends, like any distributed system architecture, bring a mix of organizational challenges and technical complexities. Their primary aim is to allow teams to operate and deploy independently, but this approach introduces several new issues:

This means a significant amount of time must be dedicated to planning integration and solving technical challenges before feature implementation can begin. Furthermore, communication inefficiencies among teams can lead to frustration and are likely to persist over time.

It’s possible that a small, tightly-knit team of developers could progress more quickly than multiple teams using a microfrontend approach.

Our client chose microfrontends because they had several well-established teams and clear communication channels for defined domains. However, a monolithic mobile app with a microservice backend, developed by a single additional team, might have been equally effective.

Why Ionic and Capacitor?

In short, Capacitor is a webview that enables us to deploy a webapp to Android and iOS devices. It also provides many useful plugins that make it easy to use native device features like camera, location and push notifications within that webapp.

The Ionic framework provides a library of well tested UI components that make the web app look, feel and behave like a native Android or iOS app.

Since our clients existing teams primarily had experience in web development and the end product was to be a mobile app, Capacitor and Ionic seemed like a natural choice.

Capacitor is primarily designed for deploying a web app with a single set of bundled source files. However, building and publishing a new version of an app everytime there is an update by any team is a time-consuming process. We will therefore also need a way to load updated source code at runtime.

Why not Ionic Portals?

Ionic Portals claims to be an all-in-one solution for making micro frontends work with Ionic and Capacitor. It integrates multiple web views and provides data interfaces (portals) between them. It also comes with sophisticated cloud deployment infrastructure (Ionic Live Updates, AppFlow), allowing developers to push code updates directly into the app, bypassing the app store.

After careful consideration however, we chose not to use portals and try to integrate our frontends using established web-technologies like custom elements. Portal’s five-figure starting price tag is a hard sell so early on. After all, it seems like we would be able to implement it at a later time, should it become necessary.

Architecture Goals

Our main aim is to enable our teams to develop and deploy their projects independently. This includes:

Although all teams are using Ionic, we’re trying to keep the dependency on a specific version as low as possible

Micro Frontend Architecture Diagram
Micro Frontend Architecture Diagram

Authentication and Redirects

A common approach to authentication in mobile apps is to intercept all HTTP requests. If a request is unauthorized, the app handles authentication in the background and stores auth and refresh tokens.

In our situation (which I believe is fairly typical in corporate settings), our client opted to use their existing Keycloak instance, just as they do with their web app. This setup means every request passes through an auth proxy. If there’s a valid session cookie, the proxy forwards the request to its intended destination. Without a valid cookie, or if the session is expired, the request gets redirected to Keycloak’s login page.

This method simplifies things considerably. Since every significant part of the application, including the mobile app shell, is behind the auth proxy, authentication is consistently ensured. This eliminates the need for implementing request interception or token management within the app.

However, there are challenges. Specifically, Capacitor doesn’t always handle these redirects smoothly.

Some Capacitor plugins are not available after redirects

We’ve encountered a strange bug in Capacitor when your web app is loaded through its server.url configuration. Some Capacitor plugins don’t always initialize correctly if they’re imported by a webpage other than the one initially loaded in the webview. For instance, the PushNotifications plugin becomes unavailable after a redirect through the Keycloak login page. You might encounter an error like this:

"PushNotifications" plugin is not implemented on android

We were not able to solve the capacitor bug but there is an acceptable workaround: Once the user is logged in, we reload the capacitor webview by calling window.location.reload().

// main.js
import { Capacitor } from  "@capacitor/core";

(async () => {
	if(Capacitor.isNativePlatform() && !Capacitor.isPluginAvailable("PushNotifications")) {
		// If it's a native platform and the plugin is unavailable,
		// it's most likely because of the redirect bug.
		// Let's reload the webview to make it work.
		window.location.reload();
		return;
	}
// ... more initialization
});

Since we work with long term sessions, this reload should not happen frequently.

Initializing Push Notifications Early On

Let’s say we would like to redirect users to a specific page if they open the app by tapping on a push notification. To make this work, we use Capacitor’s push notifications plugin and register a listener for the pushNotificationActionPerformed event.

Where exactly you register your push notification listeners will depend on the details of your architecture.

You may decide to register them early on in a local index file, as intended by capacitor, to make sure you catch the first event once the app is opened.

However, you might also decide to use the server.url config to load your webapp from a server as we did with our mobile app shell. In that case, the plugin might initialize too late and miss the first event. The push notification plugin itself has a buffer mechanism to make sure you receive events that were dispatched before you registered your listeners. However, capacitors plugins are lazy-loaded once they are imported in your web app. Therefore the plugin won’t initialize itself or the underlying Google Firebase SDK before it’s import is used in a JS file.

But because Capacitor enables us to directly write native Android and iOS code, we can easily make sure the plugin is loaded early on. In our Android project, for example, we simply customize our MainActivity by adding the plugin to the initial plugins list of it’s parent BridgeActivity class:

import com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin;
import com.getcapacitor.BridgeActivity;

public  class  MainActivity  extends  BridgeActivity {
	public  MainActivity() {
		this.initialPlugins.add(PushNotificationsPlugin.class);
	}
}

Micro-Frontends and routing with the Ion-Router

Each team should have the ability to manage the routing of their individual frontends within their own repositories. However, there’s a catch: in the mobile app shell, we need to maintain a single ion-router instance, as Ionic restricts us to one router per app.

To enable this, every frontend outlines its routes and corresponding custom elements (specifically, the related JavaScript and CSS files) in a manifest file. This file is then stored in a designated location on the CDN.

Take, for example, our library frontend. Here, we’ve also defined a tab featuring a book icon.

// example of a routes manifest file.
// Predefined routes point to built artifacts containing custom elements.
{
   "components":{
      "library-main-view":{
         "script":"/library/static/main-view-7e0f75b9.js",
         "styles":"/library/static/main-view-7e0f75b9.css"
      },
      "library-details-view":{
         "script":"/library/static/details-view-4c66f067.js",
         "styles":"/library/static/details-view-4c66f067.css"
      }
   },
   "routes":[
      {
         "path":"/library",
         "component":"library-main-view"
      },
      {
         "path":"/library/details/:id",
         "component":"library-details-view"
      }
   ],
   "tab":{
      "rootPath":"/library",
      "label":"Library",
      "icon":"book"
   }
}

The mobile app shell is programmed to search for a manifest file for each system. It uses the routes in these files to set up the central router. To streamline this process, we developed a custom Vite plugin. This plugin automatically generates the manifest file along with other build artifacts.

Integrating Ionic’s UI components

Capacitor provides the webview and plugins for accessing native features, while Ionic offers a set of UI components essential for achieving a native app appearance.

Ionic is available in four variants: Angular, React, VueJS, and what’s often called Custom Elements for Vanilla JS. Although our teams mainly use VueJS for building components, our client wanted Ionic to be integrated in a way that’s not tied to any specific technology or version. This led us to choose JavaScript’s Custom Elements for incorporating Ionic.

Another key requirement from our client was to minimize the need for teams to frequently align the versions of shared frameworks. This meant implementing Ionic in a way that’s as indifferent to versions as possible.

A challenge we face is that Ionic’s custom elements aren’t version-tagged. This makes it impossible for different teams to use varying versions of the framework independently.

In practice, the version of Ionic gets effectively determined by the first UI fragment that utilizes it. This sets the Ionic version for the entire application.

To align with our architectural goals, we chose not to let individual teams use Ionic directly. Instead, we’ve encapsulated the Ionic UI framework components within our existing pattern library. By doing so, we only expose these as versioned custom elements through our pattern library. This strategy reduces our dependency on this particular framework. However, it also significantly reduces flexibility and creates a potential bottleneck.

What’s left in the end

Micro frontends can indeed be effectively integrated with Ionic and Capacitor. However, as you’ve likely gathered from our experiences, this approach introduces numerous unexpected technical challenges that affect almost every aspect of our architecture and integration.

It’s crucial, therefore, to thoroughly assess whether a micro frontend architecture truly benefits your project. If you find that it does, I hope the insights, examples, and solutions shared in this text will simplify your implementation process.

Photo by Mel Poole on Unsplash

Learn more about softwarchitecture. We have a 3-day-training.