How to setup a Micro Frontend with Angular and Nx

Webpack 5 introduced a Module Federation Plugin enabling multiple, independently built and deployed bundles of code to form a single application. This is the foundation of Micro Frontend Architecture and the Module Federation Plugin makes implementing such an architecture much simpler.
With Angular 12 adding support for Webpack 5 it increases the viability of scaffolding a Micro Frontend architecture with Angular.

We have added some generators to aid in the scaffolding of the Module Federation configuration required for setting up a Micro Frontend Architecture.

Therefore, using Nx, it can be fairly straightforward to scaffold and build a Micro Frontend Architecture from a monorepo with all the additional benefits of Nx.

In this guide, we'll show you to how to set up a Micro Frontend Architecture with Nx and Angular.

NOTE: When serving Micro-Frontends (MFEs) in dev mode locally, there'll be an error output to the console: import.meta cannot be used outside of a module, and the script that is coming from is styles.js. It's a known error output, but it doesn't actually cause any breakages from as far as our testing has shown. It's because the Angular compiler attaches the styles.js file to the index.html in a <script> tag with defer.

It needs to be attached with type=module, but doing so breaks HMR. There is no way of hooking into that process for us to patch it ourselves.

The good news is that the error doesn't propagate to production, because styles are compiled to a CSS file, so there's no erroneous JS to log an error.

It's worth reiterating that there's been no actual errors or breakages noted from our tests.

What we'll build

We will put together a simple Admin Dashboard application that requires a user to log in to view the protected content.
To achieve this we will have two applications:

  • Admin Dashboard application
    • This will contain the layout of the dashboard and the content that can only be viewed by an authenticated and authorized user.
  • Login application
    • This will contain the forms and logic for authenticating and authorizing a user

When the user tries to view a protected page within the Admin Dashboard, if they are not authenticated we will present them with a login form so that they can authenticate and view the contents of the page.

The end result should look something like this:

First steps

Create an Nx Workspace

To start with, we need to create a new Nx Workspace. We can do this easily with:

# Npm
npx create-nx-workspace ng-mfe
# Yarn
yarn create nx-workspace ng-mfe --packageManager=yarn

You'll be prompted for a preset. We recommend selecting empty as it will allow you finer control over your workspace configuration.

You'll also be prompted if you would like to setup Nx Cloud. For this tutorial select No, however, I highly recommend that you read more about it here.

Add the Angular Plugin

To add Angular-related features to our newly created monorepo we need to install the Angular Plugin. Again, this is pretty easy to do:

NOTE: Check that you are now at the root of your monorepo in your terminal. If not, run cd ng-mfe

# Npm
npm install --save-dev @nrwl/angular
# Yarn
yarn add -D @nrwl/angular

Simple! You are now able to use Nx Generators to scaffold Angular applications and libraries.

Creating our applications

We need to generate two applications that support Module Federation.

We'll start with the Admin Dashboard application which will act as a host application for the Micro-Frontends (MFEs):

# Npm
npx nx g @nrwl/angular:host dashboard
# Yarn
yarn nx g @nrwl/angular:host dashboard

The application generator will create and modify the files needed to set up the Angular application.

Now, let's generate the Login application as a remote application.

# Npm
npx nx g @nrwl/angular:remote login --host=dashboard
# Yarn
yarn nx g @nrwl/angular:remote login --host=dashboard

Note: We provided --host=dashboard as an option. This tells the generator that this remote application will be consumed by the Dashboard application. The generator will automatically link these two applications together in the mfe.config.js that gets used in the webpack.config.js.

Note: The RemoteEntryModule generated will be imported in app.module.ts file, however, it is not used in the AppModule itself. This is to allow TS to find the Module during compilation, allowing it to be included in the built bundle. This is required for the Module Federation Plugin to expose the Module correctly. You can choose to import the RemoteEntryModule in the AppModule if you wish, however, it is not necessary.

What was generated?

Let's take a closer after generating each application.

For both applications, the generator did the following:

  • Created the standard Angular application files
  • Added a mfe.config.js file
  • Added a webpack.config.js and webpack.prod.config.js
  • Added a bootstrap.ts file
  • Moved the code that is normally in main.ts to bootstrap.ts
  • Changed main.ts to dynamically import bootstrap.ts (this is required for the Module Federation to correct load versions of shared libraries)
  • Updated the build target in the project.json to use the @nrwl/angular:webpack-browser executor (this is required as it supports passing a custom Webpack configuration to the Angular compiler)
  • Updated the serve target to use @nrwl/angular:webpack-server (this is required as we first need Webpack to build the application with our custom Webpack configuration)

The key differences reside within the configuration of the Module Federation Plugin within each application's mfe.config.js.

We see the following within Login's micro frontend configuration:

1module.exports = {
2  name: 'login',
3  exposes: {
4    './Module': 'apps/login/src/app/remote-entry/entry.module.ts',
5  },
6};

Taking a look at each property of the configuration in turn:

  • name is the name that Webpack assigns to the remote application. It must match the name of the application.
  • exposes is the list of source files that the remote application provides consuming shell applications for their own use.

This config is then used in the webpack.config.js file:

1const { withModuleFederation } = require('@nrwl/angular/module-federation');
2const config = require('./mfe.config');
3module.exports = withModuleFederation(config);

We can see the following in Dashboard's micro frontend configuration:

1module.exports = {
2  name: 'dashboard',
3  remotes: ['login'],
4};

The key difference to note with the Dashboard's configuration is the remotes array. This is where you list the remote applications you want to consume in your host application.

You give it a name that you can reference in your code, in this case login. Nx will find where it is served.

Now that we have our applications generated, let's move on to building out some functionality for each.

Adding Functionality

We'll start by building the Login application, which will consist of a login form and some very basic and insecure authorization logic.

User Library

Let's create a user data-access library that will be shared between the host application and the remote application. This will be used to determine if there is an authenticated user as well as providing logic for authenticating the user.

nx g @nrwl/angular:lib shared/data-access-user

This will scaffold a new library for us to use.

We need an Angular Service that we will use to hold state:

nx g @nrwl/angular:service user --project=shared-data-access-user

This will create a file user.service.ts under the shared/data-access-user library. Change it's contents to match:

1import { Injectable } from '@angular/core';
2import { BehaviorSubject } from 'rxjs';
3
4@Injectable({
5  providedIn: 'root',
6})
7export class UserService {
8  private isUserLoggedIn = new BehaviorSubject(false);
9  isUserLoggedIn$ = this.isUserLoggedIn.asObservable();
10
11  checkCredentials(username: string, password: string) {
12    if (username === 'demo' && password === 'demo') {
13      this.isUserLoggedIn.next(true);
14    }
15  }
16
17  logout() {
18    this.isUserLoggedIn.next(false);
19  }
20}

Add a new export to the shared/data-access-user's index.ts file:
export * from './lib/user.service';

Login Application

First, add FormsModule to the imports array in your remote-entry/entry.module.ts file:

1import { NgModule } from '@angular/core';
2import { FormsModule } from '@angular/forms';
3import { CommonModule } from '@angular/common';
4import { RouterModule } from '@angular/router';
5
6import { RemoteEntryComponent } from './entry.component';
7
8@NgModule({
9  declarations: [RemoteEntryComponent],
10  imports: [
11    CommonModule,
12    FormsModule,
13    RouterModule.forChild([
14      {
15        path: '',
16        component: RemoteEntryComponent,
17      },
18    ]),
19  ],
20  providers: [],
21})
22export class RemoteEntryModule {}

Next we want to set up our entry.component.ts file so that it renders a login and has injected our UserService to allow us to sign the user in:

1import { Component } from '@angular/core';
2import { UserService } from '@ng-mfe/shared/data-access-user';
3
4@Component({
5  selector: 'ng-mfe-login-entry',
6  template: `
7    <div class="login-app">
8      <form class="login-form" (ngSubmit)="login()">
9        <label>
10          Username:
11          <input type="text" name="username" [(ngModel)]="username" />
12        </label>
13        <label>
14          Password:
15          <input type="password" name="password" [(ngModel)]="password" />
16        </label>
17        <button type="submit">Login</button>
18      </form>
19      <div *ngIf="isLoggedIn$ | async">User is logged in!</div>
20    </div>
21  `,
22  styles: [
23    `
24      .login-app {
25        width: 30vw;
26        border: 2px dashed black;
27        padding: 8px;
28        margin: 0 auto;
29      }
30      .login-form {
31        display: flex;
32        align-items: center;
33        flex-direction: column;
34        margin: 0 auto;
35        padding: 8px;
36      }
37      label {
38        display: block;
39      }
40    `,
41  ],
42})
43export class RemoteEntryComponent {
44  username = '';
45  password = '';
46
47  isLoggedIn$ = this.userService.isUserLoggedIn$;
48
49  constructor(private userService: UserService) {}
50
51  login() {
52    this.userService.checkCredentials(this.username, this.password);
53  }
54}

Note: This could be improved with error handling etc. but for the purposes of this tutorial, we'll keep it simple.

Let's add a route to our Login application so that we can render the RemoteEntryComponent.
Open app.module.ts and add the following route to the RouterMoodule.forRoot(...) declaration.

1RouterModule.forRoot(
2  [
3    {
4      path: '',
5      loadChildren: () =>
6        import('./remote-entry/entry.module').then((m) => m.RemoteEntryModule),
7    },
8  ],
9  { initialNavigation: 'enabledBlocking' }
10);

Now let's serve the application and view it in a browser to check that the form renders correctly.

nx run login:serve

We can see if we navigate a browser to http://localhost:4201 that we see the login form rendered:

Login Application

If we type in the correct username and password (demo, demo), then we can also see the user gets authenticated!

Perfect! Our login application is complete.

Dashboard Application

Now let's create the Dashboard application where we'll hide some content if the user is not authenticated. If the user is not authenticated, we will present them with the Login application where they can log in.

For this to work, the state within UserService must be shared across both applications. Usually, with Module Federation in Webpack, you have to specify the packages to share between all the applications in your Micro Frontend solution.
However, by taking advantage of Nx's project graph, Nx will automatically find and share the dependencies of your applications.

Note: This helps to enforce a single version policy and reduces the risk of Micro Frontend Anarchy

Now, let's delete the app.component.html and app.component.css files in the Dashboard application. They will not be needed for this tutorial.

Finally, let's add our logic to app.component.ts. Change it to match the following:

1import { Component, OnInit } from '@angular/core';
2import { Router } from '@angular/router';
3import { distinctUntilChanged } from 'rxjs/operators';
4
5import { UserService } from '@ng-mfe/shared/data-access-user';
6
7@Component({
8  selector: 'ng-mfe-root',
9  template: `
10    <div class="dashboard-nav">Admin Dashboard</div>
11    <div *ngIf="isLoggedIn$ | async; else signIn">
12      You are authenticated so you can see this content.
13    </div>
14    <ng-template #signIn><router-outlet></router-outlet></ng-template>
15  `,
16  styles: [``],
17})
18export class AppComponent implements OnInit {
19  isLoggedIn$ = this.userService.isUserLoggedIn$;
20
21  constructor(private userService: UserService, private router: Router) {}
22
23  ngOnInit() {
24    this.isLoggedIn$
25      .pipe(distinctUntilChanged())
26      .subscribe(async (loggedIn) => {
27        if (!loggedIn) {
28          this.router.navigateByUrl('login');
29        } else {
30          this.router.navigateByUrl('');
31        }
32      });
33  }
34}

We can run both the dashboard application and the login application and you can try it out using:

nx run dashboard:serve-mfe

Conclusion

As you can see, with this approach, your Login application can be deployed independently and developed independently without forcing you to have to rebuild or redeploy your Dashboard application. This can lead to a powerful micro frontend architecture that enables multiple teams to work independently in a single monorepo!

In this tutorial, we exposed a single module that was consumed dynamically as an Angular Route.

References and Further Reading