· npm / typescript / publishing

How to publish an npm package the right way in 2026

Dual ESM/CJS with tsup, a proper exports map, changesets for versioning, and provenance — the complete 2026 workflow that the top-ranking tutorials still miss.

By

1,450 words · 8 min read

The top-ranking tutorial for this query is from 2023. It teaches module.exports, skips TypeScript entirely, and predates npm provenance, the exports map, and dual ESM/CJS output. If you follow it today, you ship an npm package with no type-safe entry points, no supply-chain proof of origin, and broken module resolution on Node 22+. This tutorial covers what’s actually correct in 2026.

Who this is for

You’re shipping a TypeScript library to npm — a utility package, a framework plugin, something other people will npm install. You want it to work in both ESM and CommonJS projects, come with types, and be verifiable with npm audit signatures. If you’re writing an app, not a library, most of this doesn’t apply.

What you’ll need

  • Node ≥ 22.14.0 and npm CLI ≥ 11.5.1 (check with node -v and npm -v)
  • A GitHub account (for provenance via OIDC or classic Granular Access Tokens)
  • An npm account (npm login at npmjs.com)
  • pnpm is used in the examples (pnpm vs npm — what actually changes when you switch); npm and Yarn work the same way for the publish step

Step 1 — Set up your package.json with a proper exports map

The exports field replaces the old main and module fields. It tells Node exactly which file to load for each import style, and it prevents consumers from deep-importing private internals.

{
  "name": "my-package",
  "version": "1.0.0",
  "type": "module",
  "files": ["dist"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

Three rules that bite people:

types must be first. TypeScript resolves exports conditions in order. Put types anywhere else and TypeScript may pick the wrong entry point.

Any path not in exports throws ERR_PACKAGE_PATH_NOT_EXPORTED. That’s intentional — it enforces the public API boundary. If a consumer was importing my-package/src/utils, they can’t anymore. Add a subpath export if you mean to expose it:

"./utils": {
  "types": "./dist/utils.d.ts",
  "import": "./dist/utils.mjs",
  "require": "./dist/utils.cjs"
}

ESM-only is fine for Node 22+ projects. Drop the require condition if none of your consumers are on CommonJS. Dual output is for library authors supporting legacy tooling. If you control who installs your package, ESM-only is simpler and correct.

Step 2 — Build dual ESM/CJS output with tsup

tsup is the shortest path to dual output. Install it:

pnpm add -D tsup

Create tsup.config.ts:

import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  clean: true,
  outExtension({ format }) {
    return format === 'esm' ? { js: '.mjs' } : { js: '.cjs' };
  },
});

What each option does:

  • format: ['esm', 'cjs'] — emits both .mjs and .cjs
  • dts: true — generates *.d.ts declaration files alongside the output
  • clean: true — wipes dist/ before each build so stale files don’t survive
  • outExtension — forces explicit .mjs/.cjs extensions instead of .js. This is required when "type": "module" is set in package.json, otherwise Node can’t tell ESM from CJS by extension alone

Run the build:

pnpm tsup

Output:

dist/
  index.mjs      ← ESM
  index.cjs      ← CJS
  index.d.ts     ← TypeScript declarations

That maps directly to the exports block from Step 1. Add a build script to package.json:

"scripts": {
  "build": "tsup"
}

Forward-looking note: tsdown is gaining traction as a Rolldown-based alternative to tsup. Same API surface, faster builds. Worth watching if you hit tsup performance limits on large packages, but tsup is the stable default today.

Step 3 — Add changesets for versioning

Manual version bumps and hand-edited CHANGELOGs break at any scale. Changesets ties version bumps to the intent of each change.

Install and initialize:

pnpm add -D @changesets/cli && pnpm changeset init

This creates a .changeset/ directory. Commit it. The config file it generates is fine at defaults for most packages.

The daily workflow — run this after any change worth shipping:

pnpm changeset

It asks which packages changed, what kind of bump (patch, minor, major), and a one-line summary. It writes a markdown file into .changeset/. Commit that file alongside your code change. That’s it until release.

When you’re ready to release:

pnpm changeset version   # reads all changeset files, bumps versions, writes CHANGELOG.md
pnpm changeset publish   # builds, then runs npm publish for each package

For pnpm + Turborepo monorepos with multiple packages, this handles inter-package version coordination automatically.

GitHub Actions automation — add this to your release workflow:

- name: Create Release Pull Request or Publish to npm
  uses: changesets/action@v1
  with:
    publish: pnpm release
  env:
    NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Add "release": "pnpm changeset publish" to package.json scripts. When changesets exist on main, the action opens a “Version Packages” PR. Merging that PR triggers the publish. You don’t touch npm publish directly.

Step 4 — Authenticate the right way

As of November 2025, only Granular access tokens are supported — legacy access tokens have been removed (source). If your CI still has NPM_TOKEN set to a classic automation token, it will stop working — if it hasn’t already.

Two options in 2026:

Option A: Granular Access Token (simpler)

Go to npmjs.com → Account Settings → Access Tokens → Generate New Token → Granular Access Token.

Select the specific packages this token can publish (don’t scope it wider than needed), set an expiry, and copy the token. Add it to your GitHub Actions secrets as NPM_TOKEN. The changesets workflow from Step 3 picks it up automatically.

Granular tokens scope down the blast radius if the token leaks. Classic tokens could publish or deprecate anything in your account.

Option B: npm Trusted Publishing (no token at all)

Trusted Publishing uses OIDC to let GitHub Actions prove its identity directly to npm — no NPM_TOKEN stored anywhere.

Requirements: npm CLI ≥ 11.5.1, Node ≥ 22.14.0, and the package already published at least once (or created on npmjs.com before the first automated publish).

Set up on npmjs.com → your package → Settings → Trusted Publishers → Add Publisher. Enter your GitHub organization/user and repository name. Then in your GitHub Actions workflow, drop NPM_TOKEN entirely and add the OIDC permission:

permissions:
  id-token: write
  contents: read

Trusted Publishing also generates provenance automatically — you don’t need --provenance separately. This is the forward-looking default for 2026 and beyond.

Step 5 — Publish your npm package with provenance

Provenance lets consumers run npm audit signatures my-package and verify the package was built from a specific commit on a specific repository in a verified CI environment. It’s free on all accounts.

If you’re using Trusted Publishing (Option B above), provenance is automatic. Skip this step.

If you’re using a Granular Access Token (Option A), add --provenance to your publish command:

npm publish --provenance

Or in your package.json release script:

"release": "pnpm changeset publish --no-git-checks -- --provenance"

After publishing, verify it worked:

npm audit signatures my-package

Output with a successful attestation:

audited 1 package in 1s

1 package has a verified registry signature

First publish — two things to know

Scoped packages: be explicit on first publish. npm v11 defaults to public access for new packages, but being explicit avoids surprises. If you want the package public:

npm publish --access public

If you want it private (a paid feature), use --access restricted.

The first publish must use a token, not Trusted Publishing. OIDC Trusted Publishing requires the package to already exist on the registry. Create the package with a Granular Access Token first, then switch to Trusted Publishing for all future releases.

Caveats

pnpm publish vs npm publish — both accept --provenance. The --provenance flag behavior is identical between the two.

ESM-only is fine if you control your consumers. Dual output adds complexity. If you’re publishing a Nuxt plugin, a Vite plugin, or anything targeting Node 22+ exclusively, drop the require condition. One format, one entry point.

tsup alternatives — if tsup’s esbuild-based output conflicts with your type-checking setup (unusual but it happens with complex generics), try unbuild. It’s slower but uses rollup-plugin-typescript2 for stricter type fidelity.

allowExcessArguments irrelevant here — that’s a Commander.js gotcha. Not an npm publishing concern.

References

SourceURL
npm access tokens (official)docs.npmjs.com
npm publish docs (CLI v11)docs.npmjs.com/cli/v11
npm provenance docsdocs.npmjs.com
npm Trusted Publishersdocs.npmjs.com
Node.js packages / exportsnodejs.org/api/packages
tsup docstsup.egoist.dev
changesets introgithub.com/changesets
changesets/actiongithub.com/changesets/action
Bootstrapping npm provenance w/ GitHub Actions (Jan 2026)thecandidstartup.org