npm Package Development (Bun-First) Build and publish npm packages using Bun as the primary runtime and toolchain, producing output that works everywhere npm packages are consumed. When to Use This Skill Use when: Creating a new npm library package from scratch Setting up build/test/lint tooling for an existing package Fixing CJS/ESM interop, exports map, or TypeScript declaration issues Publishing a package to npm Reviewing or improving package configuration Do NOT use when: Building an npx-executable CLI tool (use the npx-cli skill) Building an application (not a published package) Working in a monorepo (this skill targets single-package repos) Toolchain Concern Tool Why Runtime / package manager Bun Fast install, run, transpile Bundler Bunup Bun-native, dual output, .d.ts generation Type declarations Bunup (via tsc) Integrated with build TypeScript module: "nodenext" , strict: true + extras Maximum correctness for published code Formatting + basic linting Biome v2 10-25x faster than ESLint, single tool Type-aware linting ESLint + typescript-eslint 40+ type-aware rules Biome can't do Testing Vitest Test isolation, mature mocking, coverage Versioning Changesets File-based, explicit, monorepo-ready Publishing npm publish --provenance Trusted Publishing / OIDC Scaffolding a New Package Run the scaffold script to generate a complete project: bun run < skill-path
/scripts/scaffold.ts ./my-package \ --name my-package \ --description "What this package does" \ --author "Your Name" \ --license MIT Options: --dual — Generate dual CJS/ESM output (default: ESM-only) --no-eslint — Skip ESLint, use Biome only Then install dependencies: cd my-package bun install bun add -d bunup typescript vitest @vitest/coverage-v8 @biomejs/biome @changesets/cli bun add -d eslint typescript-eslint
unless --no-eslint
Project Structure
my-package/
├── src/
│ ├── index.ts # Package entry point — all public API exports here
│ └── index.test.ts # Tests co-located with source
├── dist/ # Built output (gitignored, included in published tarball)
├── .changeset/
│ └── config.json
├── package.json
├── tsconfig.json
├── bunup.config.ts
├── biome.json
├── eslint.config.ts # Type-aware rules only
├── vitest.config.ts
├── .gitignore
├── README.md
└── LICENSE
Critical Configuration Details
Read these reference docs before modifying any configuration. They contain the reasoning behind each decision and the sharp edges that cause subtle breakage:
reference/esm-cjs-guide.md
—
exports
map configuration, dual package hazard,
module-sync
, common mistakes
reference/strict-typescript.md
— tsconfig rationale, Biome rules, ESLint type-aware rules, Vitest config
reference/publishing-workflow.md
— Changesets,
files
field, Trusted Publishing, CI pipeline
Key Rules (Non-Negotiable)
These are the rules that, when violated, cause the most common and painful bugs in published packages. Follow these without exception.
Package Configuration
Always use
"type": "module"
in package.json.
ESM-only is the correct default.
require(esm)
works in all supported Node.js versions.
Always use
exports
field, not
main
.
main
is legacy.
exports
gives precise control over what consumers can access.
types
must be the first condition
in every exports block. TypeScript silently fails to resolve types if it isn't.
Always export
"./package.json": "./package.json"
.
Many tools need access to the package.json and
exports
encapsulates completely.
Use
files: ["dist"]
in package.json.
Whitelist approach prevents shipping secrets. Never use
.npmignore
.
Run
npm pack --dry-run
before every publish.
Verify the tarball contains exactly what you intend.
TypeScript
Use
module: "nodenext"
for published packages.
Not
"bundler"
. Code satisfying nodenext works everywhere; the reverse is not true.
strict: true
is non-negotiable.
Without it, your .d.ts files can contain types that error for consumers using strict mode.
Enable
noUncheckedIndexedAccess
.
Catches real runtime bugs from unguarded array/object access.
Ship
declarationMap: true
.
Enables "Go to Definition" to reach original source for consumers.
Do not use path aliases (
paths
) in published packages.
tsc does not rewrite them in emitted code. Consumers can't resolve them.
Code Quality
any
is banned.
Use
unknown
and narrow. Suppress with
// biome-ignore suspicious/noExplicitAny:
=20.19.0 in package.json. This documents the minimum supported Node.js version (first LTS with stable require(esm) ). Testing Use Vitest, not bun:test. bun:test lacks test isolation — module mocks leak between files. Vitest runs each test file in its own worker. Set coverage thresholds (branches, functions, lines, statements all ≥ 80%). Enforced in vitest.config.ts. Development Workflow
Write code and tests
bun run test:watch
Vitest watch mode
Check everything
bun run lint
Biome + ESLint
bun run typecheck
tsc --noEmit
bun run test
Vitest run
Build
bun run build
Bunup → dist/
Prepare release
bunx changeset
Create changeset describing changes
bunx changeset version
Bump version, update CHANGELOG
Publish
bun run release
Build + npm publish --provenance
Adding Subpath Exports When the package needs to expose multiple entry points: Add the source file: src/utils.ts Add to bunup.config.ts entry: entry: ['src/index.ts', 'src/utils.ts'] Add to package.json exports: { "exports" : { "." : { "types" : "./dist/index.d.ts" , "default" : "./dist/index.js" } , "./utils" : { "types" : "./dist/utils.d.ts" , "default" : "./dist/utils.js" } , "./package.json" : "./package.json" } } Reminder: Adding or removing export paths is a semver-major change. Switching to Dual CJS/ESM Output If consumers require CJS support for Node.js < 20.19.0: Update bunup.config.ts: format: ['esm', 'cjs'] Update package.json exports to include module-sync , import , and require conditions See reference/esm-cjs-guide.md for the exact exports map structure Bun-Specific Gotchas bun build does not generate .d.ts files. Use Bunup (which delegates to tsc) or run tsc --emitDeclarationOnly separately. bun build CJS output is experimental. Always use target: "node" for npm-publishable CJS. target: "bun" produces Bun-specific wrappers. bun build does not downlevel syntax. Modern ES2022+ syntax ships as-is. If targeting older runtimes, additional transpilation is needed. bun publish does not support --provenance . Use npm publish for provenance signing. bun publish uses NPM_CONFIG_TOKEN , not NODE_AUTH_TOKEN . CI pipelines may need adjustment.