Skip to content
Back to the Lab

Import Maps Bring Bare Specifiers to the Browser

A deep technical guide to import maps, covering why the browser could never resolve a bare module specifier on its own, writing an imports and scopes map, path prefix mappings, verifying module integrity, where the map has to live in a document, what it does not do, and browser support.

A dark Egnworks banner representing a browser resolving a bare module name through a JSON map.

Every npm package written to be imported by name assumes a resolver sitting between the source code and the module it actually loads, import { debounce } from "lodash-es" means nothing to a browser on its own, since the browser has never accepted anything as a module specifier except an actual URL or a relative path. That resolution step, turning a bare package name into a real location, has always been a bundler’s job. Import maps move it into the browser itself.

Why the Browser Never Understood a Bare Specifier#

Native ES modules require an import specifier to be either an absolute URL or a path that begins with /, ./, or ../. import "lodash-es" was never valid on its own, since lodash-es is neither, which is exactly the syntax every published npm package uses without a second thought. A bundler such as Webpack or Rollup fixed this by rewriting every bare specifier at build time into a real path pointing at a bundled file, work that had to happen before the code ever reached a browser, because the browser itself had no concept of a package name resolving to anything.

Writing an Import Map#

An import map is a block of JSON inside a <script type="importmap"> element, mapping bare specifier text directly to a URL the browser fetches instead.

<script type="importmap">
{
  "imports": {
    "lodash-es": "https://esm.sh/lodash-es@4.17.21"
  }
}
</script>

<script type="module">
  import { debounce } from "lodash-es";
</script>

From that point on, any import statement or dynamic import() call anywhere in the page can use lodash-es as a specifier, and the browser resolves it through the map instead of failing to parse it.

Path Prefixes for a Whole Directory#

A trailing slash on both the key and the value maps an entire directory of specifiers at once, rather than listing every file individually.

<script type="importmap">
{
  "imports": {
    "components/": "/src/components/"
  }
}
</script>

import Button from "components/button.js" resolves through this to /src/components/button.js, and any other file added under that directory later needs no further entry in the map to become importable the same way.

Scoping a Mapping to Part of the Application#

The top level imports map applies everywhere, but scopes overrides that mapping for code loaded from under a specific path, useful when different parts of an application genuinely need different versions of the same dependency.

<script type="importmap">
{
  "imports": {
    "square": "/modules/shapes/square-v2.js"
  },
  "scopes": {
    "/legacy/": {
      "square": "/modules/shapes/square-v1.js"
    }
  }
}
</script>

A module importing square from anywhere under /legacy/ resolves to the older version, while the same specifier everywhere else in the application resolves to the current one. When more than one scope could apply, the most specific matching path wins.

Verifying Integrity Without a Bundler#

A bundled dependency’s integrity is normally whatever the build pipeline already verified when it installed the package. An import map pointing directly at a remote URL has no equivalent step unless one is added explicitly, which is what the integrity field provides.

<script type="importmap">
{
  "imports": {
    "square": "/modules/shapes/square.js"
  },
  "integrity": {
    "/modules/shapes/square.js": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  }
}
</script>

The browser refuses to execute the fetched module if its contents do not match the given hash, the same protection subresource integrity already provides for an ordinary <script src>, applied here at the level of a single mapped module.

Where the Import Map Has to Live#

An import map must appear inline, in a <script type="importmap"> element with no src attribute, and it must be declared before any module script that depends on the specifiers it defines. There is no supported way to load an import map from an external file, and multiple import map elements on the same page merge together into one combined map rather than the later one replacing the earlier one entirely.

What an Import Map Does Not Do#

An import map resolves specifiers, it does not bundle anything. Every mapped module is still fetched as its own individual network request, there is no minification, tree shaking, or code splitting happening anywhere in the process, since none of that requires a resolver, it requires an actual build step operating on the source. A project depending heavily on many small, individually fetched modules in production may still want a bundler for exactly those optimizations, even after adopting import maps to avoid needing one during development.

It also only affects import statements and import() expressions. An ordinary <script src="lodash"> tag is unaffected by any import map on the page, since that attribute was never a module specifier in the first place.

A Practical Example#

A small page can load a CDN hosted dependency by its published package name, with no local node_modules and no build step of any kind.

<script type="importmap">
{
  "imports": {
    "date-fns": "https://esm.sh/date-fns@3.6.0"
  }
}
</script>

<script type="module">
  import { formatDistanceToNow } from "date-fns";

  document.body.textContent = formatDistanceToNow(new Date("2026-01-01"));
</script>

Every part of this runs as plain, unbundled ES modules, the browser fetches date-fns directly from the mapped URL the same way it would fetch any other script, resolved through the map rather than through a bundler’s dependency graph.

Browser Support#

Import maps are supported in Chrome, Edge, Firefox, and Safari, and count as broadly available today. For a project that still needs to support an older browser version, the es-module-shims polyfill implements the same behavior in JavaScript, adding a small amount of overhead only on browsers that actually need it.

Conclusion#

Import maps take the one thing a bundler was doing purely as a resolution step, turning "lodash-es" into an actual URL, and hand it to the browser directly through a JSON map declared once in the document. imports covers the common case, scopes handles a dependency that needs to differ by part of the application, and integrity gives a directly fetched module the same verification a bundled one already had. What a bundler still does beyond resolution, bundling, minification, and code splitting, remains a separate concern, one a project can keep or drop independently of whether it still needs a bundler just to make import "some-package" work at all.

References#

MDN: script type=“importmap”

MDN: JavaScript Modules

Can I Use: Import Maps

GitHub: es-module-shims