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 — 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:
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:
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. 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 — you want to know the CDN served exactly what you expected, not a modified file.
You generate the hash 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 — 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 — 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.
See Also
- JavaScript modules (ESM) — how
importandexportwork in modern JavaScript - JavaScript service workers — another
<script type="">tag that controls browser behavior - Browser fetch and XHR — loading data from URLs in the browser