Import Maps for Native Module Loading #

Import maps move module resolution out of the bundler and into the browser’s native ES module loader. Instead of a build step rewriting import { x } from '@company/ui-core' into a hashed chunk path, you ship a small JSON document that tells the browser exactly which URL each bare specifier resolves to. The loader does the rest.

For micro-frontends this is appealing: a host shell can compose remotes that were each built independently, with no shared bundler graph and no remoteEntry.js manifest. The cost is that you now own the resolution table by hand, and you inherit a set of browser-level constraints that the Configuring Webpack Module Federation approach hides from you.

This guide is part of Webpack & Vite Module Federation Implementation. It covers the import-map syntax, how to wire it into a host shell, the failure modes that bite teams in production, and how to test and deploy it safely. Two companion guides go deeper: Using Import Maps as a Lightweight Federation Alternative compares it head-on with a full federation setup, and Module Federation vs Import Maps vs ESM CDN lays the three composition models side by side so you can pick deliberately.

What breaks without a resolution strategy #

The naive way to load a remote at runtime is to hardcode a URL: import('https://cdn.example.com/remote/v3/widget.js'). This works until two things happen.

First, the remote and the host both import React. With hardcoded URLs each of them pulls its own copy, you ship React twice, and any library that relies on module identity — React context, a Redux store, a singleton event bus — silently forks into two instances that can’t see each other.

Second, you need to ship a new version of the remote. Now you have URLs scattered across the host source, every remote’s source, and your CI config. A coordinated upgrade means a coordinated rebuild of everything, which is exactly the coupling micro-frontends were supposed to remove.

An import map fixes both by giving you one indirection layer. Code imports the stable bare specifier; the map binds that specifier to a concrete URL; you change the binding in one place. The browser guarantees that two imports of the same specifier resolve to the same module instance, so singletons hold.

The catch — and the reason this needs a guide rather than a one-liner — is that the browser parses the import map exactly once, before the first module executes. Get the timing or the resolution table wrong and you get opaque failures that bypass your error boundaries entirely.

Key objectives #

How the browser resolves a bare specifier #

Import map resolution flow Module code imports a bare specifier, the browser consults the parsed import map, and fetches the mapped absolute URL. Remote module import x from '@company/ui-core' Import map (parsed once in <head>) @company/ui-core → /ui-core/1.4.2.js react → /react/18.3.1.js @company/auth → /auth/2.1.0.js CDN fetch immutable hashed module lookup resolve URL
A bare specifier in module code is matched against the parsed import map, which yields the absolute URL the browser then fetches from the CDN.

The flow is purely declarative. The module never knows the URL; the map never knows who is importing. That separation is what lets you upgrade a library across every remote by editing one line.

Resolution follows a few rules worth committing to memory. The browser matches the longest key first, so a trailing-slash prefix like "@company/ui-core/" wins over a bare "@company/ui-core" for any subpath request. Keys are matched as literal strings, not globs — there is no wildcard or regex. A specifier that matches nothing in the map is left untouched: if it already looks like a URL (./, ../, /, or https://) the browser fetches it directly; if it is a bare specifier with no mapping, the import throws a TypeError before any network request happens. And the map is additive only within a single document — there is no inheritance from a parent frame, so each iframe or worker needs its own map.

Baseline browser support in 2026 #

As of mid-2026 import maps are Baseline Widely Available: Chromium 89+, Firefox 108+, and Safari 16.4+ all ship native support, and the scopes and integrity keys are covered by the same releases (integrity landed in Chromium 127 and Firefox 138, so treat it as the one feature that still needs a fallback on a small share of older-but-not-ancient browsers). For the vast majority of public traffic you can rely on native parsing and reserve the shim for explicitly detected legacy clients.

Setup and configuration #

A static import map is a single <script type="importmap"> block in the document <head>. It must appear before any <script type="module">, because the browser freezes the resolution table the moment the first module begins loading.

<!-- index.html — host shell -->
<head>
  <!-- The import map MUST come before any module script. -->
  <script type="importmap">
  {
    "imports": {
      "react": "https://cdn.example.com/react/18.3.1/react.js",
      "react-dom/client": "https://cdn.example.com/react-dom/18.3.1/client.js",
      "@company/ui-core": "https://cdn.example.com/ui-core/1.4.2/index.js",
      "@company/auth": "https://cdn.example.com/auth/2.1.0/index.js",
      "checkout/": "https://remotes.example.com/checkout/v3/"
    },
    "scopes": {
      "https://remotes.example.com/checkout/v3/": {
        "lodash-es": "https://cdn.example.com/lodash-es/4.17.21/lodash.js"
      }
    }
  }
  </script>

  <!-- Now, and only now, may modules load. -->
  <script type="module" src="/host/main.js"></script>
</head>

Three things in that block carry weight.

Trailing-slash prefixes ("checkout/") let one entry resolve a whole subtree. import('checkout/widget.js') resolves to https://remotes.example.com/checkout/v3/widget.js. This is how you map an entire remote without listing every file.

Immutable, content-addressed URLs for libraries. The version lives in the path (/react/18.3.1/), so the file itself can be cached forever. You change the version by changing the map, never by busting a file’s cache.

scopes override a specifier for modules loaded from a particular URL prefix. Above, the checkout remote gets its own lodash-es while the rest of the page is free to map it differently or not at all. Use scopes sparingly — every scope you add is a place where module identity can fork, which is the opposite of what you usually want. For the deliberate decisions about which libraries to share globally versus scope, see Managing Shared Dependencies at Runtime.

The import-map shim #

Native support is good on current Chromium, Firefox, and Safari 16.4+, but you will still meet older Safari and locked-down enterprise browsers. es-module-shims polyfills the spec by intercepting module loading and resolving specifiers in JavaScript. Critically, it also unlocks the one thing native maps can’t do: applying a map after the first module has loaded.

<head>
  <!-- Load the shim first; it no-ops where native support exists. -->
  <script async src="https://ga.jspm.io/npm:[email protected]/dist/es-module-shims.js"></script>
  <script type="esms-options">
    { "shimMode": true, "enforceIntegrity": false }
  </script>

  <!-- The shim reads both native importmap and importmap-shim tags. -->
  <script type="importmap">
  { "imports": { "@company/ui-core": "/ui-core/1.4.2/index.js" } }
  </script>
</head>

With shimMode on, the shim takes over resolution everywhere, which gives you one consistent code path in every browser. The trade-off is a small amount of JavaScript on the critical path and slightly slower first resolution. Most teams run the shim only when feature detection reports no native support, and let native browsers stay native.

Integration with a host shell #

In a real deployment the map is not hand-edited HTML. The host shell builds it from a registry of remote and library versions and inlines it at request time.

// host/buildImportMap.js — runs on the server / edge at request time
export function buildImportMap(registry) {
  return {
    imports: {
      // Shared libraries: ONE url each, identical for every remote.
      react: registry.libs.react,
      "react-dom/client": registry.libs.reactDomClient,
      "@company/ui-core": registry.libs.uiCore,
      // Remotes: trailing-slash prefixes to map whole subtrees.
      ...Object.fromEntries(
        registry.remotes.map((r) => [`${r.name}/`, `${r.baseUrl}/`])
      ),
    },
  };
}
// host/render.js — inline the map ahead of the entry module
import { buildImportMap } from "./buildImportMap.js";

export function renderShell(registry) {
  const map = JSON.stringify(buildImportMap(registry));
  return `<!DOCTYPE html><html><head>
    <script type="importmap">${map}</script>
    <script type="module" src="/host/main.js"></script>
  </head><body><div id="root"></div></body></html>`;
}

Inlining the map (rather than referencing an external src) removes a render-blocking round trip and sidesteps a subtle ordering hazard: an external import-map script is fetched asynchronously, and any module that starts loading before it arrives will resolve against an empty table. Inline maps are always present at parse time.

The host then mounts remotes by importing through the mapped prefix. Because both host and remote resolve react to the identical URL, they share one React instance — so the shared providers in Alternatives to Prop Drilling in Distributed UIs work without extra wiring.

Worked example: a host loading a remote ESM module #

Here is the full round trip — a host that resolves a remote through the import map, loads its entry module, and hands it a mount point. The remote is a plain ES module with a named mount/unmount lifecycle so the host can tear it down cleanly on navigation.

// host/main.js — resolves "checkout/entry.js" via the inlined import map
const registry = new Map(); // remote name -> active instance

async function mount(remote, el) {
  // Resolves to https://remotes.example.com/checkout/v3/entry.js
  const mod = await import(/* @vite-ignore */ `${remote}/entry.js`);
  if (typeof mod.mount !== "function") {
    throw new Error(`Remote "${remote}" has no mount() export`);
  }
  const instance = await mod.mount(el, { user: window.__USER__ });
  registry.set(remote, instance);
}

async function unmount(remote) {
  const instance = registry.get(remote);
  await instance?.unmount?.();
  registry.delete(remote);
}

// preload the bytes early without executing, so click-to-render is instant
function preload(remote) {
  const link = document.createElement("link");
  link.rel = "modulepreload";
  link.href = new URL(`${remote}/entry.js`, location.href).href;
  document.head.appendChild(link);
}

preload("checkout");
await mount("checkout", document.getElementById("root"));
// remotes/checkout/entry.js — built independently, React externalised
import { createRoot } from "react-dom/client"; // resolved by the HOST's map
import { App } from "./App.js";

export async function mount(el, ctx) {
  const root = createRoot(el);
  root.render(App(ctx));
  return { unmount: () => root.unmount() };
}

The remote never imports React from its own bundle — react-dom/client is a bare specifier that resolves against the host’s import map, so host and remote land on the same instance. modulepreload warms the network without running the module, which keeps the singleton-sharing guarantee intact while shaving the time-to-interactive on the first render. Returning an unmount handle gives the host a clean lifecycle hook, the same contract you would build by hand on top of Dynamically Loading Remote Modules at Runtime.

Edge cases and failure modes #

Late injection silently no-ops. Append a <script type="importmap"> after a module has loaded and native browsers throw — or worse, ignore it. If you need a map after first paint (A/B-swapping a remote, say), you must use the shim’s runtime API; there is no native escape hatch.

// Runtime override — only works under es-module-shims.
importShim.addImportMap({
  imports: { "checkout/": "https://remotes.example.com/checkout/v4/" },
});
await importShim("checkout/entry.js");

CORS failures are invisible. ES modules are fetched with CORS semantics. A mapped URL on another origin without Access-Control-Allow-Origin fails as an opaque network error that never reaches a try/catch around your import(). Set permissive-but-scoped CORS headers on every module origin and verify them in CI, not in the browser console at 2am.

Specifier drift forks singletons. If one remote maps react to 18.3.1 and another resolves it through a scope to 18.2.0, you have two Reacts and two of every context. The fix is a single source of truth for library versions — the registry above — never per-remote maps that can drift.

Partial deploys leave a torn map. If the host inlines a map pointing at checkout/v4 but the v4 bundle hasn’t finished propagating to the CDN, every checkout import 404s. Treat the map flip as the last, atomic step of a deploy (see Deployment below).

Specificity surprises. "react" and "react-dom/" are distinct entries; a request for react-dom/client matches the trailing-slash entry, not the bare react. List the exact subpaths your code imports rather than assuming a prefix covers them.

Content Security Policy blocks inline maps. An inline <script type="importmap"> is still an inline script. A strict script-src without 'unsafe-inline' will silently drop it, and every bare specifier then throws. Allow the map by hashing it (script-src 'sha256-…' over the exact JSON, including whitespace) or by tagging it with the page nonce: <script type="importmap" nonce="…">. The hash is the more robust option because the map’s content is deterministic from your registry. Remember the modules themselves also need to clear CSP — add the CDN origin to script-src (and connect-src, since dynamic import() is fetched).

Dynamic import-map injection #

The native rule — one map, parsed before the first module — is absolute. There is no DOM API to merge a second native map after the loader has started. Two patterns cover the cases where you genuinely need late changes.

Build the whole map up front, defer the modules. If the map depends on data you only have client-side (an A/B bucket, a feature flag, a tenant), compute it in a tiny inline script that runs before any module script, inject the <script type="importmap"> element, and only then kick off the entry. The map is still native and still parsed once — you just delayed the first module by a microtask.

<head>
  <script>
    // Runs synchronously, before any <script type="module">.
    const bucket = document.cookie.includes("ab=v4") ? "v4" : "v3";
    const map = { imports: { "checkout/": `/checkout/${bucket}/` } };
    const s = document.createElement("script");
    s.type = "importmap";
    s.textContent = JSON.stringify(map);
    document.head.appendChild(s);
  </script>
  <script type="module" src="/host/main.js"></script>
</head>

Use the shim’s runtime API for true post-load swaps. Once the page is interactive and you want to repoint a remote without a reload, only es-module-shims can help. In shimMode it owns resolution end to end, so addImportMap extends the table for subsequent importShim() calls.

// Runtime override — only works under es-module-shims in shimMode.
importShim.addImportMap({
  imports: { "checkout/": "https://remotes.example.com/checkout/v4/" },
});
await importShim("checkout/entry.js"); // resolves against the merged map

The catch: a module already evaluated under the old URL stays cached at that URL. addImportMap affects future imports, not modules already in the registry. To actually replace a running remote you unmount the old instance, override the map, then importShim the new entry — a controlled swap, not a hot reload of the same module identity.

Combining with Native Federation #

The hand-rolled registry above is exactly what @angular-architects/native-federation automates while keeping native import maps as the runtime substrate. Despite the Angular-centric package name, its build plugin (@softarc/native-federation) is framework-agnostic and works with esbuild, Vite, or the Angular CLI. At build time it emits a remoteEntry.json per remote describing its exposed modules and shared dependencies; at startup the host calls initFederation(), which fetches those manifests, negotiates a compatible version of each shared library, and writes the resulting native import map into the document before any module runs.

// host bootstrap with native-federation
import { initFederation } from "@angular-architects/native-federation";

await initFederation({
  checkout: "https://remotes.example.com/checkout/remoteEntry.json",
  profile: "https://remotes.example.com/profile/remoteEntry.json",
});

// Now imports resolve through the generated import map.
const { mount } = await import("checkout/Widget");
mount(document.getElementById("root"));

This buys you the version-negotiation that raw import maps lack — two remotes asking for slightly different React ranges get reconciled to one shared copy — without giving up the browser-native loader or adopting webpack’s runtime. It is the closest thing to Module Federation’s developer experience that still ships standard ESM, which is why teams on Vite or esbuild reach for it. The tradeoffs against full federation and a plain CDN are laid out in Module Federation vs Import Maps vs ESM CDN.

Testing and validation #

Resolution logic deserves its own tests, separate from the components it loads. Run the shim in shimMode inside your test environment so the same resolver runs in CI and production.

// importMap.test.js
import { buildImportMap } from "../host/buildImportMap.js";

test("every remote resolves to an absolute https URL", () => {
  const map = buildImportMap(fixtureRegistry);
  for (const url of Object.values(map.imports)) {
    expect(url).toMatch(/^https:\/\//);
  }
});

test("react is mapped to exactly one url", () => {
  const map = buildImportMap(fixtureRegistry);
  const reactUrls = new Set(
    Object.entries(map.imports)
      .filter(([k]) => k === "react" || k.startsWith("react-dom"))
      .map(([, v]) => new URL(v).pathname.split("/")[2]) // version segment
  );
  expect(reactUrls.size).toBe(1);
});

Beyond unit tests, add a smoke check in CI that loads the rendered shell in a headless browser and asserts on resolution:

// playwright smoke
await page.goto("/");
const supportsNative = await page.evaluate(() =>
  HTMLScriptElement.supports?.("importmap")
);
expect(supportsNative).toBe(true);
// No failed module requests.
const failures = [];
page.on("requestfailed", (r) => failures.push(r.url()));
await page.waitForLoadState("networkidle");
expect(failures.filter((u) => u.endsWith(".js"))).toHaveLength(0);

Pinning module integrity #

Because the modules live on a CDN you may not fully control, verify their bytes. Native import maps gained an integrity key that maps a resolved URL to a Subresource Integrity hash; the loader rejects any module whose body doesn’t match before it executes.

<script type="importmap">
{
  "imports": {
    "react": "https://cdn.example.com/react/18.3.1/react.js"
  },
  "integrity": {
    "https://cdn.example.com/react/18.3.1/react.js":
      "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  }
}
</script>

For browsers predating the integrity key, fall back to <link rel="modulepreload" integrity="…"> for each module, or set enforceIntegrity: true in esms-options and let the shim do the checking. Either way, generate the hashes in your build from the exact published artifact and store them in the same registry that produces the map, so the integrity table and the URL table can never drift apart. A mismatch becomes a load error you can catch and report rather than a silent execution of tampered code.

Deployment #

The deployment contract is simple to state and easy to get wrong: modules are immutable and cached forever; the map is mutable and never cached.

Cache-Control for /react/18.3.1/react.js   →  public, max-age=31536000, immutable
Cache-Control for the inlined map (the HTML) →  no-cache, must-revalidate

Because the map is inlined in the host HTML, “publishing a new map” means publishing new HTML — which your HTML cache policy already controls. Ship modules first, confirm they are live on the CDN, then flip the map. Order matters:

  1. Upload checkout/v4/* to the CDN, fully propagated.
  2. Health-check the new module URLs directly.
  3. Update the registry so buildImportMap emits the v4 prefix.
  4. The next page render inlines the new map; old sessions keep running v3 until they reload.

This sequencing makes rollback trivial: revert the registry entry and the next render points back at v3, which never went anywhere. Pair the flip with a feature flag for canary cohorts and the rollout looks much like the one in Progressive Rollout with Feature Flags for Remotes.

Instrument the client to catch what static checks miss. A window error listener plus a sweep of resource timings surfaces 404s on unmapped specifiers and latency spikes on a slow CDN edge:

window.addEventListener("error", (e) => {
  if (e.target?.tagName === "SCRIPT") report("module_load_error", e.target.src);
}, true);

performance.getEntriesByType("resource")
  .filter((r) => r.name.endsWith(".js") && r.duration > 1000)
  .forEach((r) => report("slow_module", { url: r.name, ms: r.duration }));

Common pitfalls #

Issue Root cause & resolution
Runtime map injection does nothing Native browsers parse the map once before the first module. Inline the full map ahead of any module script; for post-load updates use es-module-shims and importShim.addImportMap.
Modules load twice, singletons fork A shared specifier resolves to two URLs (per-remote maps or stray scopes). Build the map from one registry so react et al. map to a single immutable URL everywhere.
import() rejects with an opaque network error Missing CORS headers on a cross-origin module. Add Access-Control-Allow-Origin to every module origin and assert it in CI.
New remote 404s right after deploy Map flipped before the bundle propagated. Upload and health-check modules first, flip the registry last; revert the registry to roll back.
Stale map keeps resolving old paths The HTML carrying the inline map was cached. Serve it no-cache, must-revalidate; keep immutable only on the versioned module files.
react-dom/client fails to resolve Subpath not listed. Trailing-slash prefixes don’t cover bare entries — list each exact subpath your code imports.
Inline import map ignored, every import throws A strict CSP script-src without 'unsafe-inline' drops the inline map. Authorise it with a sha256- hash of the exact JSON or a per-request nonce, and add the module CDN to script-src and connect-src.
addImportMap swap repoints nothing The remote was already evaluated and is cached at the old URL. Unmount the old instance first, then override the map and importShim the new entry.
Integrity check fails after a CDN change The hash in the map no longer matches the served bytes (a rebuild or recompression). Regenerate integrity hashes from the published artifact in the same build that writes the map.
Bare specifier throws TypeError, no network request The specifier isn’t in the map at all. Unmapped bare specifiers fail before fetching — add the entry or import a relative/absolute URL instead.

FAQ #

Do import maps replace Module Federation?

Not exactly — they replace a different layer. Federation gives you build-time sharing negotiation and version fallback logic; import maps give you a browser-native resolution table with none of that machinery. For independently built remotes that agree on a small set of shared library versions, import maps are lighter. When you need automatic semver negotiation across remotes, federation still does more. The Module Federation vs Import Maps vs ESM CDN guide compares them concretely.

How do I update a single remote without rebuilding the host?

Change one entry in the registry and re-render the host HTML — no host rebuild, no remote rebuild beyond the one that changed. Old sessions keep their map until reload; new sessions get the new URL. That single-line indirection is the whole point of the approach.

Can I mix import maps with a bundler?

Yes. A common setup bundles each remote internally with Vite or webpack but externals the shared libraries so they resolve through the page-level import map instead of being inlined. That keeps each remote’s build self-contained while still sharing one React instance across the page.

What’s the minimum browser support I can rely on?

Native import maps work in Chromium 89+, Firefox 108+, and Safari 16.4+, which is Baseline Widely Available as of 2026. The integrity key is newer (Chromium 127+, Firefox 138+), so gate that one feature carefully. For anything older, gate es-module-shims behind HTMLScriptElement.supports('importmap') so modern browsers stay on the native path and only legacy clients pay for the polyfill.

Is there a tool that gives me Module Federation ergonomics on top of import maps?

Yes — @angular-architects/native-federation (build core @softarc/native-federation, framework-agnostic) generates a native import map from per-remote manifests and negotiates shared-library versions at startup. You get version reconciliation and a federation-like API while the browser’s own ES module loader does the resolution. It is the usual choice for Vite or esbuild teams who want federation semantics without webpack’s runtime.