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:
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.