Dynamic Federation on Micro-Frontend with Module Federation and Angular

Introduction

Angular is used to build rich front-end applications. You can now build Micro-frontend applications using Angular and the Webpack 5 module federation library. Micro front-ends allow you to build and deploy the front-end modules using the microservices approach. Micro-frontend is a pattern that allows you to build the front-end applications as individual applications(remote) that can be integrated into a shell(host) application. Module federation plugin of the Webpack allows you to load these micro-frontend applications into a shell application. 

Dynamic Module Federation

Dynamic Module Federation is a technique that allows an application to determine the location of its remote applications at runtime. It helps to achieve the use case of "Build once, deploy everywhere". Webpack Module Federation supports dynamically defining URLs for our remote applications.

This tutorial helps you to build Micro-frontends using Angular and Webpack Module Federation with the dynamic federation.

Prerequisites

  • Angular CLI- Version 14.0.0 (use exact version)
  • Module Federation library for Angular (@angular-architects/module-federation): 14.3.0 (compatible to Angular 14.0.0)
  • Node 16.x or later
  • Visual Studio Code

Building the angular workspace and creating remote(MFE) and host(shell) projects

1) To create an angular workspace, open the command terminal and run the following command.

ng new bookstore-ws --create-application false --skip-tests

2) Switch to the workspace folder and add two micro-frontend projects and a shell application.

cd bookstore-ws
ng g app book-mfe --skip-tests --routing
ng g app auth-mfe --skip-tests --routing
ng g app bookstore-shell --skip-tests --routing

3) We can also install the bootstrap and jquery libraries for the projects. Install the bootstrap and jquery to the workspace by running the following command.

npm install --save [email protected] jquery

4) Open the angular.json file and register the bootstrap and jquery in the styles and scripts sections of all 3 projects. Below is the sample configuration for the bookstore-shell project.

"styles": [
    "node_modules/bootstrap/dist/css/bootstrap.min.css",
    "projects/bookstore-shell/src/styles.css"
],
"scripts": [
    "node_modules/jquery/dist/jquery.min.js",
    "node_modules/bootstrap/dist/js/bootstrap.min.js"
]

Configure the Book-MFE project

1) Add a home component to the book-mfe project. Also, add a feature module that can be lazily loaded.

ng g c --project book-mfe components/home
ng g module --project book-mfe book --routing

2) Add a component BookListComponent to the book module in the book-mfe project.

ng g c --project book-mfe --module book book/components/book-list

3) Update the routing file in the book module to add the route for loading the book-list component. Open the book-routing.module.ts file and update the route with the following.

const routes: Routes = [
{
    path:'list',
    component:BookListComponent
}];

4) We need to update the main routing file -app-routing.module.ts. Add the following routes to it.

const routes: Routes = [
  {
    path: '',
    component: HomeComponent,
    pathMatch: 'full'
  },
  {
    path: 'books',
    loadChildren: () => import('./book/book.module').then(m => m.BookModule)
  }
];

5) Open the app.component.html file and add the following HTML code.

    <nav class="navbar navbar-expand-lg bg-primary bg-body-tertiary" data-bs-theme="dark">
        <div class="container-fluid">
          <a class="navbar-brand" href="#">Books MFE</a>
          <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
          </button>
          <div class="collapse navbar-collapse" id="navbarNav">
            <ul class="navbar-nav">
              <li class="nav-item">
                <a class="nav-link active" aria-current="page" routerLink="">Home</a>
              </li>
              <li class="nav-item">
                <a class="nav-link" routerLink="books/list">Books</a>
              </li>
            </ul>
          </div>
        </div>
      </nav>
      <div class="container">
        <router-outlet></router-outlet>
      </div>

Configure the Auth-MFE project

1) We must also add a HomeComponent and a feature module to the auth-mfe project.

ng g c --project auth-mfe components/home
ng g module --project auth-mfe auth --routing

2) Add a component LoginComponent to the auth module in the auth-mfe project.

ng g c --project auth-mfe --module auth auth/components/login

3) Update the routing file in the auth module to add the route for loading the login component. Open the auth-routing.module.ts file and update the route with the following.

const routes: Routes = [
  {
    path: 'login',
    component: LoginComponent
  }
];

4) We need to update the main routing file -app-routing.module.ts. Add the following routes to it.

const routes: Routes = [
  {
    path: '',
    component: HomeComponent,
    pathMatch: 'full'
  },
  {
    path: 'auth',
    loadChildren: () => import('./auth/auth.module').then(m => m.AuthModule)
  }
];

5) Open the app.component.html file and add the following HTML code.

    <nav class="navbar navbar-expand-lg bg-primary bg-body-tertiary" data-bs-theme="dark">
      <div class="container-fluid">
        <a class="navbar-brand" href="#">Auth MFE</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarNav">
          <ul class="navbar-nav">
            <li class="nav-item">
              <a class="nav-link active" aria-current="page" routerLink="">Home</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" routerLink="auth/login">Login</a>
            </li>
          </ul>
        </div>
      </div>
    </nav>
    <div class="container">
      <router-outlet></router-outlet>
    </div>

Configure the BookStore-Shell project

1) Add a home component to the bookstore-shell project.

ng g c --project bookstore-shell components/home

2) Open the app-routing.module.ts file and add the route for loading the HomeComponent.

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

3) Update the app.component.html file with the following code.

    <nav class="navbar navbar-expand-lg bg-primary bg-body-tertiary" data-bs-theme="dark">
      <div class="container-fluid">
        <a class="navbar-brand" href="#">Book Store</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarNav">
          <ul class="navbar-nav">
            <li class="nav-item">
              <a class="nav-link active" aria-current="page" routerLink="">Home</a>
            </li>
          </ul>
        </div>
      </div>
    </nav>
    <div class="container">
      <router-outlet></router-outlet>
    </div>

Run and test the projects.

Run the following commands in different terminals for running projects.

ng serve auth-mfe -o --port 4200
ng serve book-mfe -o --port 4300    
ng serve bookstore-shell -o --port 5000

Configure the MFE and Shell apps with Module federation

1) Add the module federation to the book-mfe project by running the following command.

ng add @angular-architects/[email protected] --project book-mfe --type remote --port 4300

2) Open the webpack.config.js file in the book-mfe project folder and update the configuration.

const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');

module.exports = withModuleFederationPlugin({

  name: 'book-mfe',

  exposes: {
    './Module': './projects/book-mfe/src/app/book/book.module.ts',
  },

  shared: {
    ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
  },

});

3) We can now add the module federation to the auth-mfe project by running the following command.

ng add @angular-architects/[email protected] --project auth-mfe --type remote --port 4200

4) Open the webpack.config.js file in the auth-mfe project folder and update the configuration.

const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');

module.exports = withModuleFederationPlugin({

  name: 'auth-mfe',

  exposes: {
    './Module': './projects/auth-mfe/src/app/auth/auth.module.ts',
  },

  shared: {
    ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
  },

});

5) Finally, add the module federation to the shell project by running the following command.

ng add @angular-architects/[email protected] --project bookstore-shell --type host --port 5000

6) Update the webpack.config.js with the following code. Make sure the ports are configured correctly.

const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');

module.exports = withModuleFederationPlugin({

  remotes: {
    "bookMfe": "http://localhost:4300/remoteEntry.js",
    "authMfe": "http://localhost:4200/remoteEntry.js",    
  },

  shared: {
    ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
  },

});

7) Open the app-routing.module.ts file in the bookstore-shell project and update the routes with the following code.

const routes: Routes = [
  {
    path:'',
    component:HomeComponent,
    pathMatch:'full'
  },
  {
    path:'books',
    loadChildren:()=>import('bookMfe/Module').then(m=>m.BookModule)
  },
  {
    path:'auth',
    loadChildren:()=>import('authMfe/Module').then(m=>m.AuthModule)
  }
];

8) Create a file with the name decl.d.ts inside the src folder of the bookstore-shell project and add the following lines.

declare module 'bookMfe/Module';
declare module 'authMfe/Module';

9) No, we can update the navigation urls in the app.component.html file of the bookstore-shell project.

    <nav class="navbar navbar-expand-lg bg-primary bg-body-tertiary" data-bs-theme="dark">
      <div class="container-fluid">
        <a class="navbar-brand" href="#">Book Store</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarNav">
          <ul class="navbar-nav">
            <li class="nav-item">
              <a class="nav-link active" aria-current="page" routerLink="">Home</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" routerLink="auth/login">Login</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" routerLink="books/list">Books</a>
            </li>
          </ul>
        </div>
      </div>
    </nav>
    <div class="container">
      <router-outlet></router-outlet>
    </div>

10) Run the following commands in different terminals for running projects.

ng serve auth-mfe -o --port 4200
ng serve book-mfe -o --port 4300    
ng serve bookstore-shell -o --port 5000

11) Open the bookstore-shell project output in the browser and navigate to the Login and Books hyperlinks.

Switch to the dynamic federation

1) Switch to your bookstore-shell application and open the file projects\bookstore-shell\webpack.config.js. Here, remove the registered remotes:

const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');

module.exports = withModuleFederationPlugin({

  remotes: {
    //"bookMfe": "http://localhost:4300/remoteEntry.js",
    //"authMfe": "http://localhost:4200/remoteEntry.js",    
  },

  shared: {
    ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
  },

});

2) Open the file app-routes.module.ts and use the function loadRemoteModule instead of the dynamic import statement.

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './components/home/home.component';

import { loadRemoteModule } from '@angular-architects/module-federation';

const routes: Routes = [
  {
    path: '',
    component: HomeComponent,
    pathMatch: 'full'
  },
  {
    path: 'books',
    loadChildren: () => loadRemoteModule({
      type: 'module',
      remoteEntry: 'http://localhost:4300/remoteEntry.js',
      exposedModule: './Module'
    })
      .then(m => m.BookModule)
  },
  {
    path: 'auth',
    loadChildren: () => loadRemoteModule({
      type: 'module',
      remoteEntry: 'http://localhost:4200/remoteEntry.js',
      exposedModule: './Module'
    })
      .then(m => m.AuthModule)
  }
];

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

3) Remove the decl.d.ts file from the src folder. Restart the shell application and test the navigation urls.

4) We can improve this solution a bit. Ideally, we load the Micro Front-end's remoteEntry.js before Angular bootstraps. Switch to the bookstore-shell project and open the file main.ts. This file contains metadata about the Micro Front-end, especially its shared dependencies. Knowing about them upfront helps Module Federation to avoid version conflicts.

import { loadRemoteEntry } from '@angular-architects/module-federation';

Promise.all([
    loadRemoteEntry({
        type: 'module',
        remoteEntry: 'http://localhost:4200/remoteEntry.js',
    }),
  loadRemoteEntry({
        type: 'module',
        remoteEntry: 'http://localhost:4300/remoteEntry.js',
    })
])
    .catch((err) => console.error('Error loading remote entries', err))
    .then(() => import('./bootstrap'))
    .catch((err) => console.error(err));

5) Run and test the applications using the following command. This will ensure the remoteEntry files are loaded before the shell application bootstraps.

ng serve book-mfe --port 4300 -o
ng serve auth-mfe --port 4200 -o
ng serve bookstore-shell --port 5000 -o

6) So far, we just hardcoded the URLs pointing to our Micro Front-ends. However, we would rather get this information at runtime from a config file or a registry service in a real-world scenario. Switch to the bookstore-shell project and create a file mf.manifest.json in its assets folder (projects\bookstore-shell\src\assets\mf.manifest.json).

{
  "bookMfe": "http://localhost:4300/remoteEntry.js",
  "authMfe": "http://localhost:4200/remoteEntry.js"
}

7) Open the main.ts in the bookstore-shell project and replace the code with the following. This will load the remoteEntry.js files for each micro-frontend using the mf.manifest.json configuration.

import {loadManifest } from '@angular-architects/module-federation';
loadManifest('assets/mf.manifest.json')
    .catch((err) => console.error('Error loading remote entries', err))
    .then(() => import('./bootstrap'))
    .catch((err) => console.error(err));

7) Adjust the lazy route of the bookstore-shell projects to the Micro Front-end (projects/bookstore-shell/src/app/app.routes.ts).

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './components/home/home.component';

import { loadRemoteModule } from '@angular-architects/module-federation';

const routes: Routes = [
  {
    path: '',
    component: HomeComponent,
    pathMatch: 'full'
  },
  {
    path: 'books',
    loadChildren: () => loadRemoteModule({
      type: 'manifest',
      remoteName: 'bookMfe',
      exposedModule: './Module'
    })
      .then(m => m.BookModule)
  },
  {
    path: 'auth',
    loadChildren: () => loadRemoteModule({
      type: 'manifest',
      remoteName: 'authMfe',
      exposedModule: './Module'
    })
      .then(m => m.AuthModule)
  }
];

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

8) Restart the micro-frontends and the shell applications. The shell application can still load the micro-frontends.

[!TIP] The ng add the initial command provides an option --type dynamic-host. This makes ng add to generate the mf.manifest.json and the call to loadManifest in the main.ts.