A Lightweight Approach to Micro Frontends

While iframes often trigger negative reactions among web developers, they prove to be a viable option when building single-page applications for microservices — commonly referred to as micro frontends. They provide strong client isolation and enable independent deployment cycles. This isolation also permits mixing different SPA frameworks within the same architecture. Alternative strategies for incorporating SPAs in microservice setups exist, each carrying its own trade-offs. A useful comparison can be found in this overview, and Brecht Billiet's presentation explores the available choices in greater depth.

In a separate article, I have already evaluated different approaches against specific architectural criteria, offering a broader perspective on this topic.

As demonstrated by Asim Hussain in a related blog post, iframes can also serve as a practical mechanism for transitioning legacy AngularJS applications to modern Angular.

For the solution outlined here, I developed a "meta router" that loads distinct SPA clients for microservices inside iframes. It manages iframe creation, keeps the client routes aligned with the shell's URL, and adjusts the iframe height automatically to avoid internal scrollbars. The library itself is framework-agnostic.

The router is available as an npm package:

npm install meta-spa-router --save

The source code, along with a working example, is hosted in my GitHub repository.

In the accompanying demo, the shell application is written in VanillaJS, while the routed child applications are built with Angular.

The shell can be configured with VanillaJS as follows:

var MetaRouter = require('meta-spa-router').MetaRouter;

var config = [
    {
        path: 'a',
        app: '/app-a/dist'
    },
    {
        path: 'b',
        app: '/app-b/dist'
    }
];

window.addEventListener('load', function() { 

    var router = new MetaRouter();
    router.config(config);
    router.init();
    router.preload();

    document.getElementById('link-a')
            .addEventListener('click', function() { router.go('a') });

    document.getElementById('link-b')
            .addEventListener('click', function() { router.go('b') });

    document.getElementById('link-aa')
            .addEventListener('click', function() { router.go('a', 'a') });

            document.getElementById('link-ab')
            .addEventListener('click', function() { router.go('a', 'b') });        

}); 

Here is the corresponding HTML for the shell:

<div>
    <a id="link-a">Route to A</a> |
    <a id="link-b">Route to B</a> |
    <a id="link-aa">Jump to A within A</a> |
    <a id="link-ab">Jump to B within A</a>
</div>

<!-- placeholder for routed apps -->
<div id="outlet"></div>

The router inserts iframes as children of the element identified by outlet, and the go method allows switching between them. The example illustrates how to navigate directly to a subroute within one of the child applications as well.

Each routed application leverages the RoutedApp class to establish communication with the shell. This connection keeps the child's router synchronized with that of the shell. In the Angular example, I register it as a service; for other frameworks, direct instantiation is equally feasible.

Because the service is framework-agnostic and lacks Angular metadata for AOT compilation, I define a token in a dedicated file named app.tokens.ts:

import { RoutedApp } from 'meta-spa-router';
import { InjectionToken } from '@angular/core';

export const ROUTED_APP = new InjectionToken<RoutedApp>('ROUTED_APP');

This token is then used to construct a provider for the RoutedApp class:

import { RoutedApp } from 'meta-spa-router';
[...]

@NgModule({
  [...],  
  providers: [{ provide: ROUTED_APP, useFactory: () => new RoutedApp() }],
  bootstrap: [AppComponent]
})
export class AppModule { }

In the AppComponent, I obtain a RoutedApp instance via dependency injection:

// app.component.ts in routed app

import { Router, NavigationEnd } from '@angular/router';
import { Component } from '@angular/core';
import { filter } from 'rxjs/operators';
import { RoutedApp } from 'meta-spa-router';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app';

  constructor(
    private router: Router, 
    @Inject(ROUTED_APP) private routedApp: RoutedApp) {
    this.initRoutedApp();
  }

  initRoutedApp() {

    this.routedApp.config({ appId: 'a' });
    this.routedApp.init();

    this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe((e: NavigationEnd) => {
      this.routedApp.sendRoute(e.url);
    });

    this.routedApp.registerForRouteChange(url => this.router.navigateByUrl(url));
  }

}

I assign an appId that, by convention, matches the child app's path in the shell. Additionally, I synchronize the meta router with the child app's router to ensure seamless navigation.