Publishing TypeScript Packages
Why this matters
Publishing a TypeScript package is harder than it looks because consumers use it in three different environments: modern bundlers that support exports, legacy tools that only read main, and TypeScript itself that needs to find declaration files. Get the package.json shape wrong and your package works in your own project but silently breaks for consumers.
The second trap is auth: mixing pnpm publish quirks with GitLab's project-level registry in CI produces intermittent token failures. This recipe gives you a deterministic path through both npm and GitLab, covering the build, the package.json fields, registry auth, and versioning.
Prerequisites: a package that builds to dist/ (using tsup, tsdown, or tsc). For GitLab: a token with write_package_registry.
1. Shape package.json for registries
The fields below are what registries and package consumers actually read. Get these right before you think about the build tool.
{
"name": "@acme/service-client",
"version": "0.0.1",
"private": false, // MUST be false to publish
"type": "module",
"main": "./dist/index.cjs", // legacy CJS entry (for tools that don't read "exports")
"module": "./dist/index.mjs", // legacy ESM entry (for bundlers that don't read "exports")
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
},
"./package.json": "./package.json"
},
"files": ["dist", "README.md"], // allowlist — anything not listed is excluded from the tarball
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean"
}
}Key decisions:
exportsis the authoritative resolver for modern tools.main/module/typesare fallbacks for older ones.- For dual ESM/CJS, emit per-format
.d.mts/.d.ctsdeclaration files so TypeScript picks the right one per condition. filesis an allowlist, not a blocklist. Verify it withnpm pack --dry-runbefore the first real publish.
2. Build the library
Three options — pick based on your needs:
Option A: tsup (fastest, esbuild-based)
Best for most packages. Zero config to get started, fast due to esbuild.
pnpm add -D tsup
pnpm exec tsup src/index.ts --format esm,cjs --dts --cleanOption B: tsdown (rolldown-based, workspace-aware)
Better for monorepo libs that use the dev/default conditional-exports pattern — tsdown understands workspace source resolution.
// tsdown.config.ts
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
});Option C: raw tsc (one format only)
Use when you need full TypeScript compiler control or emit only ESM. For dual-format, you need two tsconfigs:
// tsconfig.build.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "outDir": "dist", "declaration": true, "module": "ESNext" },
"include": ["src/**/*"]
}3. Configure auth for your target registry
Public npm
Nothing scope-specific is needed. npm login covers it. On first publish of a scoped package, pass --access public (see step 4).
GitLab project-level registry
Add a .npmrc file in the package directory (not the repo root, unless all packages share the same registry):
@acme:registry=https://gitlab.com/api/v4/projects/12345678/packages/npm/
//gitlab.com/api/v4/projects/12345678/packages/npm/:_authToken=${NPM_AUTH_TOKEN}
always-auth=trueTwo important decisions here:
- Hardcode the numeric project id (
12345678) — do not derive it from$CI_PROJECT_ID. This way the.npmrcworks correctly in forks and scaffolded copies. always-auth=truesends the token on reads too, which is required for private scopes.
In CI, set the token from the job token (no secrets needed):
script:
- export NPM_AUTH_TOKEN="$CI_JOB_TOKEN"
- pnpm run build
- npm publish4. Publish
Public scope (first publish of a scoped package)
pnpm publish --access public--access public is required exactly once — for the first publish of a scoped package on the public npm registry. After that, the scope's access setting is remembered.
Private / GitLab registry
Add publishConfig to package.json so the registry and access level are always deterministic, regardless of the user's local .npmrc:
"publishConfig": {
"registry": "https://gitlab.com/api/v4/projects/12345678/packages/npm/",
"access": "restricted"
}Then publish normally — publishConfig overrides any user-level .npmrc:
npm publish
# or
pnpm publishPrerelease (rc on non-main branches)
Use a dist-tag so existing consumers on latest don't auto-upgrade to a prerelease:
npm version --no-git-tag-version "1.4.0-rc.20260601-1430"
npm publish --tag rcConsumers who want the prerelease install with @rc: pnpm add @acme/service-client@rc.
5. Versioning across a monorepo with Changesets
When multiple packages publish together, manually keeping versions in sync is error-prone. Changesets automates the version-bump-and-publish flow:
pnpm add -Dw @changesets/cli
pnpm exec changeset initThe workflow:
- During development: contributors run
pnpm exec changesetto record intent — which packages changed, how (patch/minor/major), and a human-readable summary. This creates a small file in.changeset/. - CI opens a version PR:
changeset versionreads those files, bumps each affectedpackage.json, and updatesCHANGELOG.md. - On merge:
changeset publishpublishes every package whose version moved.
The .changeset/ files are the audit trail — they live in source control and accumulate until the version PR is merged.
Variant: in-repo lib that is never published
For a libs/* package consumed only via workspace:*, skip the publish flow entirely. Use the dev/default conditional-exports pattern so the package resolves to TypeScript source during development and to the built dist in production:
{
"name": "@acme/geometry",
"type": "module",
"exports": {
".": {
"dev": "./src/index.ts", // resolved when the "dev" condition is active (dev server, tests)
"default": "./dist/index.mjs" // resolved everywhere else
},
"./package.json": "./package.json"
},
"publishConfig": {
"exports": {
".": "./dist/index.mjs" // applied only at publish time: strips the "dev" condition
}
}
}The dev server and test runner activate dev and read src/ directly — no rebuild needed on save. A prod consumer (or a published copy) resolves to dist.
Gotchas
- Use
npm publishoverpnpm publishfor GitLab. The build can bepnpm run build, butnpm publishhas fewer auth-token edge cases with GitLab's project registry. private: falseis required to publish. In-repoworkspace:*libs should keepprivate: true(or omitversion) so they're never published by accident.- Restore
package.jsonafternpm version. After an in-place version bump, restore the working tree:trap 'git checkout -- package.json' EXIT. - Hardcode the GitLab project id in both
.npmrcandpublishConfig.registry. Never derive from$CI_PROJECT_ID. - Verify
fileswithnpm pack --dry-runbefore the first real publish. It's easy to accidentally excludedist/or include secrets.