JavaScript Import Maps Table
| Piece | What it does | Field note |
|---|---|---|
<script type=importmap> | The resolution map | JSON BEFORE any module loads - must precede module scripts |
imports: { "lodash": url } | Bare specifier alias | import 'lodash' resolves to YOUR pinned file - no bundler needed |
paths with trailing slash | Folder mapping | "utils/": "/js/utils/" - the import 'utils/date' prefix trick |
versions by rewrite | CDN pinning | Map the name to a FULL URL with the version - dependencies become visible |
scopes | Per-folder overrides | Different mappings under /legacy/ vs /new/ - migration tooling |
One map per document | Single importmap rule | Duplicate or late importmap = console error, modules resolve once |
Dynamic import also maps | import() obeys too | Same resolution for static and dynamic - no bypass |
Fallback via import maps? | No - one name, one map | Resolution is a lookup, not a try-chain - loaders layer above |
Import maps let the BROWSER resolve bare specifiers: a JSON map placed before any module script teaches import 'lodash' to load your pinned URL - dependency management without a bundler, with every mapping visible in one place in the HTML.
Bottom line: the map must precede all module scripts (resolution happens once per document), trailing-slash keys create folder prefixes ('utils/' maps every import under it), scopes override mappings per path (the migration tool), and dynamic import() obeys the same map - there is no bypass.
The honest part: import maps are RESOLUTION, not installation - no dedupe, no lockfile algebra, no transforms. One name maps to one URL. For a handful of dependencies pinned by version, that is exactly enough; for version solving at scale, a bundler still earns its keep.
How to use
- Alias the dependency: <script type=importmap>{"imports": {"lodash": "/js/[email protected]"}}</script> before any module - bare 'lodash' now resolves.
- Prefix whole folders: "utils/": "/js/utils/" maps import 'utils/date' to /js/utils/date.js - internal imports stay clean when files move.
- Override per path: scopes { "/legacy/": { "dep": "/old/dep.js" } } - two dependency versions coexist while migrating directory by directory.
Frequently asked questions
What problem do import maps actually solve for browsers?
Bare specifiers. The ES module spec allows import 'lodash' - but the browser has no node_modules to search, no package.json to consult, so bare specifiers were historically unusable: every import needed a full relative or absolute URL, which made real dependency trees hostile without a bundler rewriting paths. The import map is the missing lookup table: the mapping lives in the document, the browser resolves exactly like a bundler would, and dependencies become VISIBLE - which version, from which URL, in one JSON block. For sites shipping a few pinned dependencies without a build step, it closes the gap that kept bundlers mandatory.
Why must the import map appear before any module script?
Resolution happens once, at module-load time. The browser resolves each specifier when the importing module is evaluated, and it consults the map exactly once per document - a map that arrives after modules started loading is ignored with a console error, and a SECOND import map throws. Practically: the importmap script tag sits at the top of head, before any type=module script or dynamic import call. There is no 'update the map later' - resolution rules are frozen for the document's lifetime, which is also why scoped overrides exist instead of runtime mutation: path-dependent mapping is expressed declaratively, not patched imperatively.
How do scopes support multi-version dependencies?
Per-path overrides. A scope key is a URL prefix; imports from modules under that path resolve using that scope's mappings first. The migration pattern: /legacy/ modules keep mapping 'utils' to old-utils@3 while /new/ modules resolve 'utils' to utils@4 - both versions load in one app, neither knows about the other, and the old tree dies directory by directory until the scope is deleted. This is version coexistence without a bundler's dedupe machinery - and also its limitation: nothing stops the two versions from being loaded simultaneously (double bytes, double state), which is precisely what bundlers dedupe and import maps deliberately do not.
When is an import map the wrong choice versus a bundler?
At dependency-algebra scale. One name maps to ONE url: no deduplication (two deps requiring different versions of a shared lib load both), no lockfile (version pinning is by hand in the map), no transforms (TypeScript, JSX and minification are out of scope - ship what you serve), and every dependency change is an HTML edit. The sweet spot: up to a handful of dependencies, CDN-pinned by exact version, no build step wanted - tool sites, prototypes, progressive enhancement. The bundler line: transpilation needs, dependency graphs with shared sub-dependencies, or teams needing reproducible installs. The middle path exists too: bundlers EMIT import maps when you want their resolution without their module wrapping.