Publishing a Package: exports, Provenance & JSR
You wrote a utility, it works, and three teammates have already copy-pasted it into their projects. That copy-paste is the signal: it’s time to publish. The moment you do, the file layout inside your repo stops being your private business. A stranger’s bundler, their TypeScript compiler, and their require() call all have to reach into your package and find the right file — and if the map you ship is wrong, they get a red error before they’ve written a single line against your API.
This is the publisher’s side of the story. If you’ve read Modules, npm & Semantic Versioning, you know what package.json and a version range mean from the consumer’s seat. Here you’re on the other side of the counter: deciding exactly which files ship, which entry points exist, what a require gets versus what an import gets, and how the registry can prove the tarball came from your CI and not a compromised laptop.
From repo to registry
Publishing is a short pipeline with a few decisions baked into each step. Most of the mistakes happen because people picture it as one command (npm publish) instead of the five things that command actually depends on.
The registry step has one property worth memorizing: a published version is immutable. Once [email protected] exists, those exact bytes are frozen forever. You can’t edit them, and you can’t reuse that version number even after unpublishing. That single rule shapes everything else — it’s why you preview before you publish, and why version bumps are the only way to ship a fix.
The publisher’s package.json
A package.json you only ever install from can be sloppy. A package.json you publish is a contract. These are the fields that make up that contract.
{
"name": "@acme/parse-duration",
"version": "2.1.0",
"type": "module",
"exports": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"sideEffects": false,
"engines": { "node": ">=20" }
}
nameis your identity on the registry. It’s either a plain name (parse-duration, first-come-first-served across the whole registry) or scoped (@acme/parse-duration, namespaced under a user or org). Scopes are free and dodge the “every good name is taken” problem, so most new packages use them.versionmust be valid semver. It’s the one field you cannot forget to change — the registry rejects a re-publish of an existing version.typedecides how.jsfiles in your package are interpreted."module"means every.jsis ESM; omit it and they’re CommonJS. Setting it explicitly removes ambiguity for both your build and your consumers.exportsis the entry-point map — the most important field, and the whole next section.typespoints at your top-level.d.tsso TypeScript users get autocomplete. (Modern setups put this insideexportsinstead; more below.)filesis an allowlist of what goes in the tarball.sideEffects: falsetells bundlers your modules can be tree-shaken freely — importing one thing won’t secretly run code elsewhere. Set it only if it’s true.
The exports map
For years, packages advertised their entry point with "main": "./index.js". The problem: main names one file for everyone, and it lets consumers reach any internal file by deep path (import "your-pkg/lib/secret/impl.js"), so nothing was ever truly private. The exports field replaced it with something stricter and far more capable.
Two things happen at once when you use exports. First, encapsulation: any file you don’t list becomes unreachable from outside the package. Your public API is exactly what the map says, no more. Second, conditional resolution: for a single entry point you can hand back different files depending on how the consumer arrived — an import, a require, a type-check, or a specific runtime.
Here’s a fully specified map with two entry points — the package root and a ./utils subpath:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"default": "./dist/utils.js"
},
"./package.json": "./package.json"
}
}
Read each nested object as a list of gates checked in order. types is listed first because TypeScript’s resolver takes the first match too, and it needs the declaration file before anything else. default is listed last as the catch-all. Everything between them targets a specific loader — import for ESM, require for CommonJS, and you can also key on runtimes like node, browser, deno, or worker.
Try the resolver
Type a condition (import, require, types) or a subpath (., ./utils) and watch the map resolve it to a concrete file — the same top-to-bottom walk a bundler does.
ESM-only vs dual-format
The hardest decision in publishing today is which module formats to ship. There are two live strategies.
Dual-format ships both an ESM build and a CommonJS build, wired up through import/require conditions. It reaches the widest set of consumers — including old CommonJS-only tools — but you pay for that reach. You maintain two builds, your bundle roughly doubles, and you expose yourself to the dual-package hazard: if a program loads both your ESM and your CJS copy, they’re different module instances with separate state, so instanceof checks and shared singletons silently break.
ESM-only ships one ESM build and nothing else. Node has supported ESM natively for years, every current bundler resolves it, and CommonJS code can still reach ESM through dynamic import(). The build is simpler, the tarball is smaller, and the dual-package hazard can’t happen because there’s only one copy.
As of mid-2026, the momentum is firmly behind ESM-only for new packages. Popular libraries have gone ESM-only, Node’s own guidance leans that way, and the tooling that once forced dual builds has mostly caught up. Ship ESM-only unless you know a specific consumer is stuck on CommonJS and can’t call import().
Preview the tarball before you ship
Because a published version is frozen, you want to see exactly what’s going into it first. npm pack builds the tarball without uploading and prints the file list:
npm pack --dry-run
npm notice 📦 @acme/[email protected]
npm notice === Tarball Contents ===
npm notice 1.2kB package.json
npm notice 4.8kB dist/index.js
npm notice 0.9kB dist/index.d.ts
npm notice 2.1kB README.md
npm notice === Tarball Details ===
npm notice package size: 3.1 kB
npm notice unpacked size: 9.0 kB
npm notice total files: 4
Scan that list every time. The two classic bugs both show up here: your source .ts files or tests leaking into the tarball (bloat, and sometimes secrets), or your dist folder missing entirely because the build didn’t run before pack. Catch it now, not from a bug report.
files vs .npmignore
There are two ways to control the file list, and they’re mutually exclusive in spirit:
filesin package.json is an allowlist — “ship only these.” Safer by default, because a new file you add isn’t published unless you opt it in..npmignoreis a denylist — “ship everything except these.” One forgotten entry and a.envor a scratch file rides along.
Prefer files. A few things ship no matter what you write (package.json, README, LICENSE), and a few are always excluded (node_modules, .git), but for everything else the allowlist is the one you want.
Scopes, access, and the version bump
Scoped packages are private by default — the first publish of @acme/thing will fail unless you say otherwise:
npm publish --access public
The version bump itself has a helper. npm version edits package.json, and in a git repo it also commits and tags:
npm version patch # 2.1.0 -> 2.1.1 bug fix, no API change
npm version minor # 2.1.0 -> 2.2.0 new feature, backwards compatible
npm version major # 2.1.0 -> 3.0.0 breaking change
Match the bump to what actually changed — that promise is the entire contract behind a consumer’s ^2.1.0 range. Break it and you break installs downstream.
Provenance: proving where the tarball came from
Here’s the uncomfortable question a careful installer asks about your package: how do I know these bytes were built from the source you claim, and not slipped in by an attacker who phished your npm token? For years the honest answer was “you don’t.” A leaked publish token was game over — it let anyone push a malicious version under your name.
Provenance closes that gap. When a package is published with provenance, the registry stores a signed, verifiable statement linking the tarball to the exact CI run and git commit that produced it. The consumer — or a security scanner — can check that link. And the cleanest way to generate it is trusted publishing, where your CI authenticates to npm with a short-lived OIDC token instead of a stored secret.
A minimal GitHub Actions job that publishes with provenance looks like this:
name: publish
on:
release:
types: [published]
permissions:
contents: read
id-token: write # lets the job mint an OIDC token
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm run build
- run: npm publish # trusted publishing infers OIDC + provenance
The pieces that matter: id-token: write is what allows the runner to request an OIDC token, and you configure the trusted publisher once on the package’s npm settings page (naming the repo and workflow that’s allowed to publish). With that in place, npm publish needs no NODE_AUTH_TOKEN at all, and npm attaches provenance automatically — you don’t even pass --provenance.
JSR: the TypeScript-native registry
npm assumes you ship compiled JavaScript. If your source is TypeScript, that means a build step: run tsc, generate .d.ts files, wire up an exports map, and hope the declaration files line up with the JavaScript. Getting that right — especially for dual-format — is genuinely fiddly, and it’s a big part of why this article exists.
JSR takes a different stance. It’s an open-source registry (launched in 2024, since 2025 steered by an independent board that includes npm’s own creator) built around one idea: you publish TypeScript source, and the registry does the rest. It generates the .d.ts files, transpiles for cross-runtime use, and builds API docs straight from your types and JSDoc — with no build step for you to run or commit.
A JSR package is configured with a jsr.json (or a deno.json) rather than the full package.json dance:
{
"name": "@acme/parse-duration",
"version": "2.1.0",
"exports": "./mod.ts"
}
You publish with a runner from whichever toolchain you use — no token to paste:
npx jsr publish # or: deno publish / pnpm dlx jsr publish
npx jsr publish --dry-run # preview the file list, publish nothing
Two design choices are worth calling out because they’re deliberate reactions to npm’s history. JSR packages are ESM-only — there’s no CommonJS path at all, which sidesteps the whole dual-format problem. And there are no install or post-install scripts, ever — the registry is built so a package can’t run code on your machine at install time, and every published version is immutable bytes.
Deprecation and the unpublish rules
Because versions are immutable, “I need to take this back” has specific, limited answers.
Unpublishing is deliberately restricted, and for good reason — the ecosystem learned the hard way that yanking a popular package breaks the internet. You can remove a version only within 72 hours of publishing it, and only if nothing else on the registry depends on it. After that window, the version is effectively permanent. And even an unpublished version number is burned forever: you can never republish [email protected], you must move to a new version.
npm unpublish @acme/[email protected] # only within 72h, no dependents
Deprecation is the tool you’ll actually use most. It doesn’t remove anything — the version stays installable so you don’t break existing builds — but it attaches a warning that consumers see when they install:
npm deprecate @acme/parse-duration@"<2.0.0" "v1 is unmaintained; upgrade to v2"
npm deprecate @acme/parse-duration "package moved to @acme/duration"
That’s the graceful path: mark the old thing, point people at the new thing, and let their builds keep working while they migrate. Reserve unpublish for the genuine emergencies — a leaked secret, a broken publish minutes old — inside the 72-hour window.
Summary
- Publishing freezes bytes. A published version is immutable and its number is never reusable, so preview with
npm pack --dry-runand bump the version for every fix. - The publisher’s
package.jsonis a contract:name(prefer a scope), valid semverversion,type, anexportsmap,filesas an allowlist, andbinfor CLIs. exportsboth encapsulates and routes. Unlisted files are private; conditional keys (types,import,require,default) resolve top-to-bottom, sotypesgoes first anddefaultlast. Changing the map is a breaking change.- Default to ESM-only for new packages — simpler build, smaller tarball, no dual-package hazard. Keep dual-format only for foundational libraries with CommonJS-locked consumers.
- Publish from CI with trusted publishing. OIDC replaces a stealable token and npm attaches verifiable provenance automatically; classic tokens are deprecated as of December 2025.
- JSR takes TypeScript source and generates JS,
.d.ts, and docs with no build step — ESM-only, no install scripts, immutable. Pick it for TS-first and cross-runtime work; npm still owns the broad ecosystem. - You can’t take much back. Unpublish only within 72 hours with no dependents; otherwise
npm deprecateto warn without breaking anyone.