from bare specifiers to multi version scopes
Import Maps solve a problem ES modules have had in the browser since day one: a bare specifier such as import { render } from "preact" simply does not resolve natively unless something rewrites the path first. With an Import Map the browser itself carries out that resolution, and scopes even allow different versions for different parts of the same application, with no Webpack or Vite required.
Table of contents
- 1. The problem Import Maps actually solve
- 2. Basic syntax: the importmap script element
- 3. Resolving bare specifiers: from package name to URL
- 4. Scopes: different versions for different module areas
- 5. Combining Import Maps with CDN modules
- 6. Integrity and security for third party sources
- 7. Browser support and the es-module-shims fallback
- 8. Import Maps in production: cache busting and generation
- 9. Import Maps versus bundler aliasing
- 10. Summary
- 11. FAQ
1. The problem Import Maps actually solve
Native ES modules in the browser only understand two kinds of import paths: relative paths like ./module.js and absolute URLs like https://example.com/module.js. A so called bare specifier, meaning import { render } from "preact" with no path at all, simply does not work without help. That single restriction is the main reason every frontend project used to need a bundler by default, even when the application itself never required code splitting or tree shaking. Import Maps close that gap directly inside the browser engine.
An Import Map is essentially a JSON structure that tells the browser how to resolve bare specifiers into actual URLs. Instead of a bundler rewriting every import path at build time, the browser performs that resolution at runtime, right when it parses the module. That makes Import Maps especially attractive for projects that deliberately want to skip a build step: internal admin tools, prototypes, but also production code that relies on HTTP/2 multiplexing instead of one giant bundle.
An important distinction: Import Maps do not replace a fully fledged bundler with tree shaking, minification or code splitting. They solve exactly one problem, the mapping question of where a module name actually points. Teams that still need transpilation for older browsers or aggressive bundle optimization often combine Import Maps with a lightweight build step anyway, without giving up control over the actual module resolution.
2. Basic syntax: the importmap script element
An Import Map is included via a <script type="importmap"> element, which must appear before the first <script type="module"> in the document. The browser reads the map as soon as the document is parsed and applies it to every module import that follows. There can only be one Import Map per document, multiple importmap elements trigger an error, a deliberate design decision against conflicting mappings.
The basic structure consists of an imports key, an object that maps specifiers to URLs. A trailing slash in the specifier tells the browser that everything after it should be appended to the target URL, known as prefix mapping and particularly useful for entire package directories with several submodules.
<!-- The import map must sit BEFORE the first module script -->
<script type="importmap">
{
"imports": {
"preact": "/vendor/preact/preact.module.js",
"preact/hooks": "/vendor/preact/hooks.module.js",
"lodash-es/": "/vendor/lodash-es/",
"@app/": "/src/app/"
}
}
</script>
<script type="module">
// Bare specifier resolved through the import map
import { h, render } from "preact";
import { useState } from "preact/hooks";
// Prefix mapping: everything after "lodash-es/" gets appended
import debounce from "lodash-es/debounce.js";
// Custom alias prefix for application code
import { initRouter } from "@app/router.js";
render(h("div", null, "Hello Import Map"), document.body);
</script>
A common beginner mistake is forgetting the trailing slash. "lodash-es": "/vendor/lodash-es/" without a slash in the key only maps the exact specifier lodash-es, not lodash-es/debounce.js. Only "lodash-es/": "/vendor/lodash-es/" with a slash on both sides activates prefix behavior and allows any submodule from the same package.
3. Resolving bare specifiers: from package name to URL
The core function of an Import Map is resolving specifiers into concrete module URLs, and the resolution algorithm follows a clear priority: exact matches in the imports object always win over prefix matches. If there is both an entry for "preact" and for "preact/", the exact specifier preact uses the specific entry, while preact/compat is resolved through the prefix. This precedence makes Import Maps predictable even when several rules could theoretically apply.
For TypeScript projects that work without a bundler, combining Import Maps with native type checking is particularly practical: the TypeScript compiler does not know about import maps directly, but the paths mapping in tsconfig.json can mirror the same structure, keeping editor autocompletion and runtime resolution consistent. That avoids the usual gap between what the editor shows and what the browser actually loads.
Another practical use case: several teams in a large frontend share the same specifier for internal libraries, say @shared/ui. Without Import Maps, every module would need to know the full path and update it on any restructuring. With a central Import Map, a single change in one place is enough, every module importing @shared/ui automatically follows the new location.
4. Scopes: different versions for different module areas
The scopes key is the most powerful feature of Import Maps and solves a problem classic bundler configurations only handle through nested node_modules directories: different module versions for different parts of the same application. A scope is bound to a URL path prefix, and inside that prefix its own mapping rules override the global imports entries.
The classic scenario: a legacy widget under /widgets/legacy/ strictly needs an older version of a utility library, while the rest of the application has already migrated to the new major version. Without scopes, both versions would need separate specifiers, complicating the code at every import site. With a scope, the legacy widget imports the same specifier as the rest of the app but automatically receives the matching URL.
<script type="importmap">
{
"imports": {
"date-utils": "/vendor/date-utils/v3/index.js"
},
"scopes": {
"/widgets/legacy/": {
"date-utils": "/vendor/date-utils/v1/index.js"
}
}
}
</script>
<script type="module">
// Outside /widgets/legacy/: v3 is loaded
import { formatDate } from "date-utils";
</script>
<!-- widgets/legacy/panel.js runs inside the /widgets/legacy/ scope -->
<!-- there "date-utils" is automatically resolved to v1 -->
Scopes only apply to modules whose own URL lies inside the scope prefix, not to modules imported from there that live outside it themselves. This subtlety often causes confusion: a module at /widgets/legacy/panel.js that in turn imports a module from /vendor/shared.js uses the global imports rules again for that second import, not the scope panel.js itself lives in. Multiple nested scopes are resolved by prefix specificity, the longest matching prefix wins.
5. Combining Import Maps with CDN modules
ESM CDNs such as esm.sh, jspm.io or unpkg.com already serve npm packages as native ES modules, dependencies resolved included. Combined with Import Maps, this produces a fully functional frontend without any local node_modules and without a build step at all: the Import Map points at the CDN URLs, the browser loads the rest itself.
The big advantage over hardcoded CDN URLs in the code is decoupling: application code still imports "react", not "https://esm.sh/react@18.3.1". A version bump or a CDN provider switch means a single change in the Import Map, not in every single file that imports React. That is exactly the same decoupling you know from package.json in a Node project, just without a package manager.
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1",
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client",
"zustand": "https://esm.sh/zustand@4.5.2"
}
}
</script>
<script type="module">
import { createElement as h } from "react";
import { createRoot } from "react-dom/client";
import { create } from "zustand";
const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
createRoot(document.getElementById("app")).render(
h("button", { onClick: useStore.getState().increment }, "Click me")
);
</script>
For production applications it is worth strictly pinning CDN versions, never using @latest. A CDN update outside your own control should never unexpectedly hit production. Some teams additionally mirror CDN modules onto their own infrastructure to keep availability and latency under their own control, while the Import Map itself stays unchanged.
6. Integrity and security for third party sources
As soon as an Import Map points at foreign domains, subresource integrity becomes relevant. Newer iterations of the specification support an integrity object in Import Maps that stores a hash per URL, which the browser checks before execution. If the actually delivered content deviates from the hash, say because a CDN got compromised, the browser refuses to load the module instead of silently executing tampered code.
Content Security Policy adds another layer: a script-src directive that does not explicitly allow external domains also blocks CDN modules loaded through the Import Map. Teams running strict CSP rules should whitelist the actually used CDN domains explicitly rather than relying on unsafe-inline or an overly broad wildcard rule. This combination of integrity hashes and CSP makes Import Maps viable even for security critical applications, as long as both mechanisms are applied consistently.
7. Browser support and the es-module-shims fallback
Chrome and Edge support Import Maps since version 89, Firefox since version 108 and Safari since version 16.4, with scopes and the integrity field arriving somewhat later in some browsers than the base feature. For applications that still need to support older browser versions, the well maintained polyfill es-module-shims replicates Import Maps in plain JavaScript, including scopes and dynamic import().
The polyfill works by fetching modules itself, analyzing the source and rewriting specifiers before execution. That costs some performance compared to the native browser implementation, but is unproblematic for most applications as long as the polyfill is only loaded where it is actually needed. A feature test before loading the polyfill prevents unnecessary overhead in modern browsers.
<!-- Only load the polyfill when native import maps are missing -->
<script>
if (!HTMLScriptElement.supports || !HTMLScriptElement.supports("importmap")) {
document.write(
'<script async src="https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js"><\/script>'
);
}
</script>
<script type="importmap">
{
"imports": {
"preact": "https://esm.sh/preact@10.22.0"
}
}
</script>
<!-- type="module-shim" instead of "module" guarantees polyfill behavior -->
<script type="module-shim">
import { h, render } from "preact";
render(h("p", null, "Works with and without native import maps"), document.body);
</script>
Order matters here: the feature test must run before the importmap script, otherwise the browser loads the polyfill too late, after an error has already occurred while resolving a module. Some teams deliberately skip the feature test and always load es-module-shims to avoid branching logic in the HTML, which costs unnecessary load time in modern browsers though.
8. Import Maps in production: cache busting and generation
In production, an Import Map should not be maintained by hand once more than a handful of modules are involved. A small build script that scans directories and automatically generates the Import Map prevents stale entries and makes version changes reproducible. For cache busting, content hashes get embedded directly into the target URLs of the Import Map, exactly the way bundler output filenames carry a hash.
// generate-importmap.mjs — builds importmap.json from a manifest
import { createHash } from "node:crypto";
import { readFile, writeFile, readdir } from "node:fs/promises";
import path from "node:path";
const VENDOR_DIR = "./public/vendor";
async function hashFile(filePath) {
const content = await readFile(filePath);
return createHash("sha256").update(content).digest("hex").slice(0, 8);
}
async function buildImportMap() {
const entries = await readdir(VENDOR_DIR, { withFileTypes: true });
const imports = {};
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".js")) continue;
const fullPath = path.join(VENDOR_DIR, entry.name);
const hash = await hashFile(fullPath);
const specifier = entry.name.replace(/\.js$/, "");
// Cache-busting hash in the served URL, not the local filename
imports[specifier] = `/vendor/${entry.name}?v=${hash}`;
}
const importMap = { imports };
await writeFile("./public/importmap.json", JSON.stringify(importMap, null, 2));
console.log(`Generated import map with ${Object.keys(imports).length} entries`);
}
buildImportMap();
This generated Import Map can be fetched and inserted dynamically as a script element before the first modular script runs, or injected directly into the HTML on the server. For the server side variant a template system that reads the JSON file at build time and embeds it inline works well, saving one network round trip on first page load and being the recommended approach in practice.
9. Import Maps versus bundler aliasing
Bundlers like Webpack or Vite have solved the mapping problem for years through alias configurations, but the fundamental difference lies in when resolution happens: bundlers rewrite import paths at build time, Import Maps resolve them at runtime in the browser. That has direct consequences for development speed, debugging and deployment flexibility.
| Criterion | Bundler aliasing | Import Maps | Practical note |
|---|---|---|---|
| Resolution timing | Build time, statically wired | Runtime, in the browser | Change import maps without rebuilding |
| Build step required | Yes, always | Optional | Import maps fit build free setups |
| DevTools debugging | Source maps needed for original code | Original files visible directly | No mapping overhead while debugging |
| Multi version support | Nested node_modules | Scopes, explicitly configurable | Scopes are more explicit than resolution algorithms |
| Tree shaking | Yes, automatic | No, not included | For large apps a bundler still makes sense |
In practice the decision is rarely binary: many teams use Import Maps for vendor dependencies and a lightweight bundler exclusively for their own application code, where tree shaking has the biggest effect. This hybrid strategy combines the advantages of both approaches without having to fully commit to either side.
Mironsoft
Modern JavaScript tooling and frontend architecture
Less build complexity, more native browser features?
We assess where Import Maps can slim down your build step and set up CDN modules, scopes and fallback strategies for production ready setups.
Architecture review
Assessing where Import Maps simplify your build process
Migration
Gradual move to native modules without risk to production
Tooling setup
Automated import map generation with cache busting for your deployment
10. Summary
Import Maps solve one very specific problem: bare specifiers that native ES modules in the browser cannot understand without help. Through a <script type="importmap"> element you point specifiers at concrete URLs, with a trailing slash even at entire package prefixes. Scopes allow different module versions for different areas of the same application, a problem classic bundlers only solve through nested directory structures. Combined with ESM CDNs, Import Maps enable fully functional frontends without any local build step.
For production systems, strict version pinning, subresource integrity for third party sources and an automated script to generate the Import Map instead of manual maintenance are recommended. Where older browsers still need support, es-module-shims reliably closes the gap. Import Maps do not replace a fully fledged bundler with tree shaking, but for many projects they are the simpler, more direct way to ship modern JavaScript modules.
Import Maps — the essentials at a glance
Core principle
<script type="importmap"> maps bare specifiers to URLs, resolved at runtime directly in the browser.
Scopes for multi version
Path bound rules override global mappings, ideal for legacy widgets with old dependencies.
Security
Integrity hashes and a matching CSP directive protect against tampered CDN content.
Fallback
es-module-shims replicates import maps in JavaScript for older browsers, loaded only when a feature test requires it.