Using Import Maps as a Lightweight Federation Alternative #

You want one micro-frontend to load a component from another, and you want both to share a single copy of React — but you do not want to stand up a bundler federation runtime to do it.

Import maps solve exactly this. They let the browser resolve bare specifiers like react or @shared/ui/button.js to absolute URLs you control, so a host page and a remotely-served ES module can agree on where shared code lives without any build-time wiring between them. This guide walks through a minimal working setup: declare a map, shim older browsers, load a remote module over HTTP, and pin a single copy of a dependency that both apps import.

If you are still deciding whether this approach fits, the trade-off comparison between Module Federation, import maps, and an ESM CDN covers when each one wins. For teams that have outgrown the native loader, configuring Webpack Module Federation is the heavier alternative.

Import map resolution flow The host page and a remote module both import the bare specifier react, which the import map resolves to a single shared CDN URL. Host page import "react" import "remote/App.js" Remote module import "react" Import map bare to URL resolution Shared react one CDN copy Remote App.js remote origin
Both apps import the same bare specifier; the import map collapses them onto one shared URL.

Prerequisites #

Step 1 — Publish the remote as an ESM module #

Before the host can load it, the remote has to ship as a native ES module with its shared dependencies left external — meaning the build emits import 'react' rather than inlining a copy. Bundlers default to inlining everything, which is exactly what produces duplicate frameworks.

With Vite, set the library entry and externalize the shared packages so they stay as bare imports in the output:

// vite.config.ts (remote app)
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    lib: {
      entry: 'src/App.tsx',
      formats: ['es'],          // emit ESM only — no UMD, no IIFE
      fileName: () => 'App.js',
    },
    rollupOptions: {
      // Do NOT bundle these; leave them as `import 'react'` for the map to resolve.
      external: ['react', 'react-dom', 'react-dom/client'],
    },
  },
});

Build it (vite build) and serve dist/App.js from the remote origin. Inspect the emitted file: the top of App.js should literally contain import React from "react";. If you instead see a few thousand lines of inlined React, the external array is not taking effect and the host will end up with two copies. Once the remote serves a clean ESM build, the host’s import map governs the whole graph.

Step 2 — Declare a static import map #

Place a single <script type="importmap"> in the host <head>, before any type="module" script runs. The browser reads exactly one import map, and it must be parsed before the first module import.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <script type="importmap">
  {
    "imports": {
      "react": "https://cdn.example.com/[email protected]/esm/react.js",
      "react-dom/client": "https://cdn.example.com/[email protected]/esm/client.js",
      "remote/App": "https://remote.example.com/assets/App.js",
      "@shared/ui/": "https://cdn.example.com/[email protected]/"
    }
  }
  </script>
</head>
<body>
  <div id="root"></div>
  <script type="module" src="/host.js"></script>
</body>
</html>

Two specifier shapes matter here. A plain key like react maps one module exactly. A key ending in /, like @shared/ui/, is a prefix map: any import starting with @shared/ui/ resolves against the URL on the right, so @shared/ui/button.js becomes https://cdn.example.com/[email protected]/button.js.

Step 3 — Shim browsers without native support #

For browsers that lack native import map support — or whenever you need to construct the map at runtime — load es-module-shims. It polyfills resolution by intercepting modules tagged type="module-shim" and reading maps tagged type="importmap-shim".

The cleanest approach uses feature detection so native browsers pay nothing:

<script>
  // Skip the shim entirely if the browser resolves import maps natively.
  if (!HTMLScriptElement.supports || !HTMLScriptElement.supports('importmap')) {
    const shim = document.createElement('script');
    shim.async = true;
    shim.src = 'https://cdn.example.com/[email protected]/es-module-shims.js';
    document.head.appendChild(shim);
  }
</script>
<script type="esms-options">
  { "shimMode": false, "enforceIntegrity": false }
</script>

With shimMode: false, es-module-shims reads your standard type="importmap" and type="module" tags, so the markup from Step 2 needs no changes. Native browsers ignore the shim; older ones get full resolution.

Ordering is the whole game here. The shim must be parsed before the first type="module" script executes, otherwise the native loader will already have rejected your bare specifiers and there is nothing left to polyfill. Keep the feature-detection block and the esms-options tag in the <head>, above the module entry in the <body>.

Step 4 — Load a remote ESM module #

In the host entry file, import the remote by the bare specifier you declared. The browser fetches it over HTTP at runtime — there is no container handshake and no init() call.

// host.js
import React from 'react';
import { createRoot } from 'react-dom/client';

// Resolved by the import map to https://remote.example.com/assets/App.js
const { default: RemoteApp } = await import('remote/App');

const root = createRoot(document.getElementById('root'));
root.render(React.createElement(RemoteApp));

Because the remote is loaded lazily, wrap the import() so a failed fetch degrades gracefully instead of taking down the host shell — the remote lives on a different origin and may be deploying or briefly unreachable:

async function mountRemote() {
  try {
    const { default: RemoteApp } = await import('remote/App');
    createRoot(document.getElementById('root')).render(React.createElement(RemoteApp));
  } catch (err) {
    // Remote unreachable or failed to resolve — render a fallback, not a blank page.
    document.getElementById('root').textContent = 'This section is temporarily unavailable.';
    console.error('Remote load failed:', err);
  }
}
mountRemote();

The remote module itself imports its dependencies by the same bare names. Because the host’s map governs the whole module graph, the remote does not bundle React — it just asks for it:

// App.js (served from remote.example.com)
import React from 'react';

export default function RemoteApp() {
  // This React is the host's React, resolved through the import map.
  return React.createElement('p', null, 'Hello from the remote module');
}

Step 5 — Share a single dependency copy via the map #

The whole point of this setup is deduplication. Because the import map is global to the document, every module that imports react resolves to the same URL — and the browser fetches and evaluates that URL exactly once, caching the module record.

To guarantee a single instance, the map must contain one and only one entry per shared package. Avoid letting any remote ship its own copy:

{
  "imports": {
    "react": "https://cdn.example.com/[email protected]/esm/react.js",
    "react-dom": "https://cdn.example.com/[email protected]/esm/react-dom.js",
    "react-dom/client": "https://cdn.example.com/[email protected]/esm/client.js"
  }
}

Pin exact versions (with the version in the URL path or a content hash) so every deploy resolves identically. Configure remote builds to treat shared packages as external so they emit import 'react' rather than inlining a bundled copy. With Vite this is build.rollupOptions.external; with Rollup directly it is the external array. The result: hooks, context, and instanceof checks all work across app boundaries because there is genuinely one module instance. For the deeper rationale on why duplicate copies break React, see resolving version conflicts in shared React libraries.

Verification #

Open the host page and confirm resolution worked end to end.

  1. Network panel — In Chrome DevTools, filter by react.js. You should see it requested once, served from the CDN URL in your map with 200 (or 304 on reload). A second copy means a remote is bundling its own React.

  2. Console identity check — Confirm the shared instance is truly shared. From the host, expose React and compare it inside the remote, or assert in a quick test:

import React from 'react';
// In a browser console after load, both modules logged the same object reference.
console.assert(window.__hostReact === React, 'React instance is shared');
  1. Resolution log — A successful remote import resolves the bare specifier. If you log the resolved URL, it should match the map exactly:
const url = import.meta.resolve('remote/App');
console.log(url); // https://remote.example.com/assets/App.js
  1. Inspect the parsed map — In Chrome DevTools, open Application → Frames → top → Import map (or run JSON.parse(document.querySelector('script[type=importmap]').textContent) in the console). The browser shows the single map it honored. If you declared two <script type="importmap"> tags, only the first is listed — the rest are silently ignored, which is a common source of “works locally, breaks in prod” resolution failures.

A working setup shows one network request per shared dependency, identical module references across apps, a single honored import map, and the remote rendering without bundling its own framework copy.

Troubleshooting #

Symptom: Uncaught TypeError: Failed to resolve module specifier "react". The browser does not support import maps natively and the shim is not active. Diagnosis: check HTMLScriptElement.supports('importmap') in the console — if it returns false or is undefined, the native loader is rejecting your bare specifiers. Fix: ensure es-module-shims actually loaded before your module scripts. In shimMode: false it upgrades standard tags, but it must be present in the document head ahead of the first type="module" execution.

Symptom: hooks throw “Invalid hook call” or context is empty across the boundary. Two copies of React are loaded. Diagnosis: in the Network panel you will see react.js fetched from two different URLs (often one from the CDN, one bundled into the remote). Fix: remove the duplicate by marking react and react-dom as external in every remote’s build config so they import the bare specifier, and confirm the map has exactly one entry per package version.

Symptom: Failed to resolve module specifier "@shared/ui/button.js". Relative references must start with "/", "./", or "../". A bare specifier is not covered by the map. Diagnosis: the import key has no matching entry — usually a missing trailing-slash prefix entry, so @shared/ui/... paths fall through. Fix: add a prefix mapping ("@shared/ui/": "https://.../[email protected]/") rather than mapping each file individually, and remember the import map must be the first one parsed — only one map is honored.

Symptom: Access to script ... has been blocked by CORS policy when fetching the remote module. The remote origin or CDN does not allow cross-origin module loading. Diagnosis: the request shows a CORS error and no usable response in DevTools. Fix: serve remote modules with Access-Control-Allow-Origin covering the host origin (or * for public assets), and ensure the response Content-Type is text/javascript — some servers send application/octet-stream, which browsers refuse to execute as a module.