· jsr / deno / typescript
How to Publish a TypeScript Package on JSR: Full Guide
Step-by-step guide to publishing a TypeScript package on JSR: configure jsr.json, fix slow types, publish via Deno or GitHub Actions, and maximize your score.
By Ethan
1,640 words · 9 min read
JSR is worth your attention if you write TypeScript libraries. You ship raw .ts source, JSR generates the compiled .js and .d.ts files automatically, and your package lands on both the JSR registry and the npm @jsr scope without a separate build step. This guide walks through the full publish workflow — from account setup to a 100-point JSR score — and covers the dual-publish pattern for teams that need to keep npm compatibility.
Who this is for
TypeScript developers publishing a new library, or Node.js library authors who want Deno and Bun consumers without maintaining a separate build pipeline. If you are on CommonJS and can’t migrate to ESM right now, JSR isn’t ready for you yet — come back when you’ve made the switch.
Prerequisites
- A JSR account at jsr.io (sign in with GitHub or Google)
- Deno ≥ 1.42 (
deno --version) or Node.js + npm (fornpx jsr publish) - ESM-only source — no
require(), nomodule.exports
Create a scope and package
A scope is your namespace on JSR, like @yourname or @yourorg. Go to jsr.io/new, create a scope first, then create a package within it.
Scope naming rules: 2–20 lowercase characters, letters/numbers/hyphens only. Package names follow the same rules.
You must create the scope on jsr.io before running any publish commands — the CLI errors out if the scope doesn’t exist.
Configure jsr.json
Every JSR package needs a jsr.json (or jsr.jsonc) at the repo root. If you use Deno you can fold these fields into your deno.json instead.
Minimal config:
{
"name": "@your-scope/your-package",
"version": "1.0.0",
"exports": "./mod.ts"
}
With multiple entry points and publish filters:
{
"name": "@acme/utils",
"version": "2.1.0",
"exports": {
".": "./mod.ts",
"./strings": "./src/strings.ts",
"./numbers": "./src/numbers.ts"
},
"publish": {
"include": ["LICENSE", "README.md", "src/**/*.ts"],
"exclude": ["src/tests", "**/*.test.ts"]
}
}
Field reference:
| Field | Required | Notes |
|---|---|---|
name | Yes | Must start with @scope/. E.g. @luca/greet. |
version | Yes | Valid SemVer: 1.0.0, 2.3.0-beta.1. |
exports | Yes | A single path (string) or named export map (object). The . key is the default entrypoint. |
publish.include | No | Glob patterns for files to include. Overrides .gitignore. |
publish.exclude | No | Glob patterns for files to exclude. |
Add this to your jsr.json for editor autocompletion:
{
"$schema": "https://jsr.io/schema/config-file.v1.json"
}
Write JSR-compatible code
Import style
JSR packages must use explicit import specifiers with file extensions on all relative imports:
// JSR packages
import { encodeBase64 } from "jsr:@std/encoding@1/base64";
// npm packages
import { cloneDeep } from "npm:lodash@4";
// Node.js built-ins
import { readFile } from "node:fs";
// Relative imports — extension is required
import { greet } from "./greet.ts";
What is not allowed: require(), module.exports, export =, module augmentation via declare global, and extensionless relative imports.
Fix slow types
Slow types are TypeScript exports whose types JSR can’t extract without running the full TypeScript compiler. They degrade type-check speed for consumers and break JSR’s npm compatibility layer.
The three most common violations — and how to fix them:
// BAD — inferred return type
export function foo() {
return Math.random().toString();
}
// GOOD — explicit return type
export function foo(): string {
return Math.random().toString();
}
// BAD — inferred class property
export class MyClass {
prop = computeSomething();
}
// GOOD — explicit property type
export class MyClass {
prop: string = computeSomething();
}
// BAD — inferred constant
export const GLOBAL_ID = crypto.randomUUID();
// GOOD — explicit type annotation
export const GLOBAL_ID: string = crypto.randomUUID();
Quick check: if your code compiles cleanly with TypeScript’s isolatedDeclarations: true, you have no slow types.
deno publish --allow-slow-types exists as an escape hatch, but using it drops your JSR score in the Best Practices category and degrades npm consumers’ type experience. Don’t use it in production.
Publish your package
Dry run first — every time
deno publish --dry-run
# or
npx jsr publish --dry-run
--dry-run validates all rules without uploading anything. JSR versions are immutable — once published, a version cannot be overwritten or deleted. Dry-run before every release.
Publish from a local machine
deno publish
# or
npx jsr publish
Both commands open a browser window for authentication. You approve the specific package scope and version. No credentials are stored in the CLI.
Publish from GitHub Actions (recommended)
The GitHub Actions path is preferred because it generates a SLSA provenance attestation via Sigstore, visible in the Provenance section at the bottom of the package’s overview page — and it contributes to your Best Practices score.
- In your JSR package settings, link the GitHub repository.
- Create
.github/workflows/publish.yml:
name: Publish to JSR
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for OIDC provenance
steps:
- uses: actions/checkout@v4
- run: npx jsr publish
The id-token: write permission is what enables OIDC. Token-based publishing — even with a paid plan — never generates provenance attestations.
If you are weighing GitHub Actions against a dedicated CI service for your release pipeline, GitHub Actions vs CircleCI breaks down where each earns its place.
Token-based publishing (CI without OIDC)
# Create a token in JSR Account Settings → Tokens
# Store it as a repository secret, then:
npx jsr publish --token $JSR_TOKEN
This works but skips provenance. Use OIDC when you can.
Maximize your JSR score
Every package gets a score from 0–100 based on four categories. The score is visible on the package page and affects search ranking.
| Category | What it checks |
|---|---|
| Documentation | README present; module-level JSDoc; JSDoc on all exported functions and types |
| Best Practices | No slow types in exports; package published with OIDC provenance |
| Discoverability | Package has a non-empty description |
| Compatibility | At least one runtime marked “compatible” in package settings |
Steps to maximize the score:
- Add a
README.mdwith a usage example - Write a module-level JSDoc comment at the top of your main entry point
- Add JSDoc to every exported function, class, and type
- Annotate all exported types explicitly (eliminates slow types)
- Publish via GitHub Actions with
id-token: write - Go to package settings on jsr.io and mark runtime compatibility (Deno, Node.js, Bun, browser)
@std/encoding from the Deno Standard Library is a useful reference: it consistently scores near 100 and shows what full documentation coverage looks like in practice.
npm compatibility layer
You don’t need a separate publish step for npm users. Every JSR package is available via the @jsr npm scope — but you need to configure your package manager’s registry first.
The recommended path is npx jsr add, which writes the .npmrc entry automatically:
npx jsr add @std/encoding
npm and Bun do not auto-configure the @jsr scope; a bare npm install @jsr/std__encoding returns E404. pnpm, yarn, and vlt handle it automatically. To configure manually, add one line to .npmrc:
@jsr:registry=https://npm.jsr.io
Then install with your usual command:
# npm (needs .npmrc above)
npm install @jsr/std__encoding
# Bun (needs .npmrc above)
bun add @jsr/std__encoding
# pnpm (auto-configures)
pnpm add @jsr/std__encoding
The name mapping is: @scope/name on JSR → @jsr/scope__name on npm (note the double underscore).
If you are choosing a package manager for consuming these packages in your project, pnpm vs npm covers the concrete differences in install speed, disk usage, and monorepo support.
Under the hood, JSR rewrites jsr: and npm: specifiers, transpiles TypeScript to .js, and generates .d.ts files — all server-side, without running tsc. The result is published as a standard npm tarball under the @jsr scope.
Dual-publish to JSR and npm
If you have an existing npm audience or want to keep using import ... from "your-package" without the @jsr/ prefix, you can publish to both registries independently.
When to dual-publish: runtime-agnostic utilities (parsing, validation, formatting, hashing, pure logic) targeting Deno + Node.js + Bun + browsers.
When to skip npm: Deno-native code using Deno.* APIs that Node.js doesn’t have.
The dual-publish setup maintains two config files side by side:
jsr.json — TypeScript source, no build step:
{
"name": "@acme/mylib",
"version": "1.2.0",
"exports": "./src/index.ts"
}
package.json — compiled output after a build step:
{
"name": "mylib",
"version": "1.2.0",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}
GitHub Actions workflow:
- run: npx jsr publish # TypeScript source → JSR
- run: npm run build && npm publish # compiled JS → npm
Keep the versions in sync manually or via release-please. One hidden benefit of dual-publishing: JSR’s stricter validation often catches export map errors that npm accepts silently.
@hono/hono is the best real-world reference for this pattern — the Hono team publishes to both registries and uses JSR as the source of truth.
Gotchas
| Issue | What to do |
|---|---|
deno publish errors about unknown scope | Create the scope on jsr.io/new first — CLI can’t create it |
| File name rejected at publish time | Avoid :, *, ?, <, >, |, \ in file/directory names — JSR requires Windows-safe paths |
| Provenance tab missing after publish | Only GitHub Actions OIDC generates provenance; token-based publishing never does |
| Score stuck low despite documentation | Check runtime compatibility is set in package settings — it’s easy to miss |
--allow-slow-types was needed | Annotate explicit types on all exports instead; slow types hurt npm consumer experience |