Using JavaScript Import Maps Without Bundlers

Import maps provide a native browser mechanism to control how JavaScript resolves module specifiers in standard ES module import statements. By defining a JSON structure inside an inline HTML script tag, developers can map bare module specifiers, directory paths, and specific scopes directly to remote or local URLs. This enables modern web applications to manage modular JavaScript dependencies directly in the browser without requiring a pre-processing or bundling step with tools like Webpack, Rollup, or Vite.

The Problem with Native ES Modules

Standard ES modules natively supported in browsers require explicit file paths or full URLs for imports, such as:

import { add } from './math.js';
import confetti from 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/+esm';

Unlike Node.js or bundler-driven workflows, browsers cannot natively resolve “bare specifiers” like import confetti from 'canvas-confetti' because they lack access to a local node_modules directory lookup algorithm. Without import maps, developers either had to use bundlers to resolve these paths ahead of time or rewrite import URLs manually across all files whenever an endpoint changed.

Declaring an Import Map

An import map is declared in an HTML document using a <script> tag with type="importmap". It must be placed before any module scripts that rely on its mappings.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Import Maps Demo</title>
  
  <script type="importmap">
  {
    "imports": {
      "canvas-confetti": "https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/+esm",
      "app/": "./src/"
    }
  }
  </script>
</head>
<body>
  <script type="module">
    import confetti from 'canvas-confetti';
    import { logger } from 'app/utils.js';

    confetti();
    logger('Confetti fired!');
  </script>
</body>
</html>

Key Mapping Features

1. Bare Module Specifiers

The most common use case is mapping a single package name to a specific CDN URL or local path.

{
  "imports": {
    "lodash": "https://esm.sh/lodash-es@4.17.21",
    "react": "https://esm.sh/react@18.2.0"
  }
}

This allows code to use import _ from 'lodash' without runtime resolution errors.

2. Path Prefix Matching (Trailing Slashes)

By appending trailing slashes to both the key and the target path, import maps allow dynamic resolution of sub-paths or directories.

{
  "imports": {
    "components/": "/static/js/components/",
    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
  }
}

Importing components/Button.js dynamically resolves to /static/js/components/Button.js.

3. Version Scoping with scopes

When different parts of an application or third-party dependencies require different versions of the same library, the scopes key applies overrides restricted to specific module URL paths.

{
  "imports": {
    "chart.js": "https://esm.sh/chart.js@4.4.0"
  },
  "scopes": {
    "/legacy/": {
      "chart.js": "https://esm.sh/chart.js@2.9.4"
    }
  }
}

Any script loaded from within /legacy/ that imports chart.js receives version 2.9.4, while scripts loaded elsewhere receive version 4.4.0.

Benefits and Modern Browser Support

Import maps are natively supported across all modern browsers (Chrome, Edge, Firefox, and Safari). Key benefits include: