Nx and Angular with Rspack and Module Federation

In just a few years, Module Federation grew from a first implementation that shipped with webpack 5 to a whole ecosystem. Meanwhile, we have an implementation for rspack, a port to vite, and a runtime that can be used across the whole ecosystem.

There are also several third parties in this space: Zephyr offers a cloud-based service for fast deployments leveraging Module Federation, and Nx provides a comfortable integration that simplifies building Micro Frontends with Angular, React, and other frameworks and improves incremental builds. Furthermore, there is our take, Native Federation, a portable bundler-agnostic implementation that leverages web standards and integrates into Angular by wrapping the CLI's new esbuild-based ApplicationBuilder.

Since version 20, Nx offers executors integrating rspack and rsbuild. The latter one builds on top of rspack and provides a performant out-of-the-box setup for usual build requirements. Nx' investment into rspack and rsbuild makes incorporating Module Federation on top of rspack easy. While, so far, Nx does not include an official rspack-based executor for Angular projects, Colum Ferry from the Nx team recently published an early version of the open-source package @ng-rsbuild/plugin-nx that fills this gap by leveraging rsbuild. Also, it already supports SSR.

As a community solution, @ng-rsbuild/plugin-nx is not an official part of Nx; however, having someone like Colum behind it immediately builds trust.

In this article, I show how to use this package together with Nx, Angular, and Module Federation to build a Micro Frontend-based solution. For this, I'm using my usual Micro Frontend showcase:

Demo Application

📂 Source Code

Disclaimer: @ng-rsbuild/plugin-nx is currently in Alpha. So things might change and be improved. I will keep you posted here.

Setting up an Nx Workspace

To get started, let's add the rsbuild plugin for Angular to a newly created Nx 19 workspace and generate a shell and a Micro Frontend mfe1 with it:

npx nx add @ng-rsbuild/plugin-nx

npx nx g @ng-rsbuild/plugin-nx:application shell
npx nx g @ng-rsbuild/plugin-nx:application mfe1

For leveraging dynamic Federation, we also need the package @module-federation/enhanced that contains the Module Federation runtime:

npm i @module-federation/enhanced

Implementing the Micro Frontend

The Micro Frontend's AppComponent gets a simple implementation:

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

  search(): void {
    alert('Not implemented!');
  }
}

// Add default export:
export default AppComponent;

Please see this simple component as a replacement for a more complex Micro Frontend that might combine several use cases via routing. Please note that the example also defines a default export for the AppComponent. This simplifies lazy loading it into the shell a bit.

For the sake of completeness, you find the component's markup below:

<div id="container">
    <h1>Select a Flight</h1>
    <div>
        <input type="text" placeholder="From">
    </div>
    <div>
        <input type="text" placeholder="To">
    </div>
    <div>
        <button (click)="search()">Search</button>
    </div>
</div>

Async Bootstrapping

For our Module Federation setup, we need to switch to asynchronous bootstrapping. For this, copy the content of the file mfe1/src/main.ts to a new file mfe1/src/bootstrap.ts that is just imported in the former one:

// mfe1/src/main.ts
import('./bootstrap');

It's important to use a dynamic and hence async import here. This allows Module Federation to initialize and to take care of loading shared packages in all remaining parts of the application.

More on this: Angular Architecture Workshop (online, interactive, advanced)

Become an expert for enterprise-scale and maintainable Angular applications with our Angular Architecture workshop!

English Version | German Version

Configuring the Micro Frontend

For configuring Module Federation, we need to adjust the rsbuild configuration, @ng-rsbuild/plugin-nx generated in mfe1/rsbuild.config.ts:

import { createConfig } from '@ng-rsbuild/plugin-angular';

// eslint-disable-next-line @nx/enforce-module-boundaries
import { shareAll } from '../mf.tools';

export default createConfig({
  browser: './src/main.ts',
}, {
  server: {
    port: 4201
  },
  tools: {
    rspack: {
      output: {
        uniqueName: 'mfe1',
        publicPath: 'auto',
      },
    },
  },  
  moduleFederation: {
    options: {
      name: 'mfe1',
      filename: 'remoteEntry.js',
      exposes: {
        './Component': './src/app/app.component.ts'
      },
      shared: {
        ...shareAll({
          singleton: true,
          strictVersion: true,
        })
      }
    }
  }
});

Besides switching to the port 4201 we need to define a publicPath. This is the URL the Micro Frontend's bundles are deployed to. The value auto defines that at runtime this URL should be inferred. If you want to control the logic for inferring this URL, you can set the getPublicPath property to some JavaScript code executed at runtime.

The remaining configuration consists of a typical Module Federation settings:

  • The Micro Frontend gets the unique name mfe1.
  • Module Federation is told to put the meta data generated during the build into the file remoteEntry.js.
  • The AppComponent is exposed under the name ./Component. Using this name, the shell can import it at runtime.
  • The Micro Frontend shares all dependencies with other Micro Frontends and the shell at runtime.

The used shareAll helper simplifies the configuration. Normally, we needed an exhaustive list with all packages to share:

shared: {
  '@angular/animations': { singleton: true, strictVersion: true },
  '@angular/common': { singleton: true, strictVersion: true },
  '@angular/compiler': { singleton: true, strictVersion: true },
  '@angular/core': { singleton: true, strictVersion: true },
  '@angular/forms': { singleton: true, strictVersion: true },
  '@angular/platform-browser': { singleton: true, strictVersion: true },
  '@angular/platform-browser-dynamic': { singleton: true, strictVersion: true },
  '@angular/router': { singleton: true, strictVersion: true },
  '@module-federation/enhanced': { singleton: true, strictVersion: true },
  'rxjs': { singleton: true, strictVersion: true }
}

Setting singleton and strictVersion to true ensures that only one version of each dependency can be loaded at runtime. If several non-compatible versions of the same dependency are present, an Exception is thrown on startup. If we set strictVersion to false or leave it off, just a warning is written to the JavaScript console.

To avoid writing this exhaustive list by hand, the helper function shareAll creates such an entry for all packages found in the project's package.json in the node dependencies. The simple implementation used here does not add secondary entry points such as @angular/common/http. They need to be added by hand. I assume that the package @ng-rsbuild/plugin-nx or Nx will eventually provide such logic that also automates handling secondary entry points out of the box as well as further API sugar for simplifying the configuration.

Important: Pin Versions of Angular Packages

The shareAll helper also uses the version numbers found in your package.json. For all Angular-based packages, you should pin these numbers by removing prefixes such as ^ or ~. The reason is that currently, a compiled Angular application assumes to see exactly the same Angular version it was compiled with at runtime, as the compiled code is accessing Angular's private API. For such private APIs, there is no guarantee of semantic versioning. The good message is that there are some signs that this restriction might be removed in the future.

Configuring the Shell

The shell's rsbuild configuration in the file shell/rsbuild.config.ts looks similar to the one used for the Micro Frontend:

import { createConfig } from '@ng-rsbuild/plugin-angular';

// eslint-disable-next-line @nx/enforce-module-boundaries
import { shareAll } from '../mf.tools';

export default createConfig({
  browser: './src/main.ts',
}, {
  moduleFederation: {
    options: {
      name: 'shell',
      shared: {
        ...shareAll({
          singleton: true,
          strictVersion: true,
        })
      }
    }
  }
});

As our shell isn't exposing any module, there is no exposes node.

Also, the shell needs to be switched to asynchronous bootstrapping as discussed above. The main.ts uses the Module Federation runtime to initialize Federation. For this, we basically map the unique names of our Micro Frontends to their remote entries:

import { init } from '@module-federation/enhanced/runtime';

init({
  name: 'shell',
  remotes: [
    {
      name: "mfe1",
      entry: "http://localhost:4201/remoteEntry.js",
    }
  ],
});

import('./bootstrap');

Module Federation 2 provides a JSON-based manifest as a replacement for remote entries that consist of JavaScript code. This is a topic, I will look into in a further article.

After initializing Federation, the application delegates to the bootstrap.ts containing the bootstrap logic usually found in the main.ts.

Loading the Micro Frontend

For loading the Micro Frontend, the example at hand uses a lazy route set up in shell/src/app/app.routes.ts:

import { Route } from '@angular/router';
import { HomeComponent } from './home.component';
import { loadRemote } from '@module-federation/enhanced/runtime';
import { Type } from '@angular/core';

export const appRoutes: Route[] = [
    {
        path: '',
        pathMatch: 'full',
        component: HomeComponent
    },
    {
        path: 'mfe1',
        loadComponent: () => loadRemote('mfe1/Component') as Promise<Type<unknown>>
    }
];

The loadRemote function provided by the Federation runtime loads the AppComponent from the Micro Frontend. As there is a default export for this component, we don't need to mention its name. The string mfe1/Component points to the module, mfe1 exposes as ./Component.

The example does not provide a fallback for the case, lazy loading mfe1 does not work. In practice, there should be a catch handler, and we should handle null results showing that loadRemote was not successful

Now, for loading our Micro Frontend, we need a respective routerLink and a router-outlet in our app.component.ts:

<ul>
    <li><a routerLink="/">Home</a></li>
    <li><a routerLink="/mfe1">Flights</a></li>
</ul>

<router-outlet></router-outlet>

Trying it out

To try out our Micro Frontend solution, start both applications in two different terminal windows:

nx dev mfe1 -o
nx dev shell -o

Bonus: Sharing Libs and Data

🔀 Branch: shared-lib

So far, we just shared third-party packages installed via npm. Obviously, we can share our own npm packages the same way. Such shared packaes can also be used to establish communication between Micro Frontends. An easy way for accomplishing this is adding an Angular service to such a shared package. As services are Singletons, one Micro Frontend can write into it and others can read it. By using data structures such as Observables, Subjects, or Signals, we can also notify Micro Frontends about value changes.

To prevent publishing npm packages, we can also leverage a repo-internal library. In our case, we could, for instance, add an auth library managing the current user name:

nx g lib auth

A simple service for sharing the user name could look as follows:

import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class AuthService {
  userName = signal('');
}

Also, we need to export this service via the library's public API:

export * from './lib/auth/auth.component';

// Add this export:
export * from './lib/auth/auth.service';

The next step is to configure the auth lib as a shared library. For this, the shared section in both rsbuild configurations gets the following entry:

[...]
shared: {
  ...shareAll({
    singleton: true,
    strictVersion: true,
  }),
  '@rspack-demo/auth': {
    singleton: true,
    strictVersion: true,
    version: '0.0.0',
    requiredVersion: '0.0.0',
    import: '../auth/src/index.ts',
  },
}
[...]

As here, we don't have a package.json that informs about the library's entry point, we need to specify it by hand via the import property. Also, we need to define the current version and the version range we accept (requiredVersion). In both cases, the example assigns 0.0.0 as it is assuming that a monorepo-internal library does not have a version. In complexer cases, you might want to assign version numbers so that, for instance, only the highest version from all deployed Micro Frontends is shared at runtime.

🔀 Branch: shared-lib-helper

Also this kind of configuration can be automated via the shareAll helper: The branch shared-lib-helper contains a version of shareAll that not only shares all dependencies found in your package.json but also all path mappings found in your tsconfig.base.json that point to such internal libraries:

[...]
shared: {
  // 
  // now, shareAll takes care of both,
  // packages and repo-internal libs
  //
  ...shareAll({
    singleton: true,
    strictVersion: true,
  }),
},
[...]

Discussion and Outlook

With the community solution @ng-rsbuild/plugin-nx that is published by the Nx team member Ferry Colum we now have the possibility to build Angular-solutions with rsbuild in our Nx workspaces. This improves the build times as, similar to other representatives of this tools generation, rsbuild and the underlying rspack are compiled into machine code and implemented with parallelization in mind.

We will see whether this already justifies moving away from the Angular CLI's esbuild-based ApplicationBuilder, which is the de facto standard in the Angular space with similar characteristics (compiled to machine code, parallelization) and powers several of Angular's features such as SSR, Hybrid Rendering, and Incremental Hydration.

However, even if we set the performance question aside, this package is still vital: It brings some competition to the play that might lead to improvements at both sides and it is the foundation for running the latest version of Module Federation including all the innovations that will eventually be added in a performant way in the Angular space. Saying this, while there are pros and cons on both sides, we also see a lot of potential in our Native Federation approach.

A further advantage of the rsbuild integration is that it is designed to be compatible with most webpack plugins and hence helps to migrate existing solutions to a more modern and faster build environment. Also, rspack is backed by Bytedance, the company behind TikTok and other brands, and there is already a vivid ecosystem with tools such as the static site generator rspress or the build analyzer rsdoctor.

The possibility of using the same build tool for several frameworks in an Nx workspace is tempting. Giving people the choice of their favorite tools is a benefit Nx provides. As Colum told me, there is also the potential that this package helps foster a plugin community where people can benefit from rsbuild plugins more easily.

Finally, I want to close this article with a huge shout-out to Colum Ferry, who invested a lot of time and energy into this integration, which provides the whole community with the mentioned advantages.

eBook: Micro Frontends and Moduliths with Angular

Lernen Sie, wie Sie große und langfristig wartbare Unternehmenslösungen mit Angular planen und umsetzen

✓ 20 Kapitel
✓ Quellcode-Beispiele
✓ PDF, epub (Android und iOS) und mobi (Kindle)

Gratis downloaden