Micro-frontends via Import Maps: A Native ES Modules Guide
The core issue with MFEs is usually twofold: discovery (finding the right piece for the current URL) and dependency management (avoiding shipping five copies of React). While tools like Module Federation solve this, they often tie you to specific bundler versions. Import maps solve this by acting as a browser-native lookup table.
The Mechanics of Native Lookup
An import map is simply a JSON object inside a <script type="importmap"> tag. It tells the browser: "When you see this module specifier, fetch it from this specific URL."
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/[email protected]",
"react-dom/client": "https://esm.sh/[email protected]/client",
"@fastflights/search/": "https://assets.fastflights.com/search/"
}
}
</script>When your application code executes import React from "react", the browser doesn't panic or look for a node_modules folder; it checks the map, sees the esm.sh URL, and performs a standard HTTP request. This eliminates the need for a custom module loader or iframes.
Implementing a Low-Config Workflow
Manually maintaining a JSON map across dozens of micro-apps is a recipe for disaster. To make this production-ready, you need a manifest-driven approach. I've been using a pattern where a single configuration object generates the required maps and handles peer dependencies.
Here is a concrete example of how you define the system boundaries using a manifest:
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"],
});By centralizing the versions in this manifest, you ensure that every micro-app is referencing the exact same version of a shared library. This effectively turns the browser into your orchestrator.
Real-World Performance Gains
Switching from a heavy bundler-based composition to native ESM provides a noticeable shift in the development loop.
- Build Output: Since the browser handles the resolution, your build artifacts are smaller. You aren't bundling "shared" dependencies into every single MFE chunk.
- Deployment: Promoting a change becomes a simple matter of updating the import map pointer. You don't need to re-build the "shell" application to include a new version of a remote module.
- Developer Experience: You can actually inspect the network tab and see exactly which version of a library is being requested via the URL, rather than digging through a minified
vendor.jsbundle.
The "Gotchas" and Fixes
One specific issue I encountered was with trailing slashes in import maps. If you map @scope/app to https://cdn.com/app, the browser treats it as a literal string. If you want to import sub-paths (like @scope/app/Component.js), you must use a trailing slash in both the key and the value:
Wrong: "@scope/app": "https://cdn.com/app"
Right: "@scope/app/": "https://cdn.com/app/"
Without that trailing slash, any attempt to import a nested file within that namespace will result in a 404 or a resolution error because the browser won't treat it as a directory prefix.
For those looking for a full implementation, the rmc-toolkit provides a structured way to handle this. You can find more details at the following documentation page:
https://runtime-module-composition.dev