In this article, I am sharing about an architecture that has profoundly changed my perception of software development for large enterprises with complex infrastructure and team organization.
I’ve been eager to write this article for a long time, and now’s the time! I’ve always valued sticking to standards, and I believe the ecosystem is now ready to properly handle this architecture with the latest tools.
The advent of esbuild, the native support for ES Modules in browsers, the widespread adoption of import map, the emergence of tools like Native Federation, and the Nx ecosystem all combine to forge a flexible and well-maintained Micro Frontend Architecture.
I’ll cover:
- Real Story!
- A short reminder about browsers
- Micro frontend architecture in a nutshell
- What is an Import Map?
- Exploring the Full Potential of Import Maps and Overrides
- Nx Enables Scalable Micro Frontend Architecture
- What about Native Federation?
- Final Thoughts
Real Story!
Just to give you more context, I led the migration of several AngularJS applications to the newer Angular Framework. My client finally decided to make that move following the AngularJS deprecation announcement (stay up to date please 🙏)️.
Using the usual migration process was not possible. After investigating multiple scenarios, the micro frontend architecture was chosen. As we see, it facilitates incremental migration, provides isolation, and allows the integration of apps from multiple teams into one unified platform.
At that time, the micro frontend architecture was not yet popular and only the single-spa library was mature enough. It supports many frameworks, including AngularJS and Angular, making it a perfect choice for us!
Single-spa orchestrates the micro frontend by toggling between AngularJS or Angular implementations based on a feature flag:
Using single-spa has significantly enhanced my understanding of implementing micro frontend architecture, particularly highlighting the substantial benefits of utilizing import maps and micro frontend overrides. These tools have greatly improved my experience in local development, testing, and deployment.
I highly recommend having a look at the single-spa documentation to understand the concepts of micro frontend and import map.
A short reminder about browsers
To grasp the following subjects, I believe it’s crucial first to recall the basics of the web, focusing on the primary flow of a browser running a web application:
The first action is always to get an
index.html
file, which has everything needed to start the application.Then, the browser loads all the files that the
index.html
says it should. This often includes the main files for the application, like JavaScript and stylesheets.After that, the application or the user interaction leads to more requests being made, for example, calling APIs or loading parts of the site as needed.
The browser’s job is simply to load these files or assets and put them together into the web application.
Micro frontend architecture in a nutshell
Let’s start with a short definition: the micro frontend architecture involves breaking down a frontend application into smaller, more manageable pieces — each responsible for a distinct feature or domain of your application. It’s often compared to the microservices concept but at the frontend layer.
Determining the exact point at which an application adheres to micro frontend architecture can be challenging, like defining the ideal size for a microservice.
The key aspect is having a platform capable of plugging in and combining multiple pieces of functionality to produce a unified application. Whether these pieces are lazy-loaded components or micro frontend, the principle remains essentially the same.
In which situation it suits you well?
There are many use cases where the micro frontend architecture can be useful:
Multiple Frameworks: The most common use case involves integrating various technologies into a single product, particularly useful for unifying disparate systems.
Team Decentralization: When teams operate independently, within a monorepo or different repositories, micro frontends make it easier to merge their work into one cohesive product.
Separation of Concerns: Ideal for structuring your application into isolated domains and features for better organization.
Complex Infrastructure: The ability to plug a micro frontend into an existing environment can significantly enhance the development experience! We’ll delve into this reason further later on.
Don’t use micro frontend architecture if you don’t need it
Major Concepts
In a micro-frontend architecture, we distinguish various types of entities, each adhering to a distinct concept:
The Micro Frontend (or micro app) is loaded by the Host upon navigation or routing. Each micro frontend is responsible for a distinct feature or domain within the application. Like any app, it can contain child routes and multiple components.
The Parcel (also referred to as a component or expose) is loaded independently on-demand. It can be a shared component or a shared service and can be plugged in anywhere.
Tools/Frameworks
There are several implementations of the micro frontend architecture, and I’ll delve into three notable ones here:
Single-spa: This framework keeps things simple and works with many technologies. However, its simplicity might mean you have to do more work if you’re using just one technology.
Webpack Module Federation: Almost everyone uses Webpack, and its module federation feature makes micro frontends easy for these users. But, if you’re using a different tool, you might need to find another solution.
Native Federation: This method combines the ease of Webpack’s approach with newer tools like esbuild or Vite, fitting well with modern development practices while supporting micro frontend architecture.
What is an Import Map?
Let’s begin with the most intriguing aspect. In my opinion, the import map is an underappreciated browser technology. It is compatible with all browsers and plays a role in directly supporting JavaScript modules in the browser.
For full compatibility and extra features, we usually use the library
es-module-shims
.
How does it work?
The principle is quite straightforward. Since the introduction of the ES module into our JavaScript ecosystem, we’ve all started using syntax like:
import moment from "moment";
import { partition } from "lodash"
However, when using ES modules natively in a browser, you need to specify the full path to the JS file, something like:
import moment from "https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment.min.js";
import { partition } from "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js";
This approach isn’t very readable or maintainable, is it? Therefore, the import map was created to map a library name to a URL:
<script type="importmap">
{
"imports": {
"moment": "https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment.min.js",
"lodash": "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"
}
}
</script>
It functions similarly to TypeScript’s path mapping but directly in your browser. Now, you can use the same syntax whether loading modules locally or in the browser.
This import map can be specified inline or as an external file, like:
<script type="importmap" src="assets/shared.importmap.json"></script>
<script type="importmap" src="assets/remotes.importmap.json"></script>
For more information, I recommend checking out the MDN Web Docs and the proposal’s GitHub repository.
How is it related to the micro frontend architecture?
As I mentioned, the micro frontend architecture is a just a way for dynamically loading bundles from the browser and integrating them into the actual apps.
This orchestration is the role of the Host. However, when the host needs to load an ES module, it can simply utilize the JS import system and, with the aid of the import map, map the module to its location
Similarly, for Parcels, when you need to load a component on demand, the import map will map your JS import to the current location.
Import Maps are overridable!
You can declare multiple import maps in the same HTML. This means that if two import maps declare the same key, the last one will override the previous one.
By injecting a new import map into your HTML, you can hook/remap any bundle. Thus, you can replace a micro frontend, a component, or even a shared library!
I recommend the library
import-map-override
which allows you to manage the import map directly in your browser.
Security
Overriding an import map in a web application does not inherently reduce its security, as all frontend assets are public and can be modified client-side. However, for applications that load assets from multiple servers, configuring a Content-Security-Policy (CSP) is crucial.
CSP helps whitelist trusted domains, significantly reducing the risk of Cross-Site Scripting (XSS) and other security threats. This security measure ensures that even if client-side modifications are possible, the application’s integrity and user safety are maintained.
Exploring the Full Potential of Import Maps and Overrides
Now that we understand the principles of the import map and the fact that we can override the bundles loading directly in the browser, let’s see how we can get the advantage of that concept within our development process:
Local Development
Setting up a complex local environment in a large organization often involves:
Spending more than a day to set up your local machine.
Installing a wide range of software, like backend systems, local databases or connections to external environments, local queuing systems, etc.
Adjusting settings for multi-tenants.
Take coffee breaks while you wait for your local environment to bootstrap in the morning, hoping it stays stable throughout the day.
This complexity can be quite frustrating, especially when you only need to make a minor UI adjustment. This is the exact challenge I aimed to tackle through the adoption of micro frontend architecture in combination with the import map overrides.
Instead of running an entire complex ecosystem, you can just plug your local environment into an external environment where all of the complexity is already in place.
To do so, you just need to serve your micro frontend locally and use the import map override principle on the distant environment:
After the reload, the micro frontend loaded by the browser will be not the one on the distant server but the one on your local machine.
One crucial aspect is that you are directly integrating your code into a real environment that contains the latest main branch. This means we can move past the infamous “It Works On My Machine!” scenario.
This approach showcases true Continuous Integration
Pull Request
When you’ve completed your implementation (and tested it 😋), you typically create a pull request to merge your code into the shared codebase.
Facilitate Reviews
You can once again leverage the advantage of import map overriding to make the review process easier, allowing reviewers to validate your changes without needing to deploy or clone the code locally:
At this stage, the CI will build your app and generate new bundles for the modified micro frontend. Additionally, you can generate an affected importmap.json
with the updated bundles.
Simplify UI e2e tests
You can also use the affected import map for your UI tests (mocks). In this scenario, the affected importmap.json
generated can be injected into tools like Playwright or Cypress to directly test the affected micro frontends.
Acceptance
This step signifies the moment when you need to confirm that your code is ready for production deployment. It can be automated on CI or manually (please automate 🙏).
Typically, this is run several times per day with the most recent codebase in an environment that mirrors production. In this scenario, you’ll generate an importmap.json
that includes the latest versions of all bundles:
If the latest importmap.json
generated proves successful, it can then become a release candidate for production.
Production
When your release is validated and ready, you can consider deploying it to production. Here, too, having an importmap.json
offers significant advantages.
Deploy in a Sec
You can deploy/upload your bundles to production at any time. Until the importmap.json
references them, they will not be loaded. Thus, deployment involves merely modifying and uploading the latest import map. This deployment process takes a mere second, requires no freeze, and is completely transparent to the user.
You should have a look to them import-map-deployerlibrary which enable to update an
importmap.json
directly on the server
Keep Previous Bundles in Cache
It’s also important to note that the importmap.json
can still reference bundles with previous versions. In fact, if some micro frontends have not been modified, there’s no need to generate a new version for them.
This means that users won’t have to reload these existing versions because they are probably already cached in their browser. On the other hand, the importmap.json
should never be cached!
Canary Deployment & A/B Testing
One last, and not negligible, benefit of the importmap.json
is that it can be generated dynamically. This means you can decide whether a micro frontend should load an old version or a new one.
As a result, you can easily conduct A/B testing or canary deployments based on feature flags or authenticated user criteria!
Nx Enables Scalable Micro Frontend Architecture
I won’t delve into all the benefits of Nx, a topic I’ve extensively covered in previous writings. I’ll encourage you to have a look at the Nx website for more detailed information.
My conviction in the value Nx brings to not just JavaScript/TypeScript repositories but to any codebase is unwavering. Its strengths in enhancing sharing, visibility, performance, and adherence to conventions are universally applicable.
Monorepo and Micro Frontend aren’t the opposite?
Not at all! A monorepo adds value through enhanced code maintenance, build, and integration processes. Conversely, micro frontend architecture delivers benefits at runtime.
Both strategies advocate for separation of concerns and reusability, showcasing significant advantages in incorporating micro frontends within a monorepo.
Nx still delivers value even if you don’t use a monorepo.
Affected micro frontends
A pivotal concept in Nx is the ability to execute tasks solely on the affected code. This feature significantly simplifies working on a single micro frontend at a time in a remote environment, streamlining local development.
By limiting actions like build, lint, and testing to impacted micro frontends, the efficiency of your CI/CD processes can be markedly improved. Utilizing an affected importmap.json
that lists the affected micro frontends can enhance various processes, including testing PRs on existing environments, running e2e tests, and facilitating incremental deployments.
Single Version Policy
While independence and isolation are cornerstone principles of micro frontend architecture, sharing some services and components across all instances is inevitable.
The monorepo approach, coupled with a single version policy, ensures by design that micro frontends remain compatible with one another, fostering a cohesive ecosystem.
What about Native Federation?
Like I said at the beginning, I think now the ecosystem is mature enough to apply the same principles by using Angular, or other frameworks using esbuild, and Native Federation within an Nx monorepo.
I encourage you to have a look at the blog post announcing Native Federation
Unfortunately, I was unable to implement the import map overrides in conjunction with Native Federation. However, this issue is currently under discussion on GitHub:
Use more importmap default behaviours #489
Hi there,
As usual, it's a great library 👏. I appreciate the fact that the lesser-known importmaps
standard is utilized ;)
I've previously employed importmaps
in one of my micro-frontend projects alongside single-spa and the importmap overrides library. This approach significantly enhances the developer experience in complex environments by allowing direct integration of a local server into an integration server (more info in the YouTube video from Joel Denning).
It also proves beneficial for testing, enabling the generation of an affected importmap
on PRs and its use to override bundles in an existing environment without the need for cloning or deploying anything.
It also facilitates incremental and canary deployment.
My intention was to apply the same strategy with native federation due to its utilization. However, I found it unfeasible due to a globalCache that does not account for potential modifications to the importmaps
overrides. This capability is a default feature and it is supported by the es-module-shims library.
First, let's examine the current behavior:
flowchart TD
subgraph Remote [On Load Remote Module]
1[loadRemoteModule on routing or in component]
2[getRemote Infos From Cache]
3[importShim from remote url]
1 --> 2
2 --> 3
end
subgraph Host [On Host Initialization]
direction TB
a[initFederation In Host]
b[Fetch federation.manifest.json]
h[Load remoteEntry.json of the host]
i[Generate importmap with key/url]
y[Combine Host importmap and remotes importmaps]
z[Write importmaps in DOM]
a --> b
a --> A
b --> B
subgraph A [hostImportMap]
direction TB
h[Load remoteEntry.json]
i[Generate importmap with key/url]
h --> i
end
subgraph B [remotesImportMap]
direction TB
c[Load remoteEntry.json]
d[Generate importmap with key/url]
e[Add remote entry infos to globalCache]
c --> d
c --> e
end
A --> y
B --> y
y --> z
end
Cache((globalCache))
e .-> Cache
2 .-> Cache
<script type="esms-options">
{
"shimMode": true
"mapOverrides": true
}
</script>
2. Import the importmap directly as a file in the index.html
instead of runtime code in initFederation
The browser can combine directly multiple importmaps and load them for us. I would suggest that instead of executing runtime code, integrating all importmap already generated at compile time directly in the index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>host</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<script type="importmap-shim" src="assets/host-shared.importmap.json"></script>
<script type="importmap-shim" src="assets/remotes.importmap.json"></script>
</head>
<body>
<nx-nf-root></nx-nf-root>
</body>
</html>
new MutationObserver((mutations) => {
for (const { addedNodes, type } of mutations) {
if (type !== 'childList') continue;
for (const node of addedNodes) {
if (node.tagName === 'SCRIPT') {
if (node.type === 'importmap-shim' || node.type === 'importmap') {
const remoteNamesToRemote = globalcache.remoteNamesToRemote;
// TODO: should update the remoteNamesToRemote by changing the base url
}
}
}
}
}).observe(document, { childList: true, subtree: true });
The challenge here is that the cache is grouped per remote and only maintains a single base URL, so overriding one exposes
would change the URL for others as well.
The impact on performance is uncertain. Having an API library to manipulate importmaps
would be advantageous.
I have succeeded in overriding the loadRemoteModule
function:
export function getImportMapOverride(importMapKey: string): string | undefined {
// @ts-ignore
const imports = window?.importMapOverrides?.getOverrideMap()?.imports;
return imports && imports[importMapKey];
}
export async function loadRemoteOverrideUtils<T = any>(
remoteName: string,
exposedModule: string
): Promise<T> {
const remoteKey = `${remoteName}/${exposedModule}`;
const importMapOverrideUrl = getImportMapOverride(remoteKey);
// If override found for remoteKey, load it separately
// Else, use the default function
return importMapOverrideUrl
? importShim<T>(importMapOverrideUrl)
: loadRemoteModule(remoteName, exposedModule);
}
But I don't like the fact that the globalCache
of native federation is still invalid.
full code here https://github.com/jogelin/nx-nf/tree/poc-load-remote-overrides
In my opinion, it is the best approach because overriding only one exposes
does not make sense. Usually, we want to override an entire remote URL.
I implemented an easy way, but custom, but it keeps the globalCache
in sync:
If you have that override in your localStorage
:
Directly in the main.ts
you can use:
initFederation('/assets/federation.manifest.json')
.then(() => initFederationOverrides()) // <-- HERE
.catch((err) => console.error(err))
.then((_) => import('./bootstrap'))
.catch((err) => console.error(err));
utilities functions:
import { processRemoteInfo } from '@angular-architects/native-federation';
import { ImportMap } from './import-map.type';
const NATIVE_FEDERATION_LOCAL_STORAGE_PREFIX = 'native-federation-override:';
export function initFederationOverrides(): Promise<ImportMap[]> {
const overrides = loadNativeFederationOverridesFromStorage();
const processRemoteInfoPromises = Object.entries(overrides).map(
([remoteName, url]) => processRemoteInfo(url, remoteName)
);
return Promise.all(processRemoteInfoPromises);
}
function loadNativeFederationOverridesFromStorage(): Record<string, string> {
return Object.entries(localStorage).reduce((overrides, [key, url]) => {
return {
...overrides,
...(key.startsWith(NATIVE_FEDERATION_LOCAL_STORAGE_PREFIX) && {
[key.replace(NATIVE_FEDERATION_LOCAL_STORAGE_PREFIX, '')]: url,
}),
};
}, {});
}
The federation.manifest.json
would then appear as:
{
imports: {
"host": "http://localhost:4200/remoteEntry.json",
"mfAccount": "http://localhost:4203/remoteEntry.json",
"mfHome": "http://localhost:4201/remoteEntry.json",
"mfLogin": "http://localhost:4202/remoteEntry.json"
}
}
and directly integrate it into the index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>host</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<script type="importmap-shim" src="assets/federation.manifest.json"></script>
</head>
<body>
<nx-nf-root></nx-nf-root>
</body>
</html>
and in the initFederation
, we just need to use:
import('mfAccount').then((remoteEntry) => // same as before, inject exposes to inline importmap)
In this way:
✅ It is standard
✅ We use importmap
everywhere
✅ We can use default override behaviour
✅ It allows to override a full remote AND exposes separately
What do you Think?
Yes of course after discussion :)
However, the underlying principles remain unchanged. Rather than directly utilizing the importmap.json
, I have the option to override the federation.manifest.json
. This requires the creation of custom code within the application to enable the overrides of the bundles.
Do you want to try it?
- First, clone my GitHub repository:
git clone git@github.com:jogelin/nx-nf.git && cd nx-nf
2. Begin by installing the packages:
pnpm install
3. Next, you can start one micro frontend, for example, mf-admin
:
npx nx run mf-admin:serve
4. Then, access the URL https://nx-nf-a2d7c.web.app/admin where I have already deployed the application. You should see the application:
5. Now, open your favorite browser debugging tool and connect your local server to the remote application by adding this entry in the local storage:
localStorage.setItem('native-federation-override:mfAdmin', 'http://localhost:4203/remoteEntry.json') // override mfAdmin with you local server
6. Then, make modifications to the mf-admin
micro frontend. For example, change the message from "Welcome to the Admin Page" to “Welcome to the LOCAL Admin Page”
7. After you make changes, reload the page, and you should see your modifications reflected on the remote server immediately!
8. To revert the changes, simply remove the entry from the local storage and refresh the page to see the original state again.
localStorage.removeItem('native-federation-override:mfAdmin');
You can override any micro frontend using this approach. However, as I mentioned, the method involving native federation is not entirely native yet because it doesn’t utilize the default behavior of import maps.
You can find all the code utilizing Native Federation, Angular, and Nx in my GitHub repository.
NxNf
How leveraging native import map overrides can significantly benefit your micro frontend architecture
Final Thoughts
This exploration reveals the power of the native JavaScript ecosystem in browsers, highlighting how native support for ES modules enhances our development experience beyond faster build times.
The simplicity and effectiveness of the import map principle show us a way to solve complex issues with elegant solutions. It hints at a future where reliance on custom framework implementations diminishes in favor of native browser features, making development smoother and more intuitive.
Moreover, the use of Nx as part of this ecosystem offers a powerful toolkit that enables developers to approach complex projects with enhanced agility and precision.
The hope for more native features like these grows, promising a simpler, yet more powerful development landscape. With Nx and advancements in browser capabilities, we’re moving towards a future where building sophisticated web applications becomes more accessible and efficient.
🚀 Stay Tuned!
Credits
Joel Denning
Joel Denning is the visionary behind single-spa, boasting profound insights into the true mechanics of the web and is, in my view, a pioneer in micro frontend architecture. I highly recommend checking the single-spa website and his YouTube channel. While the videos might appear dated at first glance, rest assured, Joel is ahead of his time, and the content remains incredibly relevant today.
Engineer, Architect, Speaker, Trainer, Consultant, and Author, Manfred knows what he is talking about. For everyone, not just Angular, interested in micro frontends and web architecture in general, I encourage you to have a look at his book Enterprise Angular: Micro Frontends and Moduliths with Angular and the blog of the Angular Architect team.
Top comments (0)