DEV Community

Cover image for Micro Frontends with Native Federation 🍿
Florian Rappl
Florian Rappl

Posted on

Micro Frontends with Native Federation 🍿

Photo by Dom Talbot on Unsplash

One of the things that I find interesting is how much the browsers evolved in the last couple of years. Surely, beforehand we also had quite some progress going on - but now it's just so much detail work that goes into it. Among these many tiny improvements lures the support for native modules (EcmaScript modules - short ESM) and their capability of having aliases for modules in form of the importmaps standard.

A technology that uses this combination for providing support for micro frontends is "Native Federation". In this article I'd like to show what Native Federation is and what you need to get started with it.

👉 Find the code for this article on GitHub.

What is Native Federation

Native Federation is a compile-time and runtime mechanism to allow different scripts (micro frontends) to share dependencies and expose / import components between each other. It builds upon the ideas of Module Federation.

According to its author, it comes with the following features:

  • ✅ Mental model of Module Federation
  • ✅ Future proof: Independent of build tools like Webpack and frameworks
  • ✅ Embraces importmaps -- an emerging browser technology -- and ECMAScript modules (ESM)
  • ✅ Easy to configure
  • ✅ Blazing fast: The reference implementation not only uses the fast esbuild; it also caches already built shared dependencies (like Angular itself). However, as mentioned above, feel free to use it with any other build tool.

It works using the following mental model:

  • Remote: The remote is a separately built and deployed application. It can expose ESM that can be loaded into other applications.
  • Host: The host loads one or several remotes on demand. For your framework's perspective, this looks like traditional lazy loading. The big difference is that the host doesn't know the remotes at compilation time.
  • Shared Dependencies: If a several remotes and the host use the same library, you might not want to download it several times. Instead, you might want to just download it once and share it at runtime. For this use case, the mental model allows for defining such shared dependencies.

In particular, this means that if two or more applications use a different version of the same shared library, Native Federation makes it possible to prevent a version mismatch. There are several strategies, like falling back to another version that fits the application, using a different compatible one (according to semantic versioning) or throwing an error, which are in place to mitigate this issue.

As mentioned, in general this is all very similar to Module Federation. Let's compare the two.

Comparison to Module Federation

Native Federation builds upon the ideas and philosophy of Module Federation. However, in contrast to some popular believe these two are incompatible to each other.

There are other aspects, too, where both technologies are different:

Area Module Federation Native Federation
Tooling Webpack, rspack Vite, esbuild
Dependencies Build-Time Build-Time
Micro Frontends Build & Runtime Runtime
Nested Possible Not directly
Support manifest Not directly Yes
On the fly updates Possible Not possible
Module format Independent ESM

The most crucial difference is that Native Federation is not directly dependent on tooling support. As a downside there is no possibility of directly importing code from a remote module, e.g., the following code would not work:

import('remote/module').then(({ Component }) => {
  // Use the imported Component
});
Enter fullscreen mode Exit fullscreen mode

In contrast, in Native Federation we need to use the loadRemoteModule function imported from the @softarc/native-federation package:

loadRemoteModule({
  remoteName: "remote",
  exposedModule: "./module",
}).then(({ Component }) => {
  // Use the imported Component
});
Enter fullscreen mode Exit fullscreen mode

As already mentioned Native Federation is not directly dependent on tooling, even though shared dependencies must be resolved via the tooling. To do this Native Federation uses so-called adapters, which can be created for pretty much every bundler. Right now, the two best-supported bundlers are Vite and esbuild.

Module Federation was originally only available on Webpack. As of today, it is also available in rspack (quite naturally, as rspack tries to implement as many of the Webpack APIs as possible) and also via community contributions in other bundlers such as Vite and Rollup.

Technically, Native Federation is strongly dependent on ESM as module format. Module Federation, on the other hand, is quite independent of the format - as long as the runtime is able to load more chunks (or modules). Usually, this implies a Webpack runtime and its own loading mechanisms, but actually what mechanism is really used is independent of Module Federation.

The dependence on ESM comes with some constraints. Most importantly, Native Federation makes use of importmaps to tell the micro frontends what dependencies are used and how these can be shared. However, since importmaps follow the "initialization is finalization" approach it is impossible to update them. This implies that micro frontends can - in general - not be updated on the fly. Otherwise, any update of a micro frontend would need to work with the already shared / constructed list of dependencies in the importmap.

Finally, the @softarc/native-federation package is bundled with each micro frontend. As the package is rather small this is not directly an issue, however, it becomes an issue regarding accessing globally loaded micro frontends. We'll see now in an example application how to mitigate / improve this.

Example Application

To actually see what we can do with Native Federation we'll build a simple example application. Naturally, I've picked the famous Tractor shop by Michael Geers which is the ToDo-MVC of micro frontends.

In the end our application is supposed to look like this:

Tractor shop

Importantly, the applied CSS design (red dashes, blue dashes, green dashes) is used to make the boundaries and origins of the respective UI fragments clear.

The whole application is also fully interactive, requiring the individual micro frontends to communicate, e.g., to align on a chosen tractor:

Tractor shop in action

With this in mind we have the following distribution of UI fragments:

  • app shell: The entry point, used to load and display the page component from red.
  • red: Contains the components from the Red team - just a single page (tractor details page). This page includes three more fragments; 2 from blue and 1 from green.
  • blue: Contains the components from the Blue team - a component to display the shopping cart symbol with a counter, and a buy button component.
  • green: Contains the components from the Green team - just a single component being used as recommended products with respect to the currently selected product.

What do we actually need for the app shell to get started? We start by scaffolding a new npm project and installing the required dependencies:

npm init -y
npm install native-federation-esbuild @module-federation/vite vite typescript --save-dev
npm install @softarc/native-federation --save
Enter fullscreen mode Exit fullscreen mode

Now we create a file index.html with the following content:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Tractor Store</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/index.ts"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

This assumes that our code will run in a browser with ESM and importmap support. If this assumption is not correct we should add the following part to the <head>:

<script type="esms-options">
{
  "shimMode": true
}
</script>
<script src="https://ga.jspm.io/npm:es-module-shims@1.5.17/dist/es-module-shims.js"></script>
Enter fullscreen mode Exit fullscreen mode

With this set up it's time to look at the src/index.ts:

import "./style.css";
import { initFederation, loadRemoteModule } from "@softarc/native-federation";

(async () => {
  await initFederation({
    "red": "http://localhost:2001/remoteEntry.json",
    "blue": "http://localhost:2002/remoteEntry.json",
    "green": "http://localhost:2003/remoteEntry.json",
  });

  await loadRemoteModule({
    remoteName: "red",
    exposedModule: "./productPage",
  }).then(({ renderProductPage }) => {
    const root = document.querySelector("#app");
    renderProductPage(root);
  });
})();
Enter fullscreen mode Exit fullscreen mode

This gets the basic styling from the style.css. The actual bootstrapping is then performed using the initFederation function. Afterwards, we can use the loadRemoteModule to obtain the exposed product page from the red micro frontend. At this point everything is supposed to be served locally.

Now let's continue with the first micro frontend - the red micro frontend.

Moving to a new / fresh directory we start scaffolding the foundation. We'll need a new npm project with the right dependencies:

npm init -y
npm i @hyrious/esbuild-plugin-style @module-federation/vite esbuild-auto-path-plugin native-federation-esbuild typescript vite @types/react @types/react-dom --save-dev
npm i @softarc/native-federation react react-dom --save
Enter fullscreen mode Exit fullscreen mode

As you'll see we also install React to write the components of this micro frontend in React. Surely, Native Federation is completely independent of such frameworks and we could also choose something else.

Concerning the actual component code we create a new file product-page.tsx in the src folder with the following content:

import "./style/product-page.css";
import React from "react";
import ReactDOM from "react-dom";
import { loadRemoteModule } from "@softarc/native-federation";
import tractorRed from "./images/tractor-red.jpg";
import tractorBlue from "./images/tractor-blue.jpg";
import tractorGreen from "./images/tractor-green.jpg";
import tractorRedThumb from "./images/tractor-red-thumb.jpg";
import tractorBlueThumb from "./images/tractor-blue-thumb.jpg";
import tractorGreenThumb from "./images/tractor-green-thumb.jpg";

const product = {
  name: "Tractor",
  variants: [
    {
      sku: "porsche",
      color: "red",
      name: "Porsche-Diesel Master 419",
      image: tractorRed,
      thumb: tractorRedThumb,
      price: "66,00 €",
    },
    {
      sku: "fendt",
      color: "green",
      name: "Fendt F20 Dieselroß",
      image: tractorGreen,
      thumb: tractorGreenThumb,
      price: "54,00 €",
    },
    {
      sku: "eicher",
      color: "blue",
      name: "Eicher Diesel 215/16",
      image: tractorBlue,
      thumb: tractorBlueThumb,
      price: "58,00 €",
    },
  ],
};

const BasketInfo = React.lazy(() => loadRemoteModule({
  remoteName: "blue", exposedModule: "./basketInfo",
}));
const BuyButton = React.lazy(() => loadRemoteModule({
  remoteName: "blue", exposedModule: "./buyButton",
}));
const ProductRecommendations = React.lazy(() => loadRemoteModule({
  remoteName: "green", exposedModule: "./recommendations",
}));

function getCurrent(sku: string) {
  return product.variants.find((v) => v.sku === sku) || product.variants[0];
}

const ProductPage = () => {
  const [sku, setSku] = React.useState("porsche");
  const current = getCurrent(sku);

  return (
    <React.Suspense fallback="Loading ...">
      <h1 id="store">The Model Store</h1>
      <div className="blue-basket" id="basket">
        <BasketInfo sku={sku} />
      </div>
      <div id="image">
        <div>
          <img src={current.image} alt={current.name} />
        </div>
      </div>
      <h2 id="name">
        {product.name} <small>{current.name}</small>
      </h2>
      <div id="options">
        {product.variants.map((variant) => (
          <button
            key={variant.sku}
            className={sku === variant.sku ? "active" : ""}
            type="button"
            onClick={() => setSku(variant.sku)}
          >
            <img src={variant.thumb} alt={variant.name} />
          </button>
        ))}
      </div>
      <div className="blue-buy" id="buy">
        <BuyButton sku={sku} />
      </div>
      <div className="green-recos" id="reco">
        <ProductRecommendations sku={sku} />
      </div>
    </React.Suspense>
  );
};

export default ProductPage;

export function renderProductPage(container: HTMLElement) {
  ReactDOM.render(<ProductPage />, container);
}
Enter fullscreen mode Exit fullscreen mode

Importantly, we do not only export the component directly (as the default export - to make it already compatible with React.lazy), but also a function to render it inside some container (in this case renderProductPage). The latter is necessary to work across frameworks - or to just mount the root node as we've seen in the app shell.

Now, we'll also build the other (blue and green) micro frontends. Like with the red one, we scaffold them using npm init -y. However, the code inside is different.

For the green micro frontend we have a single component / file that will be exposed (product-recommendations.tsx, placed in a src folder):

import "./style/recommendations.css";
import React from "react";
import ReactDOM from "react-dom";
import reco1 from "./images/reco_1.jpg";
import reco2 from "./images/reco_2.jpg";
import reco3 from "./images/reco_3.jpg";
import reco4 from "./images/reco_4.jpg";
import reco5 from "./images/reco_5.jpg";
import reco6 from "./images/reco_6.jpg";
import reco7 from "./images/reco_7.jpg";
import reco8 from "./images/reco_8.jpg";
import reco9 from "./images/reco_9.jpg";

const recos = {
  1: reco1,
  2: reco2,
  3: reco3,
  4: reco4,
  5: reco5,
  6: reco6,
  7: reco7,
  8: reco8,
  9: reco9,
};

const allRecommendations = {
  porsche: ["3", "5", "6"],
  fendt: ["3", "6", "4"],
  eicher: ["1", "8", "7"],
};

const Recommendations = ({ sku = "porsche" }) => {
  const recommendations = allRecommendations[sku] || allRecommendations.porsche;

  return (
    <>
      <h3>Related Products</h3>
      {recommendations.map((id) => (
        <img src={recos[id]} key={id} alt={`Recommendation ${id}`} />
      ))}
    </>
  );
};

export default Recommendations;

export function renderRecommendations(container: HTMLElement) {
  ReactDOM.render(<Recommendations />, container);
}
Enter fullscreen mode Exit fullscreen mode

For the blue micro frontend we have two components. Each will be in its own file.

First, let's create a file basket-info.tsx in a src folder:

import "./style/basket-info.css";
import React from "react";
import ReactDOM from "react-dom";

const BasketInfo = ({ sku = "porsche" }) => {
  const [items, setItems] = React.useState([]);
  const count = items.length;

  React.useEffect(() => {
    const handler = () => {
      setItems((items) => [...items, sku]);
    };
    window.addEventListener("add-item", handler);
    return () => window.removeEventListener("add-item", handler);
  }, [sku]);

  return (
    <div className={count === 0 ? "empty" : "filled"}>basket: {count} item(s)</div>
  );
};

export default BasketInfo;

export function renderBasketInfo(container: HTMLElement) {
  ReactDOM.render(<BasketInfo />, container);
}
Enter fullscreen mode Exit fullscreen mode

Then, for the second component, create a file buy-button.tsx in the src folder:

import "./style/buy-button.css";
import React from "react";
import ReactDOM from "react-dom";

const defaultPrice = "0,00 €";
const prices = {
  porsche: "66,00 €",
  fendt: "54,00 €",
  eicher: "58,00 €",
};

const BuyButton = ({ sku = "porsche" }) => {
  const price = prices[sku] || defaultPrice;

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        window.dispatchEvent(new CustomEvent("add-item", { detail: price }));
      }}
    >
      <button>buy for {price}</button>
    </form>
  );
};

export default BuyButton;

export function renderBuyButton(container: HTMLElement) {
  ReactDOM.render(<BuyButton />, container);
}
Enter fullscreen mode Exit fullscreen mode

For brevity, I've skipped the CSS and image files. Please have a look at the sample repository for all of them; needless to say the CSS files are rather straight forward.

The CSS of the BuyButton, for instance, looks as follows:

#buy {
  align-self: center;
  grid-area: buy;
}

#buy button {
  background: none;
  border: 1px solid gray;
  border-radius: 20px;
  cursor: pointer;
  display: block;
  font-size: 20px;
  outline: none;
  padding: 20px;
  width: 100%;
}

#buy button:hover {
  border-color: black;
}

#buy button:active {
  border-color: seagreen;
}

.blue-buy {
  display: block;
  outline: 3px dashed royalblue;
  padding: 5px;
}
Enter fullscreen mode Exit fullscreen mode

Nothing too fancy here.

Having written the whole code it's time to let it run. But how? After all, this is where we need to integrate with Native Federation. Let's start with the green micro frontend as its standalone (like blue), but only a single component (unlike blue).

For this, we create a file vite.config.ts with the following content:

import { defineConfig } from "vite";
import { style } from "@hyrious/esbuild-plugin-style";
import { autoPathPlugin } from "esbuild-auto-path-plugin";
import { federation } from "@module-federation/vite";
import { createEsBuildAdapter } from "native-federation-esbuild";

export default defineConfig(async ({ command }) => ({
  plugins: [
    await federation({
      options: {
        workspaceRoot: __dirname,
        outputPath: "dist",
        tsConfig: "tsconfig.json",
        federationConfig: "src/federation.ts",
        verbose: false,
        dev: command === "serve",
      },
      adapter: createEsBuildAdapter({
        plugins: [autoPathPlugin(), style()],
      }),
    }),
  ],
}));
Enter fullscreen mode Exit fullscreen mode

This file is placed in the package's root folder (e.g., green), i.e., adjacent to the package.json.

So what's happening in this file? Let's dissect it:

  • We define a build configuration to be used by Vite, i.e., for the micro frontend to become a standalone application
  • By using the federation function from Module Federation we extract part of the application into a side-bundle, which is built using a different toolset
  • The different toolset is using the provided adapter createEsBuildAdapter, which is using esbuild
  • The esbuild configuration also comes with some plugins; one plugin for using styles and another plugin to transform paths (e.g., to assets such as images) dynamically at runtime; this is important as we don't know yet where / how the micro frontends are hosted and which paths they take in different environments (e.g., production environment)

Crucially, the specific mechanisms of the federation helper / plugin options are determined by another re-usable file called federation.ts:

const {
  withNativeFederation,
  shareAll,
} = require("@softarc/native-federation/build");

module.exports = withNativeFederation({
  name: "green",
  exposes: {
    "./recommendations": "./src/product-recommendations.tsx",
  },
  shared: {
    ...shareAll({
      singleton: true,
      strictVersion: true,
      requiredVersion: "auto",
      includeSecondaries: false,
    }),
  },
});
Enter fullscreen mode Exit fullscreen mode

Here, we export a configuration that super similar to a configuration of Webpack Module Federation. Specifically, we set the name of the micro frontend (green), the exposed modules via exposes and the shared dependencies via shared. For the latter we use the shareAll helper, which takes the dependencies from the package.json and transforms them into shared dependencies.

At this point the green micro frontend can run standalone; at least when we also create an index.html as we did for the app shell.

For this micro frontend we can use the following HTML boilerplate:

<!DOCTYPE html>
<html lang="en">
<head>
  <script type="esms-options">
    {
        "shimMode": true
    }
  </script>
  <script src="https://ga.jspm.io/npm:es-module-shims@1.5.17/dist/es-module-shims.js"></script>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Green Standalone</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

where main.ts is the entry module of the application:

import { initFederation } from "@softarc/native-federation";

(async () => {
  await initFederation({});
  await import("./bootstrap");
})();
Enter fullscreen mode Exit fullscreen mode

Doing the same for blue we end up with a bunch of micro frontends - all capable of delivering of a full application when combined. This is where we hit the first rock.

We hit some disgusting output

It turns out that only the application initializing Native Federation can set what micro frontends exist. However,

  • from perspective of our main application only red exists
  • only within red we know that green and blue are needed

This puts us in a problematic situation. Even worse: As @softarc/native-federation must be non-shared (after all, within the application it is used as a standard script; otherwise it cannot set/manipulate the import map before the first ESM loads) every micro frontend has its own version of it. Therefore, even setting all micro frontends within the application (red, green, blue) we are still unable to access green and blue from red (remember, red has its own version of the package, which was not initialized / does not know these micro frontends - they are only available from the root level / main application).

So how can we improve the situation? We can introduce a little helper, that solves this elegantly:

import { lazy } from "react";
import { initFederation, loadRemoteModule } from "@softarc/native-federation";

export async function setup(manifest?: string | Record<string, string>) {
  await initFederation(manifest);

  window.loadComponent = (remoteName, exposedModule) =>
    lazy(() =>
      loadRemoteModule({
        remoteName,
        exposedModule,
      })
    );
}

declare global {
  interface Window {
    loadComponent(remoteName: string, modulePath: string): React.FC<any>;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is a shared code in the sense that every micro frontend can use and embed it, but its not shared in the sense that every runtime needs to access the same instance of this code. In fact, every micro frontend will bundle this code in its standalone entry module, but not in its exposed modules.

With this we can replace the calls to initFederation with setup and calls to loadRemoteModule with loadComponent from window.

In the red micro frontend this looks like this (first, let's have a look at main.ts):

import { setup } from "@shared/loader";

(async () => {
  await setup({
    green: "http://localhost:2003/remoteEntry.json",
    blue: "http://localhost:2002/remoteEntry.json",
  });
  await import("./bootstrap");
})();
Enter fullscreen mode Exit fullscreen mode

Again, this is only relevant in the standalone mode. However, the product-page.tsx was also impacted:

import "./style/product-page.css";
import React from "react";
import ReactDOM from "react-dom";

// no import of `@softarc/native-federation` any more!

// data etc. remains unchanged

const BasketInfo = window.loadComponent("mf-blue", "./basketInfo");
const BuyButton = window.loadComponent("mf-blue", "./buyButton");
const ProductRecommendations = window.loadComponent(
  "mf-green",
  "./recommendations"
);

// rest as before
Enter fullscreen mode Exit fullscreen mode

So we now go against a globally available loader - which we are sure must be provided because at the end of the day we are running in an application.

The other great thing that this wrapper allows us is to change / extend the micro frontends easily, i.e., to introduce a micro frontend discovery service.

Scaling with Micro Frontend Discovery

Just using Native Federation alone would quite acceptable, however, we want to take this to the next level. What we need is a Micro Frontend Discovery service that allows publishing new micro frontends or updating existing micro frontends without any friction.

Actually, using the Piral Feed Service we can already do that. Remember that the initFederation function also accepts a string / URL? We can just use this:

import './style.css';
import { initFederation } from "@softarc/native-federation";

(async () => {
  await initFederation('https://native-federation-demo.my.piral.cloud/api/v1/native-federation');
  await import("./bootstrap");
})();
Enter fullscreen mode Exit fullscreen mode

In this example we use our own feed ("native-federation-demo") with the native-federation representation. Fetching data from this URL will result in the following payload:

{
  "mf-blue": "https://assets.piral.cloud/pilets/native-federation-demo/mf-blue/1.0.0/remoteEntry.json",
  "mf-green": "https://assets.piral.cloud/pilets/native-federation-demo/mf-green/1.0.0/remoteEntry.json",
  "mf-red": "https://assets.piral.cloud/pilets/native-federation-demo/mf-red/1.0.3/remoteEntry.json"
}
Enter fullscreen mode Exit fullscreen mode

If you want to follow these steps you will also most likely need to create your own feed. For this, you'll need log into feed.piral.cloud and click on "+" (Create Feed):

Creating a new micro frontend feed

After this, you should be redirected to a page with the feed details. Here, you can copy the URL for the native federation manifest representation (there are other options, too, but this one is the easiest format and thus works for us / this article).

The rest can remain the same. Now the question is: How can we upload these micro frontends?

To publish our micro frontends we can use the publish-microfrontend package. This is a simple command line tool that makes it possible to publish a micro frontend using a single command:

npx publish-microfrontend --url https://native-federation-demo.my.piral.cloud/api/v1/pilet --interactive
Enter fullscreen mode Exit fullscreen mode

In this example the --url flag tells the CLI utility which service (and in this case: feed) to use. Generally, you could any service as long as it speaks the appropriate protocol - in this case a form POST request using the tarball with the micro frontend's assets.

The --interactive flag tells the CLI to use the interactive flow (if available) from the provided API. This will open a web browser redirecting to the login page for the Piral Feed Service. Alternatively, we could have created an API key and use it via the --api-key flag.

Once all micro frontends are published you should see the following in the feed overview:

Micro frontends in the feed

And with this we are done - now everything works natively and can scale, too!

Conclusion

Native Federation is an interesting technology if we know how to handle the existing edge cases and downsides as compared to Module Federation. Presumably, the strongest downside are the ones inherited from being on ESM - requiring support for importmaps and being constraint to the rules of importmaps such as initialization is finalization.

By using a micro frontend discovery service we can actually bring our solution to the next level - ensuring productivity and efficiency to make our solution not only adequate for the moment, but also in the future.

👉 Find the code for this article on GitHub.

In case of questions or general advice don't hesitate to connect with me on LinkedIn 🙏.

Top comments (1)

Collapse
 
jangelodev profile image
João Angelo

Hi Florian Rappl,
Your tips are very useful
Thanks for sharing