JavaScript Import Maps Table

PieceWhat it doesField note
<script type=importmap>The resolution mapJSON BEFORE any module loads - must precede module scripts
imports: { "lodash": url }Bare specifier aliasimport 'lodash' resolves to YOUR pinned file - no bundler needed
paths with trailing slashFolder mapping"utils/": "/js/utils/" - the import 'utils/date' prefix trick
versions by rewriteCDN pinningMap the name to a FULL URL with the version - dependencies become visible
scopesPer-folder overridesDifferent mappings under /legacy/ vs /new/ - migration tooling
One map per documentSingle importmap ruleDuplicate or late importmap = console error, modules resolve once
Dynamic import also mapsimport() obeys tooSame resolution for static and dynamic - no bypass
Fallback via import maps?No - one name, one mapResolution is a lookup, not a try-chain - loaders layer above
Reference: the MDN import maps reference. 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, visible in one place. Bottom line: the map must precede all module scripts (resolution happens once), trailing-slash keys create folder prefixes, scopes override mappings per path for migrations, and dynamic import() obeys the same map. The honest boundary: it is resolution, not installation - no dedupe, no lockfile, no transform; for version algebra at scale, a bundler still earns its keep. Related tools: web components table (the no-build siblings), fetch table (where the mapped URLs load from), and closure table (module state without a bundler's scope tricks).

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

  1. Alias the dependency: <script type=importmap>{"imports": {"lodash": "/js/[email protected]"}}</script> before any module - bare 'lodash' now resolves.
  2. Prefix whole folders: "utils/": "/js/utils/" maps import 'utils/date' to /js/utils/date.js - internal imports stay clean when files move.
  3. 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.

Related tools