DEV Community

Cover image for ⏰ It’s time to talk about Import Map, Micro Frontend, and Nx Monorepo
jogelin
jogelin

Posted on • Originally published at jgelin.Medium

⏰ It’s time to talk about Import Map, Micro Frontend, and Nx Monorepo

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!

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:

Image description

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:

Image description

  1. The first action is always to get an index.html file, which has everything needed to start the application.

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

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

Image description

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:

Image description

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

Image description

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

Image description

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

Image description

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"
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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.

Image description

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:

Image description

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:

Image description

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:

Image description

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:

Image description

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.

Image description

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

Monorepos | Nx

Nx is a build system with built-in tooling and advanced CI capabilities. It helps you maintain and scale monorepos, both locally and on CI.

favicon nx.dev

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.

Run Only Tasks Affected by a PR | Nx

Nx is a build system with built-in tooling and advanced CI capabilities. It helps you maintain and scale monorepos, both locally and on CI.

favicon nx.dev

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.

Dependency Management | Nx

Nx is a build system with built-in tooling and advanced CI capabilities. It helps you maintain and scale monorepos, both locally and on CI.

favicon nx.dev

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 ;)

Goal

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.

Issue

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.

Propositions

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
Enter fullscreen mode Exit fullscreen mode

1. Allow importmap overrides in the es-module-shims library

 <script type="esms-options">
 {
   "shimMode": true
   "mapOverrides": true
 }
 </script>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

3. Ensure that when we loadRemoteModule, we obtain the actual version from the importmap

By listening to the DOM mutation like es-module-shim and refreshing the cache

  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 });
Enter fullscreen mode Exit fullscreen mode

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.

By directly reading the importmap

The impact on performance is uncertain. Having an API library to manipulate importmaps would be advantageous.

Work Around

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);
}
Enter fullscreen mode Exit fullscreen mode

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

What about directly overriding the federation.manifest.json?

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.

By using a custom approach

I implemented an easy way, but custom, but it keeps the globalCache in sync:

If you have that override in your localStorage: image

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));
Enter fullscreen mode Exit fullscreen mode

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,
      }),
    };
  }, {});
}
Enter fullscreen mode Exit fullscreen mode

But why not using an importmap to load the remoteEntry.json files?

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

and in the initFederation, we just need to use:

import('mfAccount').then((remoteEntry) => // same as before, inject exposes to inline importmap)
Enter fullscreen mode Exit fullscreen mode

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?

Do you want to make a PR?

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?

  1. First, clone my GitHub repository:
git clone git@github.com:jogelin/nx-nf.git && cd nx-nf
Enter fullscreen mode Exit fullscreen mode

2. Begin by installing the packages:

pnpm install
Enter fullscreen mode Exit fullscreen mode

3. Next, you can start one micro frontend, for example, mf-admin:

npx nx run mf-admin:serve
Enter fullscreen mode Exit fullscreen mode

4. Then, access the URL https://nx-nf-a2d7c.web.app/admin where I have already deployed the application. You should see the application:

Image description

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
Enter fullscreen mode Exit fullscreen mode

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”

Image description

7. After you make changes, reload the page, and you should see your modifications reflected on the remote server immediately!

Image description

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');
Enter fullscreen mode Exit fullscreen mode

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.

GitHub logo jogelin / nx-nf

POC repository showing Nx Native Federation and Importmap Override Configurations






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.

Image description

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.

Image description

Start a Collaboration?

Connect with me on TwitterLinkedInGithub.

Top comments (0)