Generating a TypeScript SDK is the easy half. Publishing it cleanly so customers can npm install it and have it just work — ESM and CJS, typed, with a reasonable README and a sensible version number — is where most SDKs fail. This is the short, opinionated version.
The package.json that actually works in 2026
{
"name": "@your-co/sdk",
"version": "1.0.0",
"description": "Official TypeScript SDK for Your-Co API",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"files": ["dist", "README.md"],
"engines": { "node": ">=18" },
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/your-co/sdk-typescript"
},
"homepage": "https://docs.your-api.com",
"publishConfig": {
"access": "public",
"provenance": true
}
}
What each piece matters for:
type: "module"— tells Node your.jsfiles are ESM. Without this, ESM consumers double-load.exportsmap — the modern, strict alternative tomain/module. Conditional exports (importvsrequire) means TypeScript, Node ESM, Node CJS, and bundlers all pick the right file.typesfirst inside each export — TypeScript's module resolver needs this ordering to find type declarations correctly.files: ["dist", "README.md"]— explicit allowlist. Stops you from accidentally publishing the entire repo (.env, test fixtures, internal scripts).provenance: true— npm provenance signs the package with your CI's identity. Adoption is rising; customers in regulated environments increasingly require it.
Dual ESM/CJS without the headache
The cleanest 2026 pattern is to build both formats from one TypeScript source:
{
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.6.0"
}
}
tsup (or unbuild, or hand-configured esbuild) emits dist/index.js (ESM), dist/index.cjs (CJS), and dist/index.d.ts (types) in one pass. The exports map above routes each consumer to the right one.
Avoid: shipping ESM-only without thinking about CJS consumers. Most enterprise codebases still have CJS code paths somewhere. Dual-format is two extra lines of config and saves a lot of support tickets.
Semver, honestly
Three rules that hold up in practice:
- A change in any exported type's public shape is breaking. Adding a required field to a request type, removing a property from a response type, narrowing a union — all major bumps.
- A change in runtime error class structure is breaking. Renaming
APIError→ClientError, changing the property names, removing a status field. Major bump. - A new optional field, a new method, a new error code — minor. Bug fix that doesn't change shape — patch.
The most common semver mistake: treating "I changed the generated code, but the public surface is identical" as a patch. It is a patch. The mistake is the other direction — bumping minor when the surface actually changed in a way that breaks customers.
Tooling to enforce this: SDK compatibility reports catch surface drift before publishing. Bloom runs one on every release.
README that pulls its weight
Five sections, in this order:
- Install —
npm install @your-co/sdkand nothing else. - Quickstart — one ten-line snippet that shows auth + one real call.
- Authentication — bearer / OAuth / API-key, with environment variable convention.
- Errors — what gets thrown, what the shape is, how to catch.
- Versioning — link to changelog, link to docs, semver policy in one paragraph.
Everything else (full API reference, every parameter) lives in your hosted docs, not the README. The README is what npm renders on the package page; keep it short and high-signal.
Publishing — the actual commands
# 0. Make sure CI built dist/ (or build locally for the first publish)
npm run build
# 1. Dry-run to see what would ship
npm pack --dry-run
# 2. Verify the tarball contents (look for accidental .env, fixtures, etc)
# npm pack --dry-run prints the file list; review it.
# 3. Login to npm (one-time per machine)
npm login
# 4. Publish (with 2FA OTP — npm requires for new packages)
npm publish --otp=XXXXXX
# 5. Verify on the registry
npm view @your-co/sdk version
For scoped packages (@your-co/sdk), the first publish needs --access public unless you want a private package. Subsequent publishes don't.
2FA: required, not optional
npm now enforces 2FA for publishing to most accounts. If you don't have it set up:
- Enable on https://www.npmjs.com/settings/~/profile
- Pick TOTP (authenticator app) or WebAuthn (hardware key). WebAuthn is faster to use if you're publishing often.
- The CLI will prompt for the OTP on each publish;
--otp=XXXXXXskips the prompt.
For CI publishing (e.g. release-please + GitHub Actions), use a granular access token with publish scope, scoped to the specific package, and set NPM_TOKEN as a repo secret. Provenance via --provenance requires (a) a public repository, (b) a repository field in package.json matching the publish source, (c) a supported cloud-hosted CI runner with OIDC — self-hosted runners aren't supported, and Sigstore will emit cryptic errors if any of the three are missing.
What .npmignore should look like
Don't use .npmignore — use files in package.json (shown above). .npmignore is a denylist and has the same accidental-ship-everything failure mode as .gitignore. files is an allowlist and only ships what you list.
Want spec → SDK → npm as one workflow?
Try Bloom free for 30 days — Bloom generates the TypeScript SDK from your OpenAPI spec, builds it dual-format with the right exports map, runs a compatibility report against your last published version, and can publish to npm from the dashboard. Completely free for 30 days. No credit card required.