Building Micro-frontends based on Angular
Have you been wondering how to build an Angular Micro-frontend? You are in the right place!
Benefits of Micro Frontend Architecture
Automation of the CI/CD workflow
Because each app integrates and deploys separately, the CI/CD pipeline is simplified. Because all functionalities are distinct, you don't have to be concerned about the entire program while introducing a new feature. If there is a little issue in a module's code, the CI/CD pipeline will fail the entire build process.
Flexibility of teams
Numerous teams can bring value to multiple systems while working independently.
Single responsibility
Using this technique, each team is able to produce components with a single responsibility. Each Micro Frontend team is completely focused on the functionality of its Micro Frontend.
Reusability
Code reusability means that you will be able to utilize it in various places. Multiple teams can reuse a single module that has been built and delivered.
Technology agnosticism
Micro Frontend design is technology agnostic. Components from several web development frameworks can be used (React, Vue, Angular, etc.).
Simple learning
Smaller modules are easier for new developers to learn and grasp than a monolithic system with a large code structure.
Getting started
The diagram below depicts a micro frontend architecture
Header & Footer Module Federation
This section has at least two components that are ready to be exported from this module. First and foremost, we must establish a new app and configure a custom angular builder — This builder allows us to use custom webpack configs.
$ ng new layout
$ npm i --save-dev ngx-build-plus
Now, at the root of our project, we must create the webpack.config.js and webpack.prod.config.js files.
const webpack = require("webpack");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
output: {
publicPath: "http://localhost:4201/",
uniqueName: "layout",
},
optimization: {
runtimeChunk: false,
},
plugins: [
new ModuleFederationPlugin({
name: "layout",
library: { type: "var", name: "layout" },
filename: "remoteEntry.js",
exposes: {
Header: "./src/app/modules/layout/header/header.component.ts",
Footer: "./src/app/modules/layout/footer/footer.component.ts",
},
shared: {
"@angular/core": { singleton: true, requiredVersion: "auto" },
"@angular/common": { singleton: true, requiredVersion: "auto" },
"@angular/router": { singleton: true, requiredVersion: "auto" },
},
}),
],
};
module.exports = require("./webpack.config");
We can specify the minimum needed version, if two or more versions of a package are permitted, and so on. More information regarding various plugin choices may be found here.
Then, in angular.json , add a custom config file and set the default builder to ngx-build-plus
...
"projects": {
"layout": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
},
"@schematics/angular:application": {
"strict": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "ngx-build-plus:browser",
"options": {
"outputPath": "dist/layout",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": [],
"extraWebpackConfig": "webpack.config.js"
},
"configurations": {
"production": {
"budgets": [{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"extraWebpackConfig": "webpack.prod.config.js",
"fileReplacements": [{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "ngx-build-plus:dev-server",
"configurations": {
"production": {
"browserTarget": "layout:build:production"
},
"development": {
"browserTarget": "layout:build:development",
"extraWebpackConfig": "webpack.config.js",
"port": 4205
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "ngx-build-plus:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": [],
"extraWebpackConfig": "webpack.config.js"
}
}
}
}
},
"defaultProject": "layout"
}
Register Page Module Federation
This web application will house all of the logic for the login/registration page. The fundamental routine is nearly same; we need to create a new app and install a custom builder in order to use custom webpack configurations
$ ng new registration
$ npm i --save-dev ngx-build-plus
Following that, we must construct webpack.config.js and webpack.prod.config.js
// webpack.config.js
const webpack = require("webpack");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
output: {
publicPath: "http://localhost:4202/",
uniqueName: "registration",
},
optimization: {
runtimeChunk: false,
},
plugins: [
new ModuleFederationPlugin({
name: "registration",
library: { type: "var", name: "registration" },
filename: "remoteEntry.js",
exposes: {
RegistrationModule:
"./src/app/modules/registration/registration.module.ts",
},
shared: {
"@angular/core": { singleton: true, requiredVersion: "auto" },
"@angular/common": { singleton: true, requiredVersion: "auto" },
"@angular/router": { singleton: true, requiredVersion: "auto" },
},
}),
],
};
module.exports = require("./webpack.config");
As you can see, we just export RegistrationModule here. In our shell program, we may utilize this module as a lazy-loaded module. In addition, we must change the default builder to ngx-build-plus and include webpack configurations in the angular JSON file — The same as we did for the Header & Footer module before.
Dashboard Module Federation
This module displays certain information to a logged-in user. The same method as for the Register page, but with personal webpack configurations:
// webpack.config.js
const webpack = require("webpack");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
output: {
publicPath: "http://localhost:4203/",
uniqueName: "dashboard",
},
optimization: {
runtimeChunk: false,
},
plugins: [
new ModuleFederationPlugin({
name: "dashboard",
library: { type: "var", name: "dashboard" },
filename: "remoteEntry.js",
exposes: {
DashboardModule: "./src/app/modules/dashboard/dashboard.module.ts",
},
shared: {
"@angular/core": { singleton: true, requiredVersion: "auto" },
"@angular/common": { singleton: true, requiredVersion: "auto" },
"@angular/router": { singleton: true, requiredVersion: "auto" },
},
}),
],
};
Shell App Module Federation
The main app is responsible for combining all of the individual micro frontend modules into a single app. As previously, we construct a new app using a custom Angular builder
$ ng new shell
$ npm i --save-dev ngx-build-plus
Add your own webpack configurations
const webpack = require("webpack");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
output: {
publicPath: "http://localhost:4200/",
uniqueName: "shell",
},
optimization: {
runtimeChunk: false,
},
plugins: [
new ModuleFederationPlugin({
shared: {
"@angular/core": { eager: true, singleton: true },
"@angular/common": { eager: true, singleton: true },
"@angular/router": { eager: true, singleton: true },
},
}),
],
};
But first, we need to add webpack configuration with the custom builder to the angular.json file. All module configurations are declared in environment/environment.ts (for the production version, the localhost address must be replaced with the deployed public address)
export const environment = {
production: false,
microfrontends: {
dashboard: {
remoteEntry: "http://localhost:4203/remoteEntry.js",
remoteName: "dashboard",
exposedModule: ["DashboardModule"],
},
layout: {
remoteEntry: "http://localhost:4201/remoteEntry.js",
remoteName: "layout",
exposedModule: ["Header", "Footer"],
},
},
};
Then, when applicable, we must include a loading Dashboard and a registration page. First and foremost, we must develop module federation utilities that will allow us to import remote modules from other programs.
// src/app/utils/federation-utils.ts
type Scope = unknown;
type Factory = () => any;
interface Container {
init(shareScope: Scope): void;
get(module: string): Factory;
}
declare const __webpack_init_sharing__: (shareScope: string) => Promise<void>;
declare const __webpack_share_scopes__: { default: Scope };
const moduleMap: Record<string, boolean> = {};
function loadRemoteEntry(remoteEntry: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (moduleMap[remoteEntry]) {
return resolve();
}
const script = document.createElement("script");
script.src = remoteEntry;
script.onerror = reject;
script.onload = () => {
moduleMap[remoteEntry] = true;
resolve(); // window is the global namespace
};
document.body.append(script);
});
}
async function lookupExposedModule<T>(
remoteName: string,
exposedModule: string
): Promise<T> {
// Initializes the share scope. This fills it with known provided modules from this build and all remotes
await __webpack_init_sharing__("default");
const container = window[remoteName] as Container;
// Initialize the container, it may provide shared modules
await container.init(__webpack_share_scopes__.default);
const factory = await container.get(exposedModule);
const Module = factory();
return Module as T;
}
export interface LoadRemoteModuleOptions {
remoteEntry: string;
remoteName: string;
exposedModule: string;
}
export async function loadRemoteModule<T = any>(
options: LoadRemoteModuleOptions
): Promise<T> {
await loadRemoteEntry(options.remoteEntry);
return lookupExposedModule<T>(options.remoteName, options.exposedModule);
}
There are several utilities for creating lazily loaded routes
// src/app/utils/route-utils.ts
import { Routes } from "@angular/router";
import { loadRemoteModule } from "./federation-utils";
import { APP_ROUTES } from "../app.routes";
import { Microfrontend } from "../core/services/microfrontends/microfrontend.types";
export function buildRoutes(options: Microfrontend[]): Routes {
const lazyRoutes: Routes = options.map((o) => ({
path: o.routePath,
loadChildren: () => loadRemoteModule(o).then((m) => m[o.ngModuleName]),
canActivate: o.canActivate,
pathMatch: "full",
}));
return [...APP_ROUTES, ...lazyRoutes];
}
Then we must create a micro frontend service
// src/app/core/services/microfrontends/microfrontend.service.ts
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { MICROFRONTEND_ROUTES } from "src/app/app.routes";
import { buildRoutes } from "src/app/utils/route-utils";
@Injectable({ providedIn: "root" })
export class MicrofrontendService {
constructor(private router: Router) {}
/*
* Initialize is called on app startup to load the initial list of
* remote microfrontends and configure them within the router
*/
initialise(): Promise<void> {
return new Promise<void>((resolve) => {
this.router.resetConfig(buildRoutes(MICROFRONTEND_ROUTES));
return resolve();
});
}
}
In addition, file for type
// src/app/core/services/microfrontends/microfrontend.types.ts
import { LoadRemoteModuleOptions } from "src/app/utils/federation-utils";
export type Microfrontend = LoadRemoteModuleOptions & {
displayName: string;
routePath: string;
ngModuleName: string;
canActivate?: any[];
};
Then we must declare remote modules based on the routes
// src/app/app.routes.ts
import { Routes } from "@angular/router";
import { LoggedOnlyGuard } from "./core/guards/logged-only.guard";
import { UnloggedOnlyGuard } from "./core/guards/unlogged-only.guard";
import { Microfrontend } from "./core/services/microfrontends/microfrontend.types";
import { environment } from "src/environments/environment";
export const APP_ROUTES: Routes = [];
export const MICROFRONTEND_ROUTES: Microfrontend[] = [
{
...environment.microfrontends.dashboard,
exposedModule: environment.microfrontends.dashboard.exposedModule[0],
// For Routing, enabling us to ngFor over the microfrontends and dynamically create links for the routes
displayName: "Dashboard",
routePath: "",
ngModuleName: "DashboardModule",
canActivate: [LoggedOnlyGuard],
},
{
...environment.microfrontends.registration,
exposedModule: environment.microfrontends.registration.exposedModule[0],
displayName: "Register",
routePath: "signup",
ngModuleName: "RegistrationModule",
canActivate: [UnloggedOnlyGuard],
},
];
Also, in the main app module, utilize our Micro Frontend Service
// src/app/app.module.ts
import { APP_INITIALIZER, NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { RouterModule } from "@angular/router";
import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { APP_ROUTES } from "./app.routes";
import { LoaderComponent } from "./core/components/loader/loader.component";
import { NavbarComponent } from "./core/components/navbar/navbar.component";
import { MicrofrontendService } from "./core/services/microfrontends/microfrontend.service";
export function initializeApp(
mfService: MicrofrontendService
): () => Promise<void> {
return () => mfService.initialise();
}
@NgModule({
declarations: [AppComponent, NavbarComponent, LoaderComponent],
imports: [
BrowserModule,
AppRoutingModule,
RouterModule.forRoot(APP_ROUTES, { relativeLinkResolution: "legacy" }),
],
providers: [
MicrofrontendService,
{
provide: APP_INITIALIZER,
useFactory: initializeApp,
multi: true,
deps: [MicrofrontendService],
},
],
bootstrap: [AppComponent],
})
export class AppModule {}
We must now load the Footer and Header components. To do so, we must edit the app component
// src/app/app.component.html
<main>
<header #header></header>
<div class="content">
<app-navbar [isLogged]="auth.isLogged"></app-navbar>
<div class="page-content">
<router-outlet *ngIf="!loadingRouteConfig else loading"></router-outlet>
<ng-template #loading>
<app-loader></app-loader>
</ng-template>
</div>
</div>
<footer #footer></footer>
</main>
Then the file src/app/app.component.ts will look something like this
import {
ViewContainerRef,
Component,
ComponentFactoryResolver,
OnInit,
AfterViewInit,
Injector,
ViewChild,
} from "@angular/core";
import {
RouteConfigLoadEnd,
RouteConfigLoadStart,
Router,
} from "@angular/router";
import { loadRemoteModule } from "./utils/federation-utils";
import { environment } from "src/environments/environment";
@Component({
selector: "app-root",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.scss"],
})
export class AppComponent implements AfterViewInit, OnInit {
@ViewChild("header", { read: ViewContainerRef, static: true })
headerContainer!: ViewContainerRef;
@ViewChild("footer", { read: ViewContainerRef, static: true })
footerContainer!: ViewContainerRef;
loadingRouteConfig = false;
constructor(
private injector: Injector,
private resolver: ComponentFactoryResolver,
private router: Router
) {}
ngOnInit() {
this.router.events.subscribe((event) => {
if (event instanceof RouteConfigLoadStart) {
this.loadingRouteConfig = true;
} else if (event instanceof RouteConfigLoadEnd) {
this.loadingRouteConfig = false;
}
});
}
ngAfterViewInit(): void {
// load header
loadRemoteModule({
...environment.microfrontends.layout,
exposedModule: environment.microfrontends.layout.exposedModule[0],
}).then((module) => {
const factory = this.resolver.resolveComponentFactory(
module.HeaderComponent
);
this.headerContainer?.createComponent(factory, undefined, this.injector);
});
// load footer
loadRemoteModule({
...environment.microfrontends.layout,
exposedModule: environment.microfrontends.layout.exposedModule[1],
}).then((module) => {
const factory = this.resolver.resolveComponentFactory(
module.FooterComponent
);
this.footerContainer?.createComponent(factory, undefined, this.injector);
});
}
}
We include logic for loaders as well as reasoning for lazy-loaded components here (Header, Footer).
Conclusion
As frontend codebases get more advanced, there is a growing desire for more maintainable micro frontend structures. As a result, the ability to set clear boundaries that provide the appropriate levels of coupling and cohesiveness between technical and domain entities is critical, as is the ability to scale software delivery across separate, autonomous teams.
Subscribe to my newsletter
Read articles from Code With Ian directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Code With Ian
Code With Ian
Learning to code? You are in the right place! "Code With Ian" provides coding tutorials for programmers from beginning to advanced level.