TypeScript source-first registry — the real reason jsr.io is different from npm
If you've ever published a package to npm, you know how tedious the process can be. Tweaking tsconfig.json, running tsc to generate dist/, verifying that .d.ts files came along properly, then aligning the exports field in package.json — you end up spending more time on release configuration than on the actual library code. I remember losing half a day to build configuration the first time I open-sourced a utility library.
JSR (JavaScript Registry) approaches this problem from the ground up, differently. You publish TypeScript source files as-is, and the registry handles transpilation for each runtime and type definition generation internally at publish time. This means you can ship a package that works across Deno, Node.js, and Bun from a single source — no build pipeline required.
Launched in March 2024 by the Deno team, JSR is a registry designed to coexist with npm. The goal isn't to replace npm, but to solve at the registry level what npm cannot — direct TypeScript source publishing and guaranteed cross-runtime compatibility. In February 2025, Evan You (Vue/Vite), Isaac Schlueter (npm creator), and Ryan Dahl (Node.js/Deno creator) joined the governance board, clearly shifting the project from a single-company effort to community infrastructure. Having key figures from competing ecosystems on the same board is a fairly unusual signal.
Core Concepts
What Makes JSR Fundamentally Different from npm
Direct TypeScript source publishing is the biggest difference. Publishing to npm requires compiled JS output, but JSR accepts .ts source files and handles them internally at publish time. This structurally reduces the mismatch between source and published types — that situation where a misconfigured build produces garbled .d.ts files.
Immutable versions also matter. Once a version is published, it cannot be deleted. This is a deliberate design choice to prevent supply chain disruptions like the left-pad incident caused by npm's unpublish feature. If you've ever run packages in production, you've probably felt how important this is at least once.
Being ESM-only is both a constraint and a clear statement of direction. CommonJS require() is not supported. However, the weight of this constraint has lessened considerably since Node.js 22.12.0 (LTS, October 2024) stabilized synchronous require(esm) support without a flag. Users on 22.0–22.11.x still need a flag, so be aware.
What the npm.jsr.io Proxy Does
The reason you can use JSR packages in Node.js and Bun just like npm install is thanks to the npm.jsr.io proxy. This proxy is not a simple redirect. At publish time, JSR transpiles the TypeScript source and generates .d.ts files; the proxy then packages these artifacts in an npm-compatible format and serves them. In other words, JSR does the build for you — you upload the source, the registry infrastructure produces the JS transformation and type declarations, and Node.js users receive it like a regular npm package.
Combined with version immutability, this creates an additional benefit: the pre-processed artifacts can be aggressively cached, making installations via npm.jsr.io fast. The immutability constraint on the publisher side translates into a performance benefit on the user side.
jsr.json — Minimal Configuration
Compared to package.json, the configuration is lean. The essentials are name (scope required), version, and exports.
{
"name": "@myorg/utils",
"version": "1.0.0",
"exports": {
".": "./src/mod.ts",
"./http": "./src/http.ts",
"./validation": "./src/validation.ts"
}
}Remember that exports must point directly to .ts source files. If you point to build artifacts like ./dist/mod.js, the publish will succeed but JSR's automatic type generation and documentation will not work properly.
The scope (the @myorg part) can be created from a GitHub account or organization name. JSR enforces the @scope/name format, so flat names like my-utils cannot be registered at all.
Import Syntax by Runtime
// Deno — direct import with the jsr: scheme
import { formatDate } from "jsr:@myorg/utils";
// Node.js / Bun — install first
// pnpm add jsr:@myorg/utils
// bun add jsr:@myorg/utils
import { formatDate } from "@myorg/utils";Deno supports jsr: imports natively, while Node.js and Bun use it like a regular npm package via the npm.jsr.io proxy. From the package consumer's perspective, there is almost no runtime-specific branching.
JSR Score — Package Quality Indicator
JSR automatically scores published packages. First, a note on terminology: slow types refers to patterns where type inference is overly complex and slows down build times — exported functions without explicit return types are the prime example. The Score system automatically checks for these patterns at publish time.
| Category | Impact | Key Items |
|---|---|---|
| Runtime compatibility | High | Deno, Node, Bun, browser compatibility |
| Documentation | High | JSDoc coverage, example code |
| Best practices | High | No slow types, tests present |
| Discoverability | Medium | Description, keywords, readme |
| Provenance | Medium | GitHub Actions OIDC provenance |
(Impact levels are approximate weightings based on the official Scoring docs)
At first I thought "oh great, gamification again..." but in practice it works pretty well as a checklist for things that are easy to overlook when first creating a package.
// Slow type — relies on return type inference
export function parseUrl(input: string) {
return new URL(input); // JSR will warn about this
}
// Recommended — explicit return type
export function parseUrl(input: string): URL {
return new URL(input);
}When to Choose JSR
Before diving into implementation, you need to be able to judge whether JSR actually fits your situation.
Advantages — Where JSR Shines
| Item | Practical Meaning |
|---|---|
| TypeScript source publishing | Structurally prevents broken types caused by build misconfiguration |
| Automatic documentation | Well-written JSDoc alone completes the package page |
| JSR Score | Package quality visible as a metric, like a PR review |
| Version immutability | Guarantees stability of production dependencies |
| GitHub Actions OIDC | Provenance signing without managing secrets |
| Cross-runtime single source | Covers Deno, Node, and Bun without separate build configs |
Disadvantages — An Honest Look at Real Constraints
| Item | Practical Impact |
|---|---|
| ESM only | Migration cost for CommonJS legacy projects |
| Package count gap (40K vs 3.2M) | High chance the library you want isn't on JSR |
| Immature tooling | Dependabot, Renovate, and npm audit don't fully support JSR |
| Slow enterprise adoption | Additional setup needed for internal registries like Artifactory |
| No Node-only APIs | process, __dirname, fs → won't work in Deno/Bun |
Practical Application
Choosing a Publishing Strategy
Cross-Runtime Package Design Principles
For a package published to JSR to work across Deno, Node, and Bun, you need to avoid runtime-specific APIs. In order of priority:
- Can Web APIs (WinterCG standards) do the job? →
fetch,URL,crypto.subtle,Intl, etc. work across all three runtimes. Use these first whenever possible. - Is runtime-specific branching unavoidable? → Branch with
typeof Deno !== "undefined", but if bundler compatibility matters, conditional exports is the better choice (see below).
The Neon Serverless Postgres Driver (an npm package — not separately published on JSR) illustrates this principle well. It's implemented on top of WebSocket and Web Fetch API, which is why it works in edge environments like Cloudflare Workers and Deno Deploy.
When Runtime-Specific Branching Is Necessary
When you genuinely need different behavior per runtime — like Deno.env vs process.env — you can branch on global object existence.
// src/env.ts — cross-runtime environment variable reading
export function getEnv(key: string): string | undefined {
if (typeof Deno !== "undefined") {
return Deno.env.get(key);
}
if (typeof process !== "undefined" && process.env) {
return process.env[key];
}
// Browser / edge (no environment variables)
return undefined;
}However, the typeof Deno !== "undefined" pattern prevents bundlers like esbuild and Vite from applying dead code elimination. If a consumer processes the package through a bundler, the Deno branch code will be included in the bundle as-is. If bundler-friendly output is a goal, splitting runtime-specific entry points via conditional exports is the better approach.
The more branching code you have, the heavier the testing burden. It's better to solve things with Web API standards first and treat branching as a last resort.
Coexisting jsr.json and package.json
In a dual-publishing setup, both files live side by side.
my-date-utils/
├── jsr.json # For JSR publishing
├── package.json # For npm publishing
├── src/
│ ├── mod.ts
│ └── ...
└── tests/
└── ...name and version exist independently in each file and must be kept in sync on each release. There is no conflict — pnpm dlx jsr publish reads jsr.json, and pnpm publish reads package.json, so each command only touches its own file.
Set exports in package.json based on build artifacts (dist/), and have jsr.json point directly to .ts sources. It's normal for the two files to have different exports paths.
// package.json — based on build artifacts
{
"name": "@myorg/date-utils",
"version": "1.0.0",
"exports": {
".": {
"import": "./dist/mod.js",
"types": "./dist/mod.d.ts"
}
}
}// jsr.json — based on TypeScript source
{
"name": "@myorg/date-utils",
"version": "1.0.0",
"exports": {
".": "./src/mod.ts"
}
}Dual Publishing — Deploying to Both npm and JSR
This is the approach adopted by major libraries like Hono and Supabase. One important clarification about the CI workflow below: pnpm build is the step that generates JS artifacts for npm publishing. The JSR publish step (pnpm dlx jsr publish) uploads source directly rather than build artifacts, so it does not require a build step — JSR handles transpilation at publish time.
# .github/workflows/publish.yml
name: Publish
on:
push:
tags:
- "v*"
permissions:
contents: read
id-token: write # Required for JSR provenance
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: "https://registry.npmjs.org"
- run: pnpm install
- name: Build (generate JS artifacts for npm publishing)
run: pnpm build
- name: Publish to npm
run: pnpm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish to JSR
run: pnpm dlx jsr publish
# Auto-authenticated via OIDC token — no separate secret neededWith the id-token: write permission, JSR authenticates automatically via GitHub Actions OIDC. Not having to manage a separate API token is more convenient than it sounds. Once configured, every subsequent release deploys to both registries with a single tag.
Example Real-World Package Structure
Let's say you're building a date utility package.
// src/mod.ts — auto-documented via JSDoc
/**
* Converts an ISO 8601 date string into the specified format.
*
* @example
* ```ts
* import { formatDate } from "@myorg/date-utils";
* formatDate("2026-07-12", "MMMM D, YYYY"); // "July 12, 2026"
* ```
*/
export function formatDate(iso: string, pattern: string): string {
const date = new Date(iso);
// ISO strings are parsed as UTC midnight, so UTC methods must be used
// to avoid the date shifting due to timezone offset
return pattern
.replace("YYYY", date.getUTCFullYear().toString())
.replace("MM", String(date.getUTCMonth() + 1).padStart(2, "0"))
.replace("DD", String(date.getUTCDate()).padStart(2, "0"));
}
export type { FormatPattern } from "./format.ts";JSDoc @example blocks render directly on the JSR package page. Package quality is communicated in a visible form without needing a separate documentation site.
When file system paths are needed, import.meta.url can be used — but cross-runtime (Deno/Node/Bun) and cross-platform (macOS/Windows) are separate concerns.
// On Windows Node.js, pathname returns /C:/Users/... which causes errors when passed directly to fs APIs
// Use fileURLToPath to convert it
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("./assets", import.meta.url));Common Mistakes in Practice
Using Node-only globals inside packages is the most frequent issue. Using __dirname or process.cwd() inside a library causes an immediate runtime error in Deno.
// Wrong — Node-only
import path from "node:path";
const root = path.join(__dirname, "assets");
// Correct — ESM standard (add fileURLToPath if using with fs APIs)
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("./assets", import.meta.url));exports paths in jsr.json pointing to build artifacts is also common. JSR requires pointing directly to .ts source files. Using a path like ./dist/mod.js will publish successfully, but automatic type generation won't work correctly.
Attempting to register a package name without a scope — JSR enforces the @scope/name format. Flat names like my-utils cannot be registered at all.
Closing Thoughts
JSR in one sentence: it's a registry built for publishing TypeScript the way TypeScript was meant to be published. If npm is a general-purpose warehouse for the JS ecosystem, JSR is an attempt to guarantee type safety and cross-runtime compatibility at the registry level.
If you're creating a new utility library, it's worth considering JSR-first publishing. If you already have an npm package, dual publishing is the practical choice. Hono and Supabase have already validated that path, and once configured, every subsequent release deploys to both registries with a single tag.
Looking at the open governance transition in February 2025 and the goal of joining the OpenJS Foundation, JSR is shifting its center of gravity from a Deno team project to shared JS ecosystem infrastructure. It won't replace npm anytime soon, but it has matured into a fully viable choice as the default publishing destination for new cross-runtime libraries.
3 steps to get started now:
- Create a scope on
jsr.io— log in with your GitHub account and register the@yourusernamescope - Add
jsr.jsonto an existing library — setexportsto point to.tssources and runpnpm dlx jsr publishfor the first publish - Check JSR Score and improve — resolve slow types warnings, add JSDoc
@exampleblocks to raise your score
References
- JSR Official Site
- Why JSR? — Official Docs
- Publishing Packages — Official Docs
- jsr.json Configuration Reference
- JSR Scoring — Official Docs
- JSR Governance Overview
- Introducing the JSR Open Governance Board — Deno Blog
- Introducing JSR (Open Beta) — Deno Blog
- How We Built JSR — Deno Blog
- JSR Is Not Another Package Manager — Deno Blog
- Announcing Hono on JSR — Deno Blog
- Exploring JSR for JavaScript module management — LogRocket
- JS Runtimes Have Forked in 2025: Cross-Runtime Libraries — Debugg.ai
- JSR GitHub Open Source Repository
- FOSDEM 2025 — JSR: from private ownership to open governance