Micro frontends architecture is still a hot topic in the Front-End field. Many projects are adopting or have already adopted this approach. However, it is one of the most controversial topics, and you should carefully consider the pros and cons before implementing it. Today, I will share how I dealt with this challenge and solved it in my application.
Understanding Your Goals
The first thing to consider is the business needs of the project and how your architecture will fit in. You can't build whatever you want – your decisions should align with the business direction. Secondly, evaluate the benefits of this architecture for your specific project. Don't rely solely on observations from others or follow trends without a cost-benefit analysis. Determine if the advantages are worth the cost.
Thirdly, address these common concerns before making the shift:
- What is my current architecture?
- How is the application deployed, and how will it deploy in the new architecture?
- Can a micro frontend application be integrated into other applications? What stack do they use?
- What is the complexity of the application? Does it involve routing or state management?
- How will communication between apps be handled?
- Is your team technically ready for this shift?
These are the minimal recommendations before you start development.
Solving the Problem
If you have answers to the above questions, you're ready to proceed with the technical implementation for Angular apps. Historically, Angular apps are built using Webpack. This is not a significant issue until you face the micro frontend topic. Different bundlers can have runtime compatibility issues. For example, integrating an Angular app into a React app built with Vite and esbuild can be problematic.
It makes more sense to follow web standards (ES Modules) rather than locking your projects to a specific bundler like Module Federation and Webpack.
Fortunately, since Angular 16, the Angular team has included esbuild out of the box. I recommend updating beyond version 16. Now, it's enough to update your angular.json file with the new builder and other fields:
{
"build": {
"executor": "@angular-devkit/build-angular:browser-esbuild",
"options": {
"stylePreprocessorOptions": {
"includePaths": [""]
},
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "./tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "1mb",
"maximumError": "2mb"
}
],
"outputHashing": "none",
"baseHref": "/"
},
"development": {
"optimization": false,
"extractLicenses":false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
}
}
It's a good practice to integrate the micro frontend with minimal effort. For example, you can expose a component that accepts the remote app name and link. This makes it easier for consumers, as they don't need to worry about the underlying processes. Consider the following example:
import { Component, Input, OnInit } from '@angular/core';
const MFE_CACHE_KEY = '__mfe-cache';
@Component({
selector: 'mfe-wrapper',
template: '<div id="mfe-root"></div>'
})
export class MfeWrapperComponent implements OnInit {
@Input() public url: string;
@Input() public appName: string;
public ngOnInit(): void {
const importFn = async (path: string): Promise<string> => {
if (window[MFE_CACHE_KEY]?.[path]) {
return window[MFE_CACHE_KEY][path];
} else {
const response = await fetch(path);
const scriptContent = await response.text();
window[MFE_CACHE_KEY] = { ...window[MFE_CACHE_KEY], [path]: scriptContent };
return scriptContent;
}
};
const element = document.getElementById('mfe-root');
const executeScript = (scriptContent: string): void => {
const scriptElement = document.createElement('script');
scriptElement.type = 'module';
scriptElement.async = true;
scriptElement.textContent = scriptContent;
element.appendChild(scriptElement);
};
element.appendChild(document.createElement(this.appName));
(async (): Promise<void> => {
const mainScript = await importFn(`${this.url}/main.js`);
executeScript(mainScript);
const polyfillsScript = await importFn(`${this.url}/polyfills.js`);
executeScript(polyfillsScript);
})();
}
}
In this example, we download the necessary scripts (main.js
and polyfills.js
) from the provided source. You must deploy your Angular build to the URL that you expose to the consumers. We convert the script into text and insert it into the script tag to avoid caching issues. You can extract the logic of downloading and script creation into separate scripts and reuse it for a React component wrapper for React app integrations – the approach remains the same.
One important note is dealing with styles. You may notice that angular.json
doesn't have a styles
field. This is intentional. If you leave this field, the builder creates a style.css
file, which can override the host application’s styles. By removing this field, the styles are added to the main.js
file. Additionally, you can wrap the app in ShadowDOM
to ensure that your styles don't override the host styles.
If you use UI libraries and components without styles encapsulation, these approaches won't help – the styles will leak into the host application’s head. Carefully choose the components you use.
Advantages:
- Your Angular application can be integrated into any other application regardless of the framework.
- Easy integration with the host by providing a component with only two inputs – URL and app name (you can omit the name if the URL has only one application).
- In most cases, your application’s styles are encapsulated and don't override the host’s styles.
Disadvantages:
- This approach is compatible with Angular 16 and above. Version upgrades can be challenging.
- Path resolution issues. If the host and remote app are on different domains, import paths will resolve within the host domain. You can either remap all your imports after the build (use this only if necessary) or host the remote app on the same domain (or use emulation).
- The styles encapsulation problem is not fully solved.
Conclusion
Adopting a micro frontend architecture requires careful planning and consideration of both business and technical factors. The approach described here provides a way to integrate Angular applications into various host environments with minimal effort.
While it offers significant advantages, such as framework-agnostic integration and style encapsulation, there are also challenges to address, including compatibility with older Angular versions and path resolution issues.
By understanding these trade-offs and planning accordingly, you can effectively implement a micro frontend architecture that meets your project’s needs.