JavaScript Import Maps: Resolving Bare Specifiers

Import maps are a native web standard that enables browsers to map module specifiers to specific URLs or paths, allowing developers to use bare module specifiers directly in client-side JavaScript without a bundler. This article covers what import maps are, the problem of bare specifiers in native ES modules, how to implement import maps using the <script type="importmap"> tag, and the practical benefits they bring to web development.

The Bare Module Specifier Problem

In environments like Node.js or bundler-driven setups (e.g., Vite, Webpack), JavaScript developers regularly use bare module specifiers to import dependencies:

import React from 'react';
import { debounce } from 'lodash';

When modern browsers natively introduced ECMAScript Modules (ESM) via <script type="module">, they enforced a strict rule: module specifiers must be absolute URLs, relative URLs starting with ./, ../, or root-relative paths starting with /. A bare specifier like 'react' does not resolve to a file path natively, causing the browser to throw an error:

TypeError: Failed to resolve module specifier "react". Relative references must start with either "/", "./", or "../".

Historically, resolving this required running a build step to rewrite imports into direct file paths or bundle them into a single file.

What Are Import Maps?

An import map is a JSON structure embedded directly in an HTML document that instructs the browser on how to resolve module specifiers. By defining an import map, you can associate bare specifiers or custom aliases with absolute or relative URLs, allowing native ES modules to consume external libraries cleanly.

Import maps are defined using a <script> tag with the type="importmap" attribute:

<script type="importmap">
{
  "imports": {
    "lodash": "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/lodash.js",
    "utils/": "/src/utils/"
  }
}
</script>

With this map placed in the HTML document before any module execution, the browser can resolve:

<script type="module">
  import { debounce } from 'lodash';
  import { formatDate } from 'utils/format.js';
</script>

The browser automatically rewrites lodash to the specified CDN URL and utils/format.js to /src/utils/format.js.

Key Features of Import Maps

1. Direct Module Mapping

You can map an exact string to an exact resource:

{
  "imports": {
    "three": "/vendor/three/build/three.module.js"
  }
}

2. Path Mapping (Trailing Slashes)

By ending both the key and the value with a forward slash (/), you create a path prefix map:

{
  "imports": {
    "components/": "/scripts/components/"
  }
}

Importing components/Modal.js dynamically resolves to /scripts/components/Modal.js.

3. Scoped Mapping

Import maps support a "scopes" key to handle version conflicts or module-specific dependency trees. If a specific folder or package requires a different version of a library, you can configure it independently:

{
  "imports": {
    "shared-lib": "/libs/shared-v2.js"
  },
  "scopes": {
    "/legacy-app/": {
      "shared-lib": "/libs/shared-v1.js"
    }
  }
}

Any file imported within /legacy-app/ that requests shared-lib will receive version 1, while all other files will receive version 2.

Rules and Limitations

Advantages of Using Import Maps