jsguides

Import Maps and Dependency Management

In browsers, ES modules require full URLs or relative paths. You can’t write import _ from "lodash" and expect it to work, since the browser has no idea where “lodash” lives. Import maps solve this by letting you define that mapping yourself, right in your HTML.

What import maps solve

Before import maps, using a third-party ES module in the browser meant one of two options: a bundler that converts import lodash from "lodash" into a single file, or a CDN that accepts bare specifiers via a special runtime (like esm.sh). Import maps give you a third option: declare the mapping explicitly:

<script type="importmap">
{
  "imports": {
    "lodash": "https://unpkg.com/lodash@4.17.21/lodash.js"
  }
}
</script>

<script type="module">
  import _ from "lodash";
  console.log(_.chunk([1, 2, 3, 4], 2));
</script>

The browser now knows exactly what URL “lodash” maps to, no build step required.

The structure of an import map

An import map is a JSON object placed inside a <script type="importmap"> tag. It has two main sections: imports (the default mappings) and scopes (per-path overrides).

imports — bare specifier mappings

The imports key maps the text you write in import statements to the actual URLs:

<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@18",
    "react-dom": "https://esm.sh/react-dom@18",
    "#utils": "./lib/utils.js"
  }
}
</script>

With this in place, any module on the page can write bare specifiers instead of full URLs. The browser resolves each specifier against the import map before fetching, so you get clean import lines without losing control over where the actual files live:

import React from "react";
import { createRoot } from "react-dom";
import { helper } from "#utils";

The # prefix is a convention for internal aliases; it signals “this is not a package from npm.”

Prefix Mappings

If a mapping key ends in /, it’s a prefix mapping. Any specifier that starts with that prefix gets the value substituted in place of the key:

{
  "imports": {
    "lodash": "https://unpkg.com/lodash@4.17.21/lodash.js",
    "lodash/": "https://unpkg.com/lodash@4.17.21/"
  }
}
  • import _ from "lodash" → full URL (exact match)
  • import fp from "lodash/fp"https://unpkg.com/lodash@4.17.21/fp (prefix match)

This means you can import submodules without listing every single one. The prefix acts like a directory reference: everything under lodash/ resolves through the same base URL, so adding a new submodule later requires no import map changes:

import chunk from "lodash/chunk.js";   // works
import debounce from "lodash/debounce.js";  // works too

Scopes — different mappings per path

The scopes key lets you use different mappings depending on the URL of the script doing the importing. When a script under a matched path imports a specifier that appears in both imports and the scope, the scope wins. This is useful for handling multiple versions of a dependency:

<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@18"
  },
  "scopes": {
    "/legacy-admin/": {
      "react": "https://esm.sh/react@16"
    },
    "/new-dashboard/": {
      "react": "https://esm.sh/react@18"
    }
  }
}
</script>

When a script at /legacy-admin/components/Button.js imports “react”, it gets v16. When a script at /new-dashboard/ imports “react”, it gets v18. The most specific matching scope wins.

Handling multiple package versions

One of the most practical uses of import maps is avoiding version conflicts. If your app uses React 18 but a third-party widget you can’t control needs React 17, you can map both:

{
  "imports": {
    "react": "https://esm.sh/react@18",
    "react-17": "https://esm.sh/react@17"
  }
}

Your app code uses import { useState } from "react" (v18). The widget’s code imports import from "react-17" (v17). Both versions load simultaneously, no conflicts.

Subresource Integrity

Import maps support the integrity key to verify that loaded modules haven’t been tampered with. You include a hash that the browser uses to check the file:

<script type="importmap">
{
  "imports": {
    "lodash": "https://unpkg.com/lodash@4.17.21/lodash.js"
  },
  "integrity": {
    "https://unpkg.com/lodash@4.17.21/lodash.js": "sha384-oGnIU21aL0tFkAkMvWMB0GgYDAj/h9PYLGQj..."
  }
}
</script>

The browser won’t load the module if the fetched content doesn’t match the declared hash. This matters most when you’re loading modules from third-party CDNs, since you want assurance the CDN served exactly what you expected, not a modified file. Subresource integrity turns the import map into a tamper-evident manifest: even if the CDN is compromised, the browser rejects mismatched files. Generating the hash is straightforward from the file’s actual content:

# sha384 hash of the file
openssl dgst -sha384 -binary lodash.js | openssl base64

Feature Detection

Not every browser supports import maps. Before relying on them, detect support:

if (HTMLScriptElement.supports?.("importmap")) {
  // Safe to use import maps
} else {
  // Fall back to a bundler or a polyfill
}

HTMLScriptElement.supports() is supported in all modern browsers, including those that don’t support import maps, so this detection is reliable.

Common Pitfalls

Import map must appear before any module scripts. The browser processes the import map before it loads any ES modules. If a module script appears before the import map, module resolution fails:

<!-- wrong: module loads before import map is parsed -->
<script type="module">import _ from "lodash";</script>
<script type="importmap">{ "imports": { "lodash": "..." } }</script>

<!-- correct: import map first -->
<script type="importmap">{ "imports": { "lodash": "..." } }</script>
<script type="module">import _ from "lodash";</script>

Invalid JSON breaks everything. If the import map JSON is malformed, no modules load. The browser doesn’t give you a helpful error, and everything just fails silently.

Scopes match the importing script’s URL, not the target module’s URL. If you have a module at /app/utils.js that imports “react”, and a scope at /app/, the scope applies. If that same module is loaded from /admin/utils.js, the scope doesn’t apply.

When to use import maps

Import maps make sense when:

  • You want to use ES modules directly in the browser without a bundler
  • You’re loading multiple third-party packages from a CDN
  • You need to run different versions of the same package simultaneously

For single-page applications with complex dependency trees, a bundler is still the better choice because import maps don’t do tree-shaking or code-splitting. But for smaller demos, experiments, or pages that load a handful of well-known packages from CDNs, import maps are lightweight and effective.

Keep aliases intentional

Import map aliases are easiest to maintain when they stay descriptive and limited in scope. A short alias like #utils or react is helpful when it matches the project shape, but too many custom names can hide where code really comes from. Keep the mapping list readable by grouping related entries and removing unused ones as soon as they are no longer needed. That discipline makes the browser setup feel deliberate instead of improvised.

Plan for multiple versions

One of the best reasons to use scopes is to isolate version differences that you cannot remove right away. That only works well if the version split is documented and temporary. If one area of the app needs an older package, write down why and what would let you collapse the split later. Otherwise, the import map can become a permanent pile of special cases. Treat version-specific scopes as a migration tool, not as the default way to organize every dependency.

Roll out carefully

Changing an import map can change what the browser loads with no source code diff in the importing module itself. That makes review important. Test the map in the same browser matrix your users rely on, and verify that the URLs still point to the exact files you expect. If you are loading from a CDN, pin versions and verify hashes where possible. Small mistakes here can break every page that uses the map, so deployment checks matter more than they do for a local alias file.

Know the Limits

Import maps are helpful, but they do not replace a build tool for every project. They do not minify bundles, remove dead code, or coordinate complex splitting strategies. If your app needs those features, keep the bundler in the stack and use import maps only where they solve a specific problem. That keeps the browser behavior clear and avoids asking the platform to do jobs it was not designed to do.

Track browser support

Import maps are only helpful when the browsers you care about can use them. Before you commit to them, check the current support picture for your audience and decide whether a fallback is needed. That check matters most on public sites where the browser mix may be broad. A small support note in the project docs can keep the team from assuming every user sees the same module loader behavior. That keeps the choice deliberate instead of accidental.

See Also