Using npm Packages Straight From the Browser
Use npm packages in plain HTML with import maps and CDN URLs. See how to pick ESM vs CommonJS, pin versions, and avoid build steps.
You can use an npm package in a plain HTML page with no bundler, no node_modules and no config file, by declaring an import map that points a bare specifier at a CDN URL serving that package as an ES module.
One page, one library, one interaction is often not worth a Vite project, with its dev server, its build output directory and its deploy story. The part that goes wrong is rarely the import map syntax: npm packages ship in three different module formats, and only two of them run in a browser at all. This article covers how to identify which format you have, the two ways to load it from a CDN, and why an unpinned URL is a correctness bug rather than a style preference.
Key Takeaways
- An import map is a JSON block inside a
<script type="importmap">tag that tells the browser which URL a bare specifier such ascanvas-confettiresolves to, which is the same job a bundler does at build time, moved into the page. - An import map cannot rescue a CommonJS-only package, because a map changes how a specifier resolves and not what format the file is written in.
- MDN lists import maps as Baseline Widely available, with support across browsers since March 2023.
- Pin an exact version in every CDN URL in the map, or the code your page executes can change without a deploy and without a commit.
- Skipping the build step means no tree shaking, so you ship whatever the package contains rather than the parts you use.
When Should You Skip the Build Step?
Skip the build step when the cost of maintaining one outlives the thing it builds. That covers a CodePen-style demo, a single interactive widget dropped into a WordPress template or a Rails view, an internal dashboard two people use, and any prototype whose lifespan is measured in days. The test is not size but ownership: if nobody is going to upgrade the toolchain in six months, a toolchain is a liability. Anything you expect to grow, ship to real traffic, or hand to a team still belongs in a bundler.
Three Kinds of File, Two of Which Run in a Browser
An npm package ships in one of three module formats, and only two of them run in a browser, so find out which build the package ships before writing any import map. A classic or UMD file works in a plain <script src> and assigns a global. An ES module needs type="module" and import statements. A CommonJS build, written with require() and module.exports, does not execute in a browser at all.
The fastest way to find out is to install the package and read it:
npm install canvas-confetti
ls node_modules/canvas-confetti/dist
cat node_modules/canvas-confetti/package.json
Look at two things in that output: the file extensions in the package and the entry-point fields. Node’s package documentation defines main, exports and type; module is an ecosystem convention that bundlers and CDNs read rather than a field Node specifies. Some packages also carry a jsdelivr or unpkg field naming a browser-ready build. For example, canvas-confetti@1.9.4 declares "main": "src/confetti.js", "module": "dist/confetti.module.mjs" and "jsdelivr": "dist/confetti.browser.js" in its package.json, which tells you a browser build and an ES module build both exist.
| Format | How you recognise it | What the browser needs | Without a build step |
|---|---|---|---|
| Classic / UMD | .umd.js, a dist/*.browser.js, or source assigning to window | Nothing special | <script src>, then use the global |
| ES module | .mjs, import/export in the source, "type": "module" | type="module" | Import map plus a module script |
| CommonJS | .cjs, require(), module.exports, "type": "commonjs" | Conversion first | A CDN that transpiles to ESM, or a build step |
That last row is where most attempts fail silently. An import map cannot rescue a CommonJS-only package, because a map changes how a specifier resolves and not what format the file is written in.
The Simple Approach: One Script Tag From a CDN
If the package ships a classic or UMD build, a single script tag is the whole integration. The global name is chosen by the package author, not by you, so check the README: the canvas-confetti README says its CDN build puts a confetti function on window.
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.js"></script>
<script>
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
If the package ships ESM or CommonJS, the CDN does the conversion instead. A request to jsDelivr’s /+esm endpoint comes back as a browser-ready ES module, and jsDelivr describes that as rather more than a syntax swap: it works out the right entry point from the package’s own fields, converts CommonJS where it has to, pulls the dependencies into the response, and strips and minifies what comes back. esm.sh does the equivalent job under the URL grammar https://esm.sh/PKG[@SEMVER][/PATH]. Either gives you a URL you can put directly in an import statement.
The Better Approach: A Script Tag of Type importmap
An import map is a JSON block inside a <script type="importmap"> tag that maps bare specifiers to URLs, so your module code reads exactly as it would inside a bundler.
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
The difference this buys is one line. Without the map, every file that needs the library repeats the CDN URL and the version:
import confetti from 'https://esm.sh/canvas-confetti@1.9.4';
With the map, the version lives in exactly one place and the import statement is portable into a bundled project unchanged.
Four rules matter in practice. First, order decides whether the map works at all: the browser has to read it before it meets any module script that imports through it, so the <script type="importmap"> block goes above that code. Second, the HTML standard permits a document to carry more than one map and specifies how they are merged, but engine support for that is not uniform, so write one map per document. Third, relative values must begin with /, ./ or ../. Fourth, a trailing slash on both sides of a mapping maps a whole package directory rather than a single entry point:
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4",
"canvas-confetti/": "https://esm.sh/canvas-confetti@1.9.4/"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
import { default as raw } from 'canvas-confetti/dist/confetti.module.mjs';
</script>
MDN rates import maps as Baseline Widely available, present in browsers since March 2023, so a polyfill is no longer part of a normal setup. If you want a runtime check anyway, HTMLScriptElement.supports() gives you one, used as HTMLScriptElement.supports?.("importmap").
One gotcha with no error message attached: ES modules are fetched under CORS rules, so opening the HTML file from disk fails even though the identical file works the moment a local server hands it to you.
Pin the Version, Every Time
Pin an exact version in every CDN URL in the map. An unpinned or range-based URL means the code your page executes can change without a deploy, without a commit and without anything in your repository to explain the difference. The deployed behaviour of the page becomes a function of the CDN’s clock rather than your git history, which turns a routine bug report into an archaeology exercise: the HTML is unchanged, the server logs are unchanged, and the JavaScript is different.
This is the one rule with no upside to breaking. canvas-confetti@1.9.4 is a fact you can reason about; canvas-confetti@latest is a promise someone else keeps.
What Do You Give Up?
Loading packages from a CDN hands a third-party origin the ability to execute arbitrary script in your page’s context. You can narrow that with a CSP and with subresource integrity: MDN notes the import map JSON object accepts an integrity key alongside imports and scopes, mapping module URLs to SRI hashes such as sha384-…. If you would rather own the delivery path entirely, serving your own assets is a different setup, covered in the roles of CDNs in frontend performance and a comparison of CDN platforms.
Three other costs come with the territory. There is no tree shaking, so you ship whatever the package contains rather than the parts you use, which is a fair trade for a demo and a bad one for an application you expect to grow. A deep dependency graph resolved at runtime means the browser discovers each module only after fetching its parent, which is why CDNs intervene: esm.sh bundles a package’s sub-modules into the response by default, holding back only the ones shared by the entry points its exports field declares, and ?bundle=false turns that off. And the failure mode is quiet: the document parses, the layout is complete, and one module never arrives because a proxy, an extension or a CSP rule blocked the origin, which is the class of bug session replay surfaces faster than an error report, since nothing was ever thrown.
For anything substantial in production, use a bundler. This technique is for the things that do not justify one.
Start by reading the package before you write a line of HTML: list the files, read main, module, exports and type, and decide from that whether you need a script tag, an import map, or a build step after all.
FAQs
Can I keep the import map in a separate JSON file instead of inline in the HTML?
No. The spec bars a script element of type importmap from carrying a src attribute at all, along with async, nomodule, defer, crossorigin, integrity and referrerpolicy, so the JSON has to sit inside the document. If the map is generated, render it into the page server-side rather than linking to it, and keep it above the first module script.
How do I load two different versions of the same package on one page?
Use the scopes key. A scope attaches a second specifier map to a URL path, so scripts loaded from under that path can resolve a package to one pinned version while the rest of the page resolves it to another. Where two scopes both match, the longer path is checked first, and the imports map is the fallback. The simpler alternative is giving each version its own bare specifier.
Do import maps apply to web workers or to the src attribute of a script tag?
No. A map only rewrites specifiers in import statements and import() calls in the document itself. The URL in a script tag's src attribute never goes through it, and neither does anything loaded inside a worker or a worklet. A dynamic import inside a document module does resolve through the map, but a worker entry script and its own imports need full URLs.
What happens if a bare specifier is not in the import map?
Resolution throws a TypeError before the module runs, and the two engines word it differently. Chrome reports that it failed to resolve the module specifier, names the specifier, and adds that relative references must start with either /, ./, or ../ (each of those three is quoted in the real message). Firefox reports: The specifier “canvas-confetti” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”. Nothing in your application code throws, so the page renders normally and only the feature backed by that module is dead.