Composing micro-frontends with import maps and native ES modules

작성자

카테고리:

← 피드로
DEV Community · Angelo · 2026-07-25 개발(SW)
Cover image for Composing micro-frontends with import maps and native ES modules

Angelo

This strategy grew out of my attempt to design an architecture around value streams: independent teams needed to own, develop, test, deploy their own code. Once changes were ready for release, they just need to promote their work. At which point the runtime dynamically loads their latest changes into the product. But independence alone wasn’t enough. If achieving it required every team to understand a complex composition framework or maintain layers of specialized configuration, the architecture would simply exchange delivery friction for cognitive load. I wanted a strategy built around a small, shared convention—one that preserved team autonomy while keeping the overall system understandable. That search eventually led me to browser-native primitives, particularly ES modules and import maps, as the foundation for Runtime Module Composition.

If you’ve worked on a large web product, you’ve probably lived through this: five teams, one page, and a build pipeline that’s grown a webpack config nobody fully understands anymore, with small changes triggering an entire suite of tests. Splitting a page into independently built, independently deployed pieces are supposed to solve the “five teams, one page” problem. In practice, most micro-frontend tooling solves it by adding another layer of build-time complexity on top.

There’s a simpler answer sitting in every modern browser already: import maps. They let a page say “when someone imports react, actually fetch it from this URL”, no bundler required, no custom module loader, no iframes. Runtime Module Composition is a small toolkit (rmc-toolkit) built entirely around that one browser feature. This post walks through what it does and why the browser-native approach ends up simpler than it sounds.

The problem, in one sentence

A page made of pieces built by different teams needs two things: a way to find the right piece for the current URL, and a way to load it without shipping five copies of React. Most solutions build both of those from scratch. Import maps give you both for free.

What an import map actually is

Strip away the name and it’s just a lookup table, written as a <script> tag:

<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/[email protected]",
    "@fastflights/search/": "https://assets.fastflights.com/search/"
  }
}
</script>

Enter fullscreen mode Exit fullscreen mode

When any JavaScript on the page later writes import React from "react", the browser looks up "react" in that table and fetches the real URL instead, a plain HTTP request, no build step involved. This is a real, standardized part of the web platform, not something a library invented. It’s supported in every major browser today.

That’s the entire trick. Everything else in this post is really just: what do you build on top of that lookup table to make it useful for a real product?

Low configuration: one object describes the whole system

Instead of hand-writing that JSON table, rmc-toolkit generates it from a single manifest. One plain object that’s also the only thing you configure:

import { defineManifest } from "@rmc-toolkit/core";

export const manifest = defineManifest({
  namespace: "@fastflights",
  assetsOrigin: "https://assets.fastflights.com",
  externalDepsOrigin: "https://esm.sh",
  externalDeps: [
    { name: "react", version: "19.1.0", peerDeps: false },
    { name: "react-dom/client", version: "19.1.0" },
  ],
  defaultPeerDeps: ["react"],
});

Enter fullscreen mode Exit fullscreen mode

That’s it! That one object is what generates the import map above, what tells the build tooling which imports to leave alone instead of bundling, and what a router uses to figure out which piece of the page owns which URL. There’s no separate integration layer to keep in sync by hand: change a version number here, and every consumer of the manifest; the import map, the build, the router – picks it up automatically, because they’re all reading the same object.

Native browser behavior: no custom runtime to trust

A lot of micro-frontend tooling works by shipping its own module-loading runtime — code that intercepts import, tracks what’s loaded, and stitches pieces together at runtime. That runtime is one more thing that can have bugs, one more thing to keep updated, one more black box between your code and what actually runs.

Runtime Module Composition doesn’t have one. Loading a piece of the page is just:

const searchModule = await import("@fastflights/search/index.mjs");

Enter fullscreen mode Exit fullscreen mode

That’s the same import() you’d write to lazy-load anything else. The browser resolves it through the import map, fetches it, and runs it — the exact same mechanism it uses for every other module on the page. There’s nothing custom to audit, because there’s nothing custom involved.

Small build artifacts: shared code only ships once

Here’s a detail that’s easy to miss but has a real, measurable effect on page weight. When a bundler doesn’t know two independently-built pieces share a dependency, it bundles that dependency into both of them. Ship five micro-frontends built with a typical setup and you can end up shipping five copies of React.

Because rmc-toolkit knows — from that same manifest — which imports are meant to come from the import map, it tells the bundler to leave them alone entirely:

// vite.config.ts, inside a slice's own project
import { defineConfig } from "vite";
import { createRollupExternal } from "@rmc-toolkit/vite";
import { manifest } from "./runtime-composition.manifest.js";

export default defineConfig({
  build: {
    lib: {
      entry: ["src/index.ts"],
      formats: ["es"],
      fileName: () => "index.mjs",
    },
    rollupOptions: {
      external: createRollupExternal(manifest),
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

The result: a slice’s build output contains only its own code. React, React DOM, whatever else is shared — all of it comes from the one URL every piece of the page already agrees on, fetched once by the browser and reused everywhere it’s referenced. More independently-built pieces on a page doesn’t mean more copies of the same dependency; it just means more small files.

Following one click through the system

Put it all together and here’s what happens when someone visits fastflights.com/search:

  1. The page loads with its import map already in place, inlined into the HTML by the build, so it’s ready before any module script runs.
  2. A small router matches /search against the manifest and gets back a module specifier: @fastflights/search/index.mjs.
  3. The host calls import() on that specifier. The browser checks the import map, finds the real URL, and fetches it from a completely different origin than the host’s own page, if that’s how it’s deployed.
  4. The loaded module mounts into the page. If it needs React, it imports it the same prefixed way, and gets back the exact same instance every other piece is already using.

Every step past step 2 is the browser doing what it already does for any other dynamic import. Nothing here is bespoke.

Try it

npm install @rmc-toolkit/core @rmc-toolkit/vite @rmc-toolkit/react

Enter fullscreen mode Exit fullscreen mode

The Quick Start walks through wiring up a small React (or Vue) host and one slice end to end, running locally in under a dozen steps.

Learn more

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다