Using frontend JavaScript libraries without a build step
Browsers no longer require a build system to run modern JavaScript, but library documentation often assumes you’re using one. The practical problem is figuring out which file formats a library ships and how to load them directly in the browser.
There are three basic kinds of JavaScript files a library can provide: classic scripts that attach to a global variable, ES modules, and CommonJS modules (which are Node-only). A fourth type, AMD, still exists but is largely irrelevant in 2024.
Inspecting the NPM build
Even if you never touch Node, every library’s distributable files originate from its NPM package. CDNs like cdnjs or jsDelivr simply mirror that build. If you want certainty about what a library provides, npm install it in a scratch folder and inspect the files directly.
The files are not always in a dist directory. The package.json main, module, and CDN-specific fields point to the actual build location. For example, Chart.js’s package.json designates dist/chart.js as the ES module entry, while jsDelivr and unpkg serve ./dist/chart.umd.js. A "type": "module" field tells Node to treat files as ES modules by default.
Case study: Chart.js (UMD)
Chart.js’s build contains three options:
chart.cjs— a CommonJS file for Node, unusable directly in the browser.chart.js— an ES module; opening the file revealsimport '@kurkle/color';.chart.umd.js— a UMD (Universal Module Definition) file that works with a plain<script src>, CommonJS, or AMD.
UMD is the zero-config path. Include the file with a script tag, and the library exposes a global variable — for Chart.js that’s Chart. Copy the UMD file into your repository to avoid CDN dependency.
Case study: @atcute/oauth-browser-client (ES module with dependencies)
This Bluesky OAuth client ships only an index.js that uses export syntax, marking it as an ES module. You can run it in the browser, but it imports other packages, which complicates things.
Direct <script> tags won’t work for ES modules. Instead:
- Define an import map in your HTML.
- Write imports like
import { configureOAuth } from '@atcute/oauth-browser-client';in your code. - Load that code with
<script type="module" src="YOURSCRIPT.js"></script>.
An import map is necessary because the module’s internal imports reference bare specifiers like @atcute/client; the browser needs a mapping to know where to fetch each dependency. Getting these maps right is fiddly, and no dedicated generator tool is established — though you could script one from esbuild’s metafile output. Simon Willison’s download-esm takes another route: it rewrites imports to point directly at local JS files, eliminating the need for an import map.
Import maps have drawbacks. Loading a dependency tree can mean dozens of HTTP requests, which in a local dev environment can cause intermittent load failures; this resolved once the site was deployed to production. ES modules also require running a web server — you cannot open index.html from the filesystem.
If a module has no dependencies, the import map disappears. You only need the type="module" attribute and an import statement that points directly at the module URL.
Case study: @atproto/oauth-client-browser (CommonJS)
A second Bluesky auth library — @atproto/oauth-client-browser — ships an index.js that looks superficially similar. But the content tells a different story: require() calls and a "type": "commonjs" field in package.json identify it as CommonJS. This is Node code; you cannot run it in a browser without a conversion step, and standard bundlers alone won’t help.
The resolution is esm.sh, a CDN that converts CommonJS into ES modules at request time. Skypack offers similar functionality and can serve as a fallback if one is down. Using it is as simple as pointing a script tag at the converted URL, e.g., https://esm.sh/@atproto/oauth-client-browser. The main concerns are trusting a third-party CDN to stay up and to remain secure over the long term.
Esbuild can perform this CommonJS-to-ES module conversion locally, but named imports like import { BrowserOAuthClient } from ... do not work in that scenario. Still, because esbuild runs on your own machine, it may be more trustworthy than a CDN.
Identifying module types
File extensions are unreliable: .js and .min.js could be any of the three types. Here is how to tell them apart by content:
- Classic (global variable): Works with a plain
<script src>tag. Look for a.umd.jsextension or documentation that shows a CDN snippet. When in doubt, try a script tag and see if a global appears. - ES module: Look for
importorexportstatements (but notmodule.exports), a.mjsextension, or a"type": "module"field. - CommonJS: Look for
require()ormodule.exports, a.cjsextension, or a"type": "commonjs"field.
Practical strategies
Given the trade-offs, a no-build system does not mean no build tools anywhere. A sensible middle ground is to run a one-time setup step — using download-esm or manually copying bundled files — and only repeating it when you bump dependency versions.
- If you prefer maximal simplicity and trust, copy a UMD build into your repo and reference it with a classic
<script>tag (when the library offers one). - If a library is a self-contained ES module, import it directly with no import map.
- If a library is an ES module with dependencies, import maps work but can be unwieldy; alternatively,
download-esmcollapses the dependency graph into local files. - If a library is CommonJS, esm.sh converts it on the fly for browser use, and newer browser versions have baseline support for import maps.
- For wider browser compatibility, running esbuild as a small local step remains the safest option.



