Micro-frontend with Angular

by Erle Monfils | | 17 min read

Last week I have been experimenting with using micro frontends in Angular. Micro frontends are a big thing nowadays, and we were asked to look into the technique in combination with the Angular framework. If you aren't familiar yet with the concept of micro frontends, you might scratch your head and ask:

Hold on... What are micro frontends?

The term 'micro frontends' was framed in 2016, although the technique built on pre-existing ideas about frontend architecture. Basically, it uses a micro service architecture to prevent your application from exploding into a huge frontend monolith that is difficult to maintain. Michael Geers explains micro frontends as follows:

The idea behind Micro Frontends is to think about a website or web app as a composition of features which are owned by independent teams. Each team has a distinct area of business or mission it cares about and specialises in. A team is cross functional and develops its features end-to-end, from database to user interface.

In other words, in a micro frontend approach an application is composed of different, self-contained micro apps that are each responsible for a specific feature, and that can be developed on and deployed seperately by different teams. Each frontend micro app would communicate with its own API service and have its own database.

Micro FE This image was taken from this article by Rohit Saxena

Using micro frontends has several benefits:

  • It facilitates more efficient collaboration and less merge conflicts, since each team develops and tests its own micro app;
  • Because the micro apps contain strictly separated features, it may result in a simpler application architecture;
  • It is framework agnostic, so you'd have the possibillity to work with several different frameworks in your application (provided you have a good reason to do so; see this article by Ehsan Motamedi, also referred to below);
  • It allows you to update or migrate your application in a piecemeal fashion, thus avoiding the situation where you ship a huge change in one go and increase the risk of bugs.

To me it seems like using micro frontends is mostly beneficial for large applications on which multiple teams could be working; although the benefits of incrementially updating or migrating to another technology definitely exist for a smaller team as well, the overhead that comes with a micro frontend architecture could be considered a downside. Ehsan Motamedi wrote a great article about the pros, cons, and possible approaches for teams that consider using micro frontends.

Sounds cool! But how exactly do we integrate the micro apps into our application?

Although no dominant standard of implementing micro frontends has emerged yet, a widely used approach is to create custom elements, using the Web Components standard. In a nutshell, a custom element is an HTML element that encapsulates its own customized functionality, and that you can refer to in HTML via a custom tag, the way you would refer to a regular HTML element. Since the features required to run these components are supported by all majors browsers, they are framework agnostic. Of course you will need a parent application that takes care of integrating the various custom elements, so as different teams you might still need to agree on some conventions for the shared code.

There are several libraries that will help you build such elements, such as LitElement and Stencil; here is a more extensive list of some of the better-known libraries. Angular has its own library, Angular Elements, which allows you to create a web component from an Angular component. Although Angular doesn't provide official support yet for using these components in non-Angular projects, there are several ways in which it can be done anyway - I'll gladly refer you to this article by Nishu Goel and this article by Shane Williamson, which show how the magic happens.

So how do we do this in Angular?

Enough theory - let's go get our hands dirty! We will build a small Angular application, using a micro frontend that is created with Angular Elements. We will specifically look at the case of routing withing an Angular custom element in combination with routing in the parent application. We will also use the NgRx library for state management. It will definitely be a plus if you have some prior knowledge of Angular when following this tutorial, as I will focus on Angular Elements and won't explain all the Angular-specific code.

For this tutorial I am especially indebted to this article of Rohit Saxena. You can find the github repo for this tutorial here. Be aware that the code in the repo is a bit more complex than the code shown in this tutorial. For example, in the repo we use NgRx for state management, but I left it out of this article to avoid complicating things.

Create an Angular project

First, make sure that you have Node installed. Also, you need the Angular command line interface in order to easily create and develop on an Angular project. You can do so by running this command in your terminal:

npm install -g @angular/cli

Next, we will create a new Angular project. I am going to build a library application where I can see a list of books that I like, so I will call the project my-library:

ng new my-library

You can answer "yes" to all the configuration questions. In particular, make sure to answer "yes" when you're asked if you want to install routing.

Angular will now generate a project folder with whole lot of files for you. Go into the project folder, and run npm run start in the terminal. The project will now be available at http://localhost:4200.

Note that the following part shows you how to setup a basic Angular application to work with, and is not strictly micro-frontend-related. If you just want to learn about micro frontends, you can skip to the following section.

All right, time to start building our library! Go into the app-component.html and throw away all the code in there. Replace it with the following:

// my-library/src/app/app.component.html

<header>
<h1>Our personal library</h1>
<a [routerLink]="'/library'">go to library</a>
</header>
<main>
<router-outlet></router-outlet>
</main>

Here, we're showing a header for our library, and a link to actually visit the library. Also, we add the router outlet, so that we can navigate to different routes later on.

Our library application will have a page with a book list, and also a page that shows the details of a single book. Let's first create the book list. Add a directory called 'library' to the app directory, and add a component called library-page.component.ts to this library folder. In library-page.component.ts, we will show a list of books. We want each book to be a link to a detailed book page. Our component will look something like this:

// my-library/src/app/library/library-page.component.ts

import {Component} from '@angular/core';
import {Observable} from 'rxjs';

@Component({
selector: 'app-library',
template: `
<h1>Library</h1>
<ol *ngIf="books$ | async as books">
<li *ngFor="let book of books">
<a [routerLink]="'/library/' + book.id"></a>
</li>
</ol>
`

})
export class LibraryPageComponent {
books$: Observable<Book[]>;

constructor() {}
}

We will retrieve the books from a service, where they are stored in a BehaviourSubject. Create a file book.service.ts and add the following code.

// my-library/src/app/library/books.service.ts

import {Injectable} from '@angular/core';
import {BehaviorSubject} from 'rxjs';

export interface Book {
id: string;
title: string;
excerpt: string;
price: number;
currency: 'EUR' | 'GBP' | 'USD';
author: {
name: string;
surname: string;
};
}

@Injectable()
export class BooksService {
books$: BehaviorSubject<Book[]> = new BehaviorSubject<Book[]>([
{
id: 'foo',
title: 'Neverending story',
price: 3.99,
currency: 'GBP',
excerpt: 'A boy ends up in a magical book.',
author: {
name: 'Michael',
surname: 'Ende',
},
},
{
id: 'bar',
title: 'Code name Verity',
price: 8.99,
currency: 'EUR',
excerpt: 'A story about the friendship between two young British women who are stationed at the same airfield during WWII',
author: {
name: 'Elizabeth',
surname: 'Wein',
},
},
{
id: 'zoo',
title: 'Americanah',
price: 5.99,
currency: 'USD',
excerpt: 'A Nigerian girl learns about cultures and race during her time in the US',
author: {
name: 'Chimamanda',
surname: 'Ngozi Adichie',
},
}
]);
}

Notice that we also created a Book interface in the service; since we were expecting an array of type Book in our LibraryPageComponent, we can use this interface from in the LibraryPageComponent. Also, we assign the service's books$ value to the books$ Observable in the LibraryPageComponent:

// my-library/src/app/library/library-page.component.ts

...
import { Book, BooksService } from './books.service';

...
export class LibraryPageComponent {
books$: Observable<Book[]>;

constructor(private booksService: BooksService) {
this.books$ = this.booksService.books$;
}
}

We are also going to create a separate module for the library, where we declare the component and provide the service:

// my-library/src/app/library/library.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';

import { LibraryPageComponent } from './library-page.component';
import { BooksService } from './books.service';

@NgModule({
declarations: [
LibraryPageComponent,
LibraryRoutingModule,
],
imports: [
CommonModule,
],
providers: [BooksService]
})
export class LibraryModule { }

We don't actually have a LibraryRoutingModule yet, so let's make it and enable routing to the book list. We create a file called library-routing.module.ts. In it, we define a route to the LibraryPageComponent and a new routing module (don't forget to import it into the LibraryModule):

// my-library/src/app/library/library-routing.module.ts

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LibraryPageComponent } from './library-page.component';

const routes: Routes = [
{ path: '', pathMatch: 'full', component: LibraryPageComponent },
];

@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LibraryRoutingModule { }

Then, we enable lazy loading the library module in the app-routing.module.ts, and accessing it through the 'library' path:

// my-library/src/app/app-routing.module.ts

import { NgModule } from '@angular/core';
import {RouterModule, Routes} from '@angular/router';

const routes: Routes = [
{
path: 'library',
loadChildren: () => import('./views/library/library.module').then(m => m.LibraryModule),
},
{
path: '**',
redirectTo: '',
}
];

@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}

Let's check if everything works as expected. Go to http://localhost:4200 in the browser (start the server if it wasn't running already). You should see the following screen:

View of the library app in the browser

Clicking on 'go to library' should show you the list with books:

View of the library app showing a list of books

We also want a detail page for each book. In the library directory, create a file book-detail.component.ts:

// my-library/src/app/library/book-detail.component.ts

import { Component } from '@angular/core';
import { Book, BooksService } from './books.service';

@Component({
selector: 'app-book-detail',
template: `
<h1>Book detail</h1>

<ng-container *ngIf="book$ | async as book">
<p>Author: <span> </span></p>
<p>Title: </p>
<p>Price: </p>
</ng-container>
`

})
export class BookDetailComponent {
book$!: Observable<Book | undefined>;

constructor(private booksService: BooksService, private route: ActivatedRoute) {
const bookId = route.snapshot.paramMap.get('bookId');

if (bookId) {
this.book$ = this.booksService.getBookById(bookId);
}
}
}

Add the getBookById function to the BooksService:

// my-library/src/app/library/books.service.ts

...
export class BooksService {
...

getBookById(bookId: string): Observable<Book | undefined> {
return this.books$.pipe(
map((books) => books.find((book) => book.id === bookId))
);
}
}

Finally, add the BookDetailComponent to the LibraryModule...

// my-library/src/app/library/library.module.ts

...
declarations: [
LibraryPageComponent,
BookDetailComponent,
],
...

... and include a route to the BookDetailComponent in the LibraryRoutingModule:

// my-library/src/app/library/library-routing.module.ts

...
const routes: Routes = [
{ path: '', pathMatch: 'full', component: LibraryPageComponent },
{ path: ':bookId', component: BookDetailComponent },
];
...

The initial setup of our app should be complete now. Go to your browser again: everything should be working!

View of library app with book detail view

Setup for micro app routing

Here things are becoming a bit more tricky. When building the application, we specifically wanted to achieve the following things:

  • We wanted the micro app to be shown on routing to a specific path on the parent app;
  • We wanted the url path to look as follows: < base-url >/< parent-path >/< path-from-micro-app > (e.g. localhost:4200/library-app/library).

However, having routing in both the parent and the micro app means that the two routing systems will clash if we don't implement a mechanism to prevent this. This is the part that gave me a headache: Angular doesn't provide a way to handle this yet (at the time of writing this article, there is an open issue requesting to handle this). Timon Grassl has written an article on how to do this with named router outlets. But this didn't fit our use case entirely, and also after having made a few tweaks I didn't get it to work for us. In the end, I settled on using Angular's @Input and @Output directives for the child and parent to know of each other's url. This required some extra configuration to handle browser refresh and navigation in the parent application, which I will explain now.

Go to the AppComponent. Here, we want to do the following:

  • We want to add an @Input directive for a router prefix. This will be set to the path from the parent component that initialises the micro app. In this way, the micro app can prefix its own routes with the path from the parent, when necessary.
  • We also want an @Input directive that takes care of situations in which the parent app knows which url the micro app needs to navigate to, while the micro app might not. For example, in the case of a browser refresh. Whenever this input fires, we want the micro app to navigate to the correct url. In other words, we need a setter to take care of this.

Let's add these things to the AppComponent:

// my-library/src/app/app.component.ts

...
export class AppComponent {
@Input() routerPrefix = ''; // Prefix of parent app

// Via this setter, the host app can force the micro app to navigate to a given url
@Input() set navigateTo(url: string) {
this.router.navigate([url]);
}

...
}

Next, we want to prevent the parent path from disappearing whenever the router of the micro app takes over. We will do this by subscribing to the micro app's router events, and prefixing the path with the parent path every time the parent path is absent.

Since the router events fire many time during routing, and we are only really interested in the moment that navigation has finished, we will filter on the NavigationEnd event. Of course, we want the events to stop firing once the component is removed from the DOM, so we use the OnDestroy hook to take care of this:

// my-library/src/app/app.component.ts

...
export class AppComponent {
...

destroy$ = new Subject<any>();

constructor(
private router: Router,
private location: Location
) {
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
takeUntil(this.destroy$)
).subscribe((event) => {
const url = (event as NavigationEnd).urlAfterRedirects;

// Prefix the micro app url with the prefix received by the host app
if (!url.startsWith(`/${this.routerPrefix}`)) {
this.location.replaceState(this.routerPrefix + url);
}
});
}

ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

So far, so good. Next, we need to let the router know that it is okay if the library route is prefixed with an unknown string. Therefore, we open the AppRoutingModule and add another route to load the LibraryModule:

// my-library/src/app/app-routing.module.ts

...

const routes: Routes = [
{
path: ':prefix/library',
loadChildren: () => import('./views/library/library.module').then(m => m.LibraryModule),
},
{
path: 'library',
loadChildren: () => import('./views/library/library.module').then(m => m.LibraryModule),
},
{
path: '**',
redirectTo: '',
}
];

...

We need to take care of one more issue. When creating this application, after I had build the micro app and loaded it into the host app, I ran into an issue: the micro app remembered its last router state. Therefore, whenever I navigated to another url within the parent application and then back to our micro app, it loaded the last viewed components. Having the parent app tell the micro app to navigate to the base url on initialision (via the navigateTo setter) didn't work consistently, since even when the micro app started out with only the base url, the components were loaded.

I solved this by changing the default router behaviour for navigating to the current route. As per its standard configuration, the Angular router ignores attempts to navigate again to the route that's currently active. In other words, when navigating to the base url when the application already displays the page for the base url, results in the router events not being fired and the component not being reloaded. We can override this behaviour by adding the following configuration to the forRoot method in the AppRoutingModule:

// my-library/src/app/app-routing.module.ts

...

@NgModule({
imports: [RouterModule.forRoot(routes, { onSameUrlNavigation: 'reload' })], // Force router events to fire again
exports: [RouterModule]
})
export class AppRoutingModule {
}

To reload the AppComponent on navigation to the same route, we make sure that the shouldReuseRoute method returns false:

// my-library/src/app/app.component.ts

...
export class AppComponent {
...

constructor(
private router: Router,
private location: Location
) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;

...
}

...
}

And we're done! Check if everything is still working in the browser. All good? Then it's time to turn our Angular application into a custom element.

Use Angular Elements to create a custom element

We are finally reading to make a custom element of our application. The first thing we have to do is to import our LibraryModule directly into the AppModule:

// my-library/src/app/app.module.ts

...
imports: [
BrowserModule,
AppRoutingModule,
LibraryModule,
],
...

Also in the AppModule, the component that will be created as a custom element should be added to an entryComponents array. This means that it will be loaded by type, instead of via its selector in a template. In our case, the component that encompasses our micro app is the AppComponent.

Furthermore, we don't want to bootstrap AppComponent when the micro app is mounted to run inside the parent component. Therefore, we bootstrap the component conditionally, dependent on a variable that we can manually change:

// my-library/src/app/app.module.ts

...

const localDevelopment = true;

@NgModule({
...
entryComponents: [AppComponent],
bootstrap: [localDevelopment ? AppComponent : []]
})
...

Then, when exporting the AppModule, we implement the code to actually create a custom element from our Angular application:

// my-library/src/app/app.module.ts

...
import { createCustomElement } from '@angular/elements';
...
export class AppModule implements DoBootstrap {
constructor(private injector: Injector) {
const libraryApp = createCustomElement(AppComponent, {injector: this.injector});

customElements.define('my-library', libraryApp);
}

ngDoBootstrap(appRef: ApplicationRef): void {}
}

Here we first use the createCustomElement function from Angular Elements to convert an Angular component into a Web Component. We also pass the injector, so that dependency injection will work at runtime.

Then, we use customElements.define() to register the element with the browser's CustomElementRegistry, so that it can create an instance of the custom element. Here, we specify the selector with which the custom element can be referred to in HTML templates (in our case 'my-library'). The selector must consist of at least two words separated by a dash. This is demanded by the Custom Elements API so that it can distinguish between custom elements and native HTML tags.

We do all this at the bootstrapping level at the module, by using the DoBootstrap hook.

Now our app is basically ready to be used as a custom element in another Angular application. There are multiple ways to go about this, but for now, we will simply build the application and copy-paste it into our (soon-to-exist) parent application. We will use the ngx-build-plus package for this, since it allows us to build the project into a single file. Run the following command:

ng add ngx-build-plus

This command changes your build configuration in angular.json, but we need to make an additional change. Open angular.json, find the right 'builder' key, and change the value to "ngx-build-plus:build". Here is where to find the correct key:

// my-library/angular.json

...
"projects": {
"my-library": {...,
"architect": {
"build": {
"builder": "ngx-build-plus:build"
...

Then, go to package.json and add the following scripts. Pay close attention that if you gave your project another name than 'my-library', you also have to change the baseDir flag in the buildrun script! If you forget this, I can tell you from experience that the resulting console error might leave you pulling your hair out if you don't pay close enough attention.

// my-library/package.json

...
"scripts": {
...
"build": "ng build --prod --single-bundle --output-hashing none --vendor-chunk false",
"buildrun": "npm run build && lite-server --baseDir=dist/my-library",
...

You can test this by making sure that the localDevelopment variable in the AppModule is set to true, and then running npm run buildrun. Now, a dist folder with the project build should have been created in the root directory. The browser will open automatically, and you should able to view a built version of your micro app that still works fine.

Add the micro app to the parent app

Now comes the fun part: using our micro app in another application. Set the localDevelopment variable in your micro app to false, and run npm run buildrun again. Then, create a new angular project that will serve as the parent application:

ng new parent-app

Create a folder called 'web-components' in the src folder of your parent project. Then go to the dist folder that has been created in your micro app, and copy paste the entire project folder - in our case, the folder called 'my-library' - to the web-components folder in your parent app. Then, in the AppModule of the parent, import the main.js file that contains the micro app code:

// parent-app/src/app/app.module.ts

import '../webcomponents/my-library/main';

@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
AppRoutingModule,
],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule { }

Angular now has access to our micro app. However, we still have to do some work before being able to use the <my-library></my-library> tag, as Angular doesn't allow custom elements out of the box. Also, as mentioned before, we want the micro app to be loaded when navigating to a certain route. We will solve these issues by creating a wrapper component and accompanying module for our micro app.

Start by creating a folder called 'library-app' in the app folder, and inside this folder create a file called library-app.component.ts. Here, we create a new component that references our micro app through its custom tag. We will create properties for its input attributes, and assign an initial value to them. Note that by setting microUrl to an empty string, we are making sure that the micro app always navigates to the base url on initialisation. We will later implement functionality that, when necessary, allows it to navigate to other micro url paths on initialisation.

// parent-app/src/app/library-app/library-app.component.ts

import {Component, OnDestroy} from '@angular/core';
import {ActivatedRoute, NavigationEnd, Router} from '@angular/router';
import {filter, takeUntil} from 'rxjs/operators';
import {Subject} from 'rxjs';

@Component({
selector: 'app-library-app',
template: `
<my-library
[routerPrefix]="routerPrefix"
[navigateTo]="microUrl"></my-library>
`
,
})
export class LibraryAppComponent {
public routerPrefix = '';
public microUrl = '';
}

Next, we create a file called library-app.module.ts, which contains a module for the micro app:

// parent-app/src/app/library-app/library-app.module.ts

import {CUSTOM_ELEMENTS_SCHEMA, NgModule} from '@angular/core';
import {LibraryAppComponent} from './library-app.component';
import {RouterModule, Routes} from '@angular/router';

const routes: Routes = [
{
path: '**',
component: LibraryAppComponent,
},
];

@NgModule({
declarations: [LibraryAppComponent],
imports: [RouterModule.forChild(routes)],
schemas: [CUSTOM_ELEMENTS_SCHEMA] // Allows us to use custom elements in the templates of the components declared above
})
export class LibraryAppModule { }

There are a few things to clarify here:

  • We define the path that leads to the micro app component with a wildcard. This is because it needs to accept all the different paths of the micro app, which of course the parent doesn't know about. If we enter a path in the browser that doesn't exist on the micro app, the micro app will take care of that.

    (We didn't create a separate routing file in this case, as we know that in our wrapper module we will only declare one component and need one route.)

  • As you can see, we define a schemas array containing the CUSTOM_ELEMENTS_SCHEMA. As I mentioned above, Angular doesn't allow tags for custom elements by default; for every tag that it doesn't recognize as HTML element or declared Angular component, it will throw an error. CUSTOM_ELEMENTS_SCHEMA tells an Angular module to allow any tag, as long as it contains a dash.

    A note: right now, Angular doesn't allow you to specify the custom elements you want to use in a module. In our case this doesn't pose a problem, since we use CUSTOM_ELEMENTS_SCHEMA in a wrapper module specifically meant only for our micro app. But consider the case in which you'd want to integrate your custom element directly into a template with other elements, and/or into a module with other components: you'll end up either removing Angular's check on if you actually use existing tags, or you'll end up making a wrapper for every micro app, which feels like unnecessary boilerplate code. Actually, more than one issue has been created on the Angular Github page to address this, so let's hope that some solution will be released in the future.

The last thing we need to do to see our micro component in action, is actually create a way to get access to it. Go to app.component.html in the parent app, throw away the code generated by the Angular CLI, and add a link that will open the micro component, as well as a router outlet and a heading for our parent app:

// parent-app/src/app/app.component.html

<h1>Parent app</h1>
<a [routerLink]="'/library-app'">Show library app</a>
<router-outlet></router-outlet>

Then, add a route in the parent app-routing.module.component.ts that loads the wrapper module for the micro app:

// parent-app/src/app/app-routing.module.component.ts

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

const routes: Routes = [
{
path: 'library-app',
loadChildren: () => import('./src/app/wrappers/library-app/library-app.module').then(m => m.LibraryAppModule),
},
{
path: '**',
redirectTo: '/',
pathMatch: 'full'
}
];

@NgModule({
imports: [
RouterModule.forRoot(routes)
],
exports: [RouterModule]
})
export class AppRoutingModule { }

Now, when you run npm run start in the parent project, go to http://localhost:4200/, and click on the link 'Show library app', you will see your micro app in action!

View of parent app with library app in browser

There is one more thing we need to do. While the micro app seems to work well, you'll see that things are not quite going the way we want once you look at the url. When we click on 'Show library app', our parent path is visible for a moment, but then quickly disappears. And it stays gone when we navigate through the micro app. This is, of course, because we are not yet passing the parent path to the micro app. Let's change this!

We will go to our wrapper LibraryAppComponent, retrieve the parent path, and assign it to the routerPrefix property that we passed on earlier to the micro app. We will do this by using the parent property on Angular's ActivatedRoute. Here, you can find the segments of the parent url and join them together to get the full path:

// parent-app/src/app/library-app/library-app.component.ts

...
import {ActivatedRoute} from '@angular/router';

...
export class LibraryAppComponent {
public routerPrefix = '';
....

constructor(private route: ActivatedRoute) {
// We pass the segments from the parent route url as prefix to the micro app
this.routerPrefix = this.route.parent?.snapshot.url.map((segment) => segment.path).join('/') || '';
}
}

We also want to pass the path from the router module in the wrapper module to the micro app (I hope you got that!). You will remember that we defined this path with a wildcard, so that it could capture any route from the micro app. Retrieving the actual path and passing it on to the micro app will allow the micro app to navigate to this path on initialisation. This is useful in case of a browser refresh, or when we want to navigate directly from the parent to a specific path in the micro app.

We will do this by listening to the parent app's router events and again using a route snapshot, this time to get the path of the wrapper's router module. To avoid duplicate code, we create a private function that takes care of returning the correctly formatted path. And of course, here, too, we need to unsubscribe from the router events once the component is destroyed:

// parent-app/src/app/library-app/library-app.component.ts

import {Component, OnDestroy} from '@angular/core';
import {ActivatedRoute, NavigationEnd, Router} from '@angular/router';
import {filter, takeUntil} from 'rxjs/operators';
import {Subject} from 'rxjs';

...
export class LibraryAppComponent {
....

destroy$ = new Subject<any>();

constructor(private route: ActivatedRoute, private router: Router) {
// We pass the segments from the parent route url as prefix to the micro app
this.routerPrefix = this.route.parent ? this.getRoutePath(this.route.parent) : '';

this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
takeUntil(this.destroy$)
).subscribe((event) => {
// Since we want the micro app to also navigate to the correct url on refresh or on parent navigation to micro app,
// we pass the segments of the url from the wrapper module.
this.microUrl = this.getRoutePath(this.route);
});
}

private getRoutePath(route: ActivatedRoute): string {
return route?.snapshot.url.map((segment) => segment.path).join('/') || '';
}

ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

Now our app should be able to handle routing between parent and micro app properly. Open de application once again in the browser, and enjoy your finished library application!

Cautionary note: using property binding with Angular Elements

In this tutorial we focused on handling routing between parent and micro app. We didn't look extensively at property and event binding between parent app and micro app. But when you continue playing around with Angular Elements, you soon will notice that things don't work entirely as you'd expect in an Angular micro app. Normally, an Angular component waits for its inputs to be filled before rendering the component. So in the OnInit hook we can expect to to be able to use the input values that are passed on from a parent component. It doesn't work like this with an Angular custom element, since Angular doesn't know what the custom element looks like and will just render it immediately. In the above-mentioned article, Rohit Saxena proposes a simple workaround for this using the OnChanges hook; check it out if you plan to experiment some more with Angular Elements.

Conclusion

If you followed me through until here, well done! I hope you've enjoyed playing around with micro frontends as much as I did. For large projects with a large team, adopting a micro frontend architecture really seems like the way forward - it makes collaboration more efficient, and offers the benefit of being able to migrate or update incrementally. Many large companies are using this technique nowadays, and it's easy to see why.

As for using specifically Angular Elements as micro frontend, I like the ease with which you can set up a micro frontend and integrate it into an existing Angular application. I do think that some things could be improved to mature the technique a bit more. As we saw in this article,

  • there isn't a unified and straightforward strategy yet to handle routing in micro apps in combination with routing in the parent app;
  • Angular doesn't let you specify which custom elements are allowed yet, which, depending on your use case and approach, can result in code that is either more bulky or more error-prone;
  • while Angular Elements can be directly used in another Angular project, there is no official support yet for using them as custom elements in another framework, although the workarounds seem to be easy and plenty.

In the future, we are planning to have a closer look at integrating micro apps created with different frameworks and libraries, such as Vue, React, and LitElement, into a single application. So stay tuned!

References and further reading...

Micro frontends theory

Web components

Micro frontends with Angular