Develop npm packages with Node.js and TypeScript following modern best practices. Use when: (1) Creating a new npm package, (2) Setting up package.json exports (dual ESM/CJS or ESM-only), (3) Configuring TypeScript for library authoring (Bundler or Node16 moduleResolution), (4) Building/publishing with tsup or tsc, (5) Creating CLI tools with bin field, (6) Testing with vitest, (7) CI/CD for npm publishing, (8) ESM/CJS interop issues, (9) Choosing a versioning / dist-tag / release-channel strategy — especially the pre-1.0 (0.x) ruling for what `latest` vs `next` should point at, how to tag prereleases, and avoiding the stale-`latest` footgun. Use this whenever the user mentions dist-tags, `latest`/`next`, prerelease tagging, 0.x versioning, or 'what version/release strategy should we use', even if they don't explicitly say 'npm package'. Keywords: npm package, publish to npm, library development, dist-tag, latest vs next, prerelease tagging, 0.x versioning, release strategy, semver channel.
Develop npm packages with Node.js and TypeScript following modern best practices. Use when: (1) Creating a new npm package, (2) Setting up package.json exports (dual ESM/CJS or ESM-only), (3) Configuring TypeScript for library authoring (Bundler or Node16 moduleResolution), (4) Building/publishing with tsup or tsc, (5) Creating CLI tools with bin field, (6) Testing with vitest, (7) CI/CD for npm publishing, (8) ESM/CJS interop issues, (9) Choosing a versioning / dist-tag / release-channel strategy — especially the pre-1.0 (0.x) ruling for what `latest` vs `next` should point at, how to tag prereleases, and avoiding the stale-`latest` footgun. Use this whenever the user mentions dist-tags, `latest`/`next`, prerelease tagging, 0.x versioning, or 'what version/release strategy should we use', even if they don't explicitly say 'npm package'. Keywords: npm package, publish to npm, library development, dist-tag, latest vs next, prerelease tagging, 0.x versioning, release strategy, semver channel.
Important: With Node16 resolution, all relative imports must include the .js extension (even for .ts source files): import { foo } from './utils.js'.
When to choose ESM-only over dual CJS/ESM
Your package targets Node.js >=18 (or >=22 where CJS can require() ESM natively)
You don't need CJS consumers
You want the simplest possible setup with tsc only (no bundler)
CLI Setup
package.json (CLI-specific fields)
{"bin":{"my-cli":"dist/cli.js"},"files":["dist"]}
Note: npm recommends bin paths without a ./ prefix ("dist/cli.js" not "./dist/cli.js"). Modern npm normalizes this automatically, but omitting ./ avoids warnings in older npm versions. Run npm pkg fix to check for issues.
Entry file (src/cli.ts)
#!/usr/bin/env nodeimport { program } from"commander";
program
.name("my-cli")
.version("1.0.0")
.description("Description here");
program
.command("init")
.option("-t, --template <name>", "template to use", "default")
.action((options) => {
console.log(`Template: ${options.template}`);
});
program.parse();
The default install is the latest dist-tag: a tagless npm install <pkg> (or pnpm add / pnpm dlx) dereferences latestdirectly — it is NOT a semver range match, so whatever latest points at is exactly what new consumers get, prerelease or not. Keeping latest on the newest shippable build is the whole game; never strand it on an old version.
Pre-1.0 (0.x) — ship clean 0.MINOR.PATCH straight to latest. Do not put a -next/-beta suffix on the everyday dev mainline. 0.x (major-zero) is itself SemVer's "anything may change" signal, so a breaking change rides a minor bump (0.2 → 0.3) and everything else a patch bump. Every release is then a clean, monotonically-increasing version that npm routes to latest automatically — a tagless install always gets the newest build, with no machinery to get stuck (esbuild, pre-1.0 Vite, Bun, Biome all do this).
Prereleases are an opt-in side channel, not the mainline. Reserve -alpha/-beta/-rc/-next plus the next (or canary) dist-tag for genuine previews — a 1.0.0-beta run-up, or a bleeding-edge line published ahead oflatest. next conventionally means "ahead of/distinct from latest" — never mirror it onto latest.
In CI, derive --tag from the version string and always pass it explicitly: hyphen → --tag next, clean X.Y.Z → --tag latest. npm ≥ 11 hard-errors when you publish a prerelease without --tag; npm ≤ 10 silently routed prereleases onto latest (a silent-downgrade footgun). Never rely on the implicit default for a prerelease. At 1.0.0 the normal stable/preview split resumes automatically under this same rule — no special-casing.
Detailed mechanics, the dual-tag "advance-latest" anti-pattern that strands latest, and ^0.x range gotchas: references/publishing.md.
Key Rules
exports Field
Always place types before default within each condition block
import condition for ESM, require condition for CJS
main/module/types at top level exist for backward compatibility with older tools
files Field
Always use files as a whitelist (not .npmignore). Set to ["dist"] to publish only build output. Verify with npm pack --dry-run.
prepublishOnly
Always include a prepublishOnly script to build (and ideally test) before publishing:
{"prepublishOnly":"npm run build && npm test"}
For tsc-only projects, you can call commands directly: "prepublishOnly": "tsc && vitest run".
Scoped Packages
For scoped packages (@myorg/pkg), configure public access via .npmrc in the project root:
access=public
Alternatively, use publishConfig in package.json:
{"publishConfig":{"access":"public"}}
sideEffects
Set "sideEffects": false for pure utility libraries to enable tree-shaking. If some files have side effects, list them: "sideEffects": ["*.css"].
Tree-Shaking
Use named exports (not default export of objects). Avoid classes when individual functions suffice.