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.

reposrc + package.jsonbuild.js + .d.tspack + signfiles field picksregistryimmutable versionconsumerresolves exportsEvery decision — what to build, what toship, how to resolve — lives in package.json.
The publish pipeline. Your source is compiled (or not), a manifest decides which files go into the tarball, npm signs and uploads it, the registry stores an immutable version, and a consumer's tool resolves an entry point back out of it.

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" }
}
  • name is 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.
  • version must be valid semver. It’s the one field you cannot forget to change — the registry rejects a re-publish of an existing version.
  • type decides how .js files in your package are interpreted. "module" means every .js is ESM; omit it and they’re CommonJS. Setting it explicitly removes ambiguity for both your build and your consumers.
  • exports is the entry-point map — the most important field, and the whole next section.
  • types points at your top-level .d.ts so TypeScript users get autocomplete. (Modern setups put this inside exports instead; more below.)
  • files is an allowlist of what goes in the tarball.
  • sideEffects: false tells 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.

consumer loadsimport '@acme/pkg'walk top to bottom, first match winstypes ? → ./index.d.tsTS onlyimport ? → ./index.jsESM ✓require ? → ./index.cjsCJSdefault → ./index.jsfallbackresolved: ./index.jsOrder is not cosmetic. A resolver stops at the firstmatching key, so a misordered map (default before import)can hand ESM callers the wrong file. types is always first.
A subpath entering the exports map passes through an ordered set of condition gates. The resolver walks top to bottom and returns the first file whose condition matches how the consumer loaded the package. types must come first; default must come last.

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.

interactiveexports resolver

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.

dual-formatindex.js (ESM)index.cjs (CJS)two builds, twopossible copiesstate can desync between themESM-onlyindex.js (ESM)one build, onecopy, alwaysreach for old CJS-only toolsdualbundle sizeESM winsbuild complexityESM winsdual-package hazardESM avoidsworks in Node 20+bothDefault to ESM-only unless a known consumer is CJS-locked.
Dual-format maintains two parallel builds and risks two live copies of your module; ESM-only ships one build that every current runtime and bundler resolves. The trade is reach for old CommonJS-only setups versus simplicity everywhere else.

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:

  • files in 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.
  • .npmignore is a denylist — “ship everything except these.” One forgotten entry and a .env or 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.

CI runGitHub / GitLabcommit + workflowOIDC token(short-lived)npm registryverifies identitysigns + storesprovenance attestationversion page showsverified provenance badgeinstaller / scannerchecks it traces to buildNo stored publish token exists to steal — the credential lives only for the length of the CI job.
Trusted publishing. CI proves its identity to npm with a short-lived OIDC token tied to the workflow and commit — no long-lived token to leak. npm signs a provenance attestation and stores it beside the version, so any installer can verify the tarball traces back to that exact build.

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.

publish to npm.ts sourceyou build: tsc.js + .d.ts + mapregistrypublish to JSR.ts sourceno build stepregistry generates.js + .d.ts + docsJSR packages are ESM-only and installable from npm-based tools via a compatibility layer.
npm expects you to build first — compile TypeScript to JS plus declarations, hand-author an exports map, then publish. JSR takes your TypeScript source directly and generates the JS, the .d.ts files, and the docs on its side.

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-run and bump the version for every fix.
  • The publisher’s package.json is a contract: name (prefer a scope), valid semver version, type, an exports map, files as an allowlist, and bin for CLIs.
  • exports both encapsulates and routes. Unlisted files are private; conditional keys (types, import, require, default) resolve top-to-bottom, so types goes first and default last. 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 deprecate to warn without breaking anyone.