Import Maps Explained: Bare Specifiers Without a Bundler
Import maps let browsers resolve bare module specifiers like "react" to real URLs, enabling native ES module imports without a bundler.
An import map is a block of JSON, declared in a <script type="importmap"> tag, that tells the browser how to resolve bare module specifiers — names like "react" or "lodash" — to actual URLs. Without one, native ES modules only understand relative or absolute paths; import { useState } from "react" fails in a browser with no bundler in front of it. An import map fixes that gap directly in HTML.
The problem it solves
JavaScript’s native module system uses import and export, and browsers have supported loading ES modules with <script type="module"> for years. But native import statements only resolve two kinds of specifiers: relative paths (./utils.js) and full URLs (https://example.com/lib.js). A bare specifier like import _ from "lodash" — the syntax every npm package uses — has no browser-native meaning. That resolution logic is normally Node’s job, or a bundler’s, done at build time.
Import maps move that resolution into the browser itself, at request time. You declare the mapping once, and every subsequent bare-specifier import in the page resolves against it — no build step required.
Basic syntax
<script type="importmap">
{
"imports": {
"lodash": "https://esm.example.com/lodash@4.17.21/lodash.js",
"app/": "/src/"
}
}
</script>
<script type="module">
import debounce from "lodash";
import { formatDate } from "app/utils.js";
</script>
The import map must appear before any <script type="module"> that depends on it. The "lodash" key maps a bare specifier directly to a URL; the "app/" key (with a trailing slash) maps a prefix, so any specifier starting with app/ resolves against /src/ — app/utils.js becomes /src/utils.js.
Import maps support a "scopes" field too, letting you override mappings for modules loaded from a specific path — useful when two dependencies need different versions of the same shared library.
Why this matters for bundler-free development
For small projects, prototypes, or sites that intentionally avoid a build pipeline — see local-first and lightweight web development — import maps let you use clean package names while still serving modules straight from a CDN, with no Vite or webpack configuration in between. The browser handles resolution natively, and you get real ES module semantics: static analysis, live bindings, and no bundler-specific runtime.
This isn’t a wholesale replacement for bundlers in production apps. Bundling still buys you tree shaking, code splitting, and minification — none of which an import map does on its own. What it replaces is the specifier resolution step, which is often the only reason a simple project reaches for a bundler in the first place.
Import maps vs bundler resolution
| Import maps | Bundler (Vite, webpack) | |
|---|---|---|
| Resolution timing | Runtime, in the browser | Build time |
| Setup | One JSON block in HTML | Config file, dependency graph |
| Bundling / minification | None | Yes |
| Tree shaking | None | Yes |
| Works without a build step | Yes | No |
| Version pinning | Manual, per URL | Managed via package.json |
Using import maps with a CDN
The most common pattern pairs an import map with a module CDN that serves npm packages as ES modules:
<script type="importmap">
{
"imports": {
"vue": "https://esm.example.com/vue@3.4.0"
}
}
</script>
Every import ... from "vue" in your modules now resolves to that pinned version, served from a CDN edge node. Because the mapping is explicit and centralized, upgrading a dependency means changing one URL rather than hunting through every import statement — similar in spirit to how package.json centralizes versions for npm, pnpm, and Yarn, just resolved by the browser instead of a package manager.
Browser support and fallbacks
Import maps are part of the browser’s native module loading behavior and are broadly supported in current evergreen browsers. Because the <script type="importmap"> tag is just JSON inside HTML, unsupporting browsers ignore it silently rather than erroring — though any module script depending on the mapping will then fail to resolve. For production apps that need to support older browsers, a bundler remains the safer choice; import maps shine in modern-browser-only contexts, internal tools, and quick prototypes where you control the runtime.
Generating import maps for larger dependency trees
Hand-writing an import map works fine for a handful of direct dependencies, but transitive dependencies multiply fast — a single UI library can pull in a dozen sub-packages, each needing its own entry. Tools exist specifically to generate import maps from a package.json-style dependency list, resolving the full tree and emitting the JSON block automatically. For anything beyond a few packages, generating the map is far less error-prone than maintaining it by hand.
The takeaway
Import maps let the browser resolve bare module specifiers natively, closing the gap between how npm packages are written and how native ES modules load. They’re not a bundler replacement — no bundling, minification, or tree shaking happens — but for prototypes, internal tools, and CDN-served dependencies, they remove the one piece of bundler config that used to be unavoidable: turning import "react" into a URL the browser can actually fetch.
Keep reading
Takina · · 4 min read What Is a Lockfile? Reproducible Dependency Installs
A lockfile records the exact dependency versions your package manager resolved, so every install — from your laptop to CI — reproduces the same tree.
Takina · · 4 min read JavaScript Intl API: Formatting Dates and Numbers
The Intl API formats dates, numbers, and currency using a user's locale without a library. How Intl.DateTimeFormat and Intl.NumberFormat work.
Takina · · 5 min read Turbopack vs Webpack: Choosing a JS Bundler
Turbopack is a Rust-based bundler built for incremental speed; Webpack is the mature, plugin-heavy standard. How they differ and when to pick each.