Open-source project
esm-dev/esm.sh avatar
esm-dev/esm.sh

esm.sh: every npm package as an import statement, no build required

A no-build JavaScript CDN for modern web development.

4,179 stars209 forksGoMIT

At a glance

What is it?
esm.sh is a MIT-licensed no-build JavaScript CDN, a Go server that serves packages from npm, JSR, GitHub and pkg.pr.new as ES modules reachable from plain import statements, transforming TypeScript, Vue and Svelte on the fly. Query flags pin dependency versions, alias packages, control bundling, tree shake exports and select esbuild targets, and the whole server is self-hostable.
Who is it for?
Use esm.sh when a page or prototype should import real packages without a bundler, when a Codepen style demo needs React or Hono immediately, or when pinning one dependency version across an import graph matters more than a node_modules directory. Prefer a local build pipeline for shipping production bundles, since CDN dependence adds a runtime you do not control and the project's own bundling caveats, side effects and import.metaurl semantics, live at the extremes.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 10 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 1, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Imports straight from URLs, or bare specifiers via import maps

The core idea fits one line, import JavaScript modules from http URLs with no installation or build steps:

js
import * as mod from "https://esm.sh/PKG[@SEMVER][/PATH]";

Combined with import maps, the URLs disappear from application code entirely, and bare specifiers resolve to pinned esm.sh addresses:

html
<script type="importmap">
  {
    "imports": {
      "react": "https://esm.sh/[email protected]",
      "react-dom/": "https://esm.sh/[email protected]/"
    }
  }
</script>
<script type="module">
  import React from "react"; // → https://esm.sh/[email protected]
  import { render } from "react-dom/client"; // → https://esm.sh/[email protected]/client
</script>

The trailing slash on the react-dom mapping is what makes subpath imports like react-dom/client work, and with that in place the application reads like ordinary bundler-based code while loading directly from a CDN. The README points to a fuller Using Import Maps section for advanced mappings, but the pattern above is the one most applications need, a couple of pinned entries that make CDN loaded code indistinguishable from bundled code at the source level.

Four registries behind one host

npm is the default namespace, with semver ranges, dist tags and submodules all addressable, react for latest, react@18 resolving to 18.3.1, react@beta for the newest beta, and react-dom/server for a sub-module. Three more registries sit behind path prefixes. JSR, the newer Deno-oriented registry, lives under /jsr/, so @std/[email protected]/base64 and @hono/hono@4 import directly. GitHub lives under /gh/, accepting tags, commit hashes or latest, with tslib available as gh/microsoft/tslib, tslib@d72d6f7 or [email protected]. And pkg.pr.new, the service that publishes pull requests as packages, is reachable through /pr/ or /pkg.pr.new/, with a compact form shown for tinybench@a832a55. One CDN, four package universes, uniform URL grammar across all of them. The registry prefixes also compose with the transform features, since a GitHub source file and an npm package take the same query flags, so a prototype can start on a pull request build from pkg.pr.new and graduate to a pinned npm release without changing anything but the URL.

TypeScript, Vue and Svelte compiled in the browser request

esm.sh transforms .ts, .tsx, .vue and .svelte files on the fly, so source files import directly in the browser without any build steps, which turns GitHub repositories into usable module sources even when they never published compiled output. The examples pull a React icon component from a .tsx file inside the phosphor-icons repository and a Vue icon from a .vue file, each with a query pinning the framework version the component expects:

js
import { Airplay } from "https://esm.sh/gh/phosphor-icons/[email protected]/src/csr/[email protected]";
import IconAirplay from "https://esm.sh/gh/phosphor-icons/[email protected]/src/icons/[email protected]";

The transform is not a toy, it handles single file components with scoped styles and TypeScript with JSX, the formats real component libraries ship in, and the ?deps flag is what keeps the transformed module bound to a framework version the host page controls.

?deps and ?alias: version surgery on the dependency graph

By default esm.sh rewrites import specifiers based on the package's own dependencies, resolving them to other esm.sh URLs. The ?deps query overrides those versions, taking PACKAGE@VERSION pairs separated by commas, so [email protected],[email protected] pins two libraries to one release line, the standard fix for React's single-instance requirement when packages disagree. The ?alias query substitutes a different package for a dependency, with the canonical example being swr with react aliased to preact/compat:

js
import useSWR from "https://esm.sh/swr?alias=react:preact/compat&[email protected]";

combining an alias with a deps pin in one URL. Together the two flags make the dependency graph editable from the consuming side, without forking the package or maintaining a patched registry mirror. The unadorned form still works when the package's own dependency resolution is acceptable, as in the plain [email protected] import shown beside the swr examples, and the flags matter exactly when it is not, when two packages must share one instance of React or when a lighter stand-in like preact should satisfy a react import.

Bundling by default, with three escape hatches

The default strategy bundles sub-modules of a package that are not shared by entry modules defined in the exports field, trading fewer network requests for possible repeated bundling of shared modules, which in extreme cases can break package side effects or alter import.meta.url semantics. The first escape hatch is per-URL, ?bundle=false, as in the @pyscript/core example. The second is per-package, an esm.sh field in package.json that authors can set to disable the default bundling for their package, alongside the recommendation that authors define the exports field properly so the analysis knows the entry modules. The third direction is the opposite, ?standalone, which bundles a module with all its external dependencies except peerDependencies into a single JavaScript file, the antd example being the illustration. And ?raw disables transforming and bundling entirely, serving the raw source as-is for cases where no transformation is wanted at all. The trade is worth spelling out, fewer requests help cold load on slow networks, while duplicated shared modules waste bytes and can break code that checks identity or location, and the docs place the failure modes honestly at the extreme end rather than claiming the default is universally right.

?exports tree shaking: from 7.3KB to 489 bytes

Tree shaking normally belongs to the bundler, but esm.sh exposes it as a query flag, ?exports=foo,bar, serving a module containing only the named members. The documented tslib comparison is concrete, importing __await and __rest from plain esm.sh/tslib costs 7.3KB, while adding ?exports=__await,__rest drops the transfer to 489 bytes, a fifteen fold reduction achieved from the import statement alone, and the docs note this composes with esbuild downstream for smaller bundles. The limitation is stated plainly, the feature does not work with CommonJS modules, since named exports must exist statically for the server to select them. A sibling flag, ?dev, builds the module with process.env.NODE_ENV set to development or the development condition from the exports field, which restores development behaviors such as React's more detailed warning messages during debugging.

Targets chosen from your User-Agent

By default the server checks the User-Agent header to determine the build target, serving each browser a build matching its capabilities, an unusual choice that makes the same URL serve different esbuild output depending on who asks. The ?target query overrides it, with the available list spanning es2015 through es2024, esnext, deno, denonext and node, so a URL embedded in Deno or Node documentation can request the right variant explicitly. Beyond targets, other esbuild options pass through as queries, ?conditions=custom1,custom2 for custom export conditions and ?keep-names for preserved identifiers, with more listed in the esbuild options section. The underlying engine is esbuild accessed through the ije/esbuild-internal Go package, which is how a CDN achieves build-step latency at request time.

A Go server that carries Deno inside its image

The implementation is Go, module github.com/esm-dev/esm.sh on go 1.26, with a small dependency set that names its jobs, Masterminds/semver for version resolution, brotli for compression, golang-lru for caching, bbolt for embedded storage, and ije/esbuild-internal and ije/gox for the build pipeline. The Dockerfile reveals the operational trickery, the server binary esmd builds statically from a golang alpine builder, the runtime image installs git, creates an unprivileged esm user, and then copies in a Deno 2.7.13 binary from the official image, with a documented hack layering glibc libraries from distroless onto musl Alpine because Deno does not provide a musl build. HOSTING.md and config.example.jsonc document self-hosting, the Makefile wires server and CLI targets including a test bootstrap, and server releases are frequent, v139, v139_1 and v139_2 landing on 2026-09-14, 09-17 and 09-21. Separate changelogs are kept for the CLI and the server, CHANGELOG-CLI.md and CHANGELOG-SERVER.md, acknowledging that the two surfaces move at different speeds, and the repository carries an AGENTS.md configuring coding agents for contribution alongside the usual contributing guide.

Editorial conclusion

Use esm.sh when a page or prototype should import real packages without a bundler, when a Codepen style demo needs React or Hono immediately, or when pinning one dependency version across an import graph matters more than a node_modules directory. Prefer a local build pipeline for shipping production bundles, since CDN dependence adds a runtime you do not control and the project's own bundling caveats, side effects and import.metaurl semantics, live at the extremes. Before relying on it, pin versions in URLs rather than riding latest, use import maps to keep specifiers clean, and if availability is a concern, read HOSTING.md and run the server yourself, the Docker image ships with everything including a Deno binary.

Frequently asked questions

what is esm sh?

esm.sh is a no-build JavaScript CDN that serves packages from npm, JSR, GitHub and pkg.pr.new as ES modules importable from plain http URLs, no installation or build steps needed. It transforms TypeScript, Vue and Svelte on the fly and offers query flags for dependency versions, aliasing, bundling, tree shaking and build targets.

What does ESM stand for?

ESM stands for ES Modules, JavaScript's native module system built on import and export statements. esm.sh serves packages in that format from URLs, so the browser's standard module loading does the work a bundler would otherwise do.

What is an ESM package?

An ESM package is one whose modules use the ES Module system, import and export statements loadable natively by browsers and modern runtimes. esm.sh converts packages from npm and other registries into that form, rewriting import specifiers to URLs so the dependency graph resolves over HTTP.

Official sources

  1. esm-dev/esm.sh on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/esm-dev-esm-sh.svg)](https://hysenlabs.com/projects/esm-dev-esm-sh)