npm-package
npm and pnpm package authoring expertise. Covers tsup and unbuild bundling, dual ESM/CJS exports, package.json fields (main, module, exports, types, bin, files), semver discipline, changesets for versioning, monorepo setup with pnpm workspaces and turborepo, npm publish workflow, scoped packages, peer dependencies, tree-shaking optimization, package provenance, and .npmrc configuration. Use when creating npm packages, configuring exports, setting up monorepos, or publishing to npm.
You are a senior JavaScript package author who ships library code that works everywhere with zero configuration headaches for consumers.
Use this skill when
- Creating a new npm package from scratch
- Configuring package.json exports for dual ESM/CJS
- Setting up tsup or unbuild for library bundling
- Managing a monorepo with pnpm workspaces
- Publishing packages to npm (public or private)
- Debugging "Cannot find module" or "ERR_REQUIRE_ESM" in consumers
- Setting up changesets for automated versioning
Package.json: The Complete Field Reference
{
"name": "@scope/my-lib",
"version": "1.0.0",
"description": "One line, searchable on npm",
"license": "MIT",
"author": "Name <email>",
"repository": { "type": "git", "url": "https://github.com/user/repo" },
"type": "module", // ESM by default. Omit for CJS-first.
"main": "./dist/index.cjs", // CJS entry (Node <12, bundlers fallback)
"module": "./dist/index.js", // ESM entry (bundlers: webpack, rollup)
"types": "./dist/index.d.ts", // TypeScript declarations
"exports": { // Modern entry point resolution (Node 12+)
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
},
"./utils": {
"import": { "types": "./dist/utils.d.ts", "default": "./dist/utils.js" },
"require": { "types": "./dist/utils.d.cts", "default": "./dist/utils.cjs" }
}
},
"files": ["dist", "README.md"], // Whitelist what gets published. Nothing else ships.
"bin": { "my-cli": "./dist/cli.js" },
"sideEffects": false, // Enables tree-shaking in bundlers
"engines": { "node": ">=18" },
"keywords": ["relevant", "search", "terms"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"prepublishOnly": "pnpm build",
"release": "changeset publish"
}
}
Critical ordering in exports: types MUST come first in each condition block. Node resolves top-down and TypeScript needs to find declarations before the runtime file.
Bundling with tsup
tsup is the gold standard for library bundling. Zero-config, built on esbuild.
// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/utils.ts"],
format: ["esm", "cjs"],
dts: true, // Generate .d.ts files
splitting: true, // Code-split shared chunks (ESM only)
clean: true, // rm dist/ before build
treeshake: true, // Dead code elimination
sourcemap: true,
minify: false, // Don't minify libraries. Let consumers decide.
target: "node18",
outDir: "dist",
external: ["react", "react-dom"], // Never bundle peer deps
});
unbuild Alternative
Use unbuild when you want passive (stub) builds during development:
// build.config.ts
import { defineBuildConfig } from "unbuild";
export default defineBuildConfig({
entries: ["src/index"],
declaration: true,
clean: true,
rollup: {
emitCJS: true,
inlineDependencies: false,
},
});
Run unbuild --stub for development -- it creates a proxy that imports directly from source. No rebuild loop needed.
Dual ESM/CJS: Getting It Right
The most common source of package bugs. Follow these rules exactly:
- Set
"type": "module"in package.json. Your source is ESM. - tsup outputs
.js(ESM) and.cjs(CJS) when format is["esm", "cjs"]. - Map exports explicitly. Never rely on auto-resolution.
- Test both paths:
# Test ESM
node --input-type=module -e "import { foo } from './dist/index.js'; console.log(foo)"
# Test CJS
node -e "const { foo } = require('./dist/index.cjs'); console.log(foo)"
Common Pitfalls
- Missing
.cjsextension: Iftype: "module", CJS files MUST use.cjs. Node will try to parse.jsas ESM. - Default export interop: CJS
module.exports = xbecomesimport x from "pkg"in ESM, butimport { default as x }in some bundlers. Prefer named exports for libraries. - Conditional exports mismatch: If
exportsfield exists,mainandmoduleare IGNORED by Node. They only serve as fallbacks for old bundlers.
Semver Discipline
- MAJOR (1.0.0 -> 2.0.0): Any breaking change. Removed exports, changed function signatures, dropped Node version support.
- MINOR (1.0.0 -> 1.1.0): New features, new exports. Everything existing still works.
- PATCH (1.0.0 -> 1.0.1): Bug fixes only. No new API surface.
Pre-1.0 (0.x.y): minor = breaking, patch = features. Get to 1.0 as fast as possible.
What counts as breaking:
- Removing or renaming an exported function/type
- Changing parameter order or types
- Narrowing accepted input or widening output types
- Dropping a Node.js version from
engines - Changing default behavior
Changesets for Versioning
pnpm add -D @changesets/cli
pnpm changeset init
Workflow:
- Developer runs
pnpm changesetand selects packages + bump type + writes summary. - This creates
.changeset/<random>.md-- committed with the PR. - On merge to main, CI runs
changeset version(consumes changesets, bumps versions, updates CHANGELOG). - Then
changeset publishpushes to npm.
# .github/workflows/release.yml
- uses: changesets/action@v1
with:
publish: pnpm changeset publish
version: pnpm changeset version
commit: "chore: release"
title: "chore: release"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
Monorepo with pnpm Workspaces
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"
// packages/core/package.json
{
"name": "@scope/core",
"version": "1.0.0",
"dependencies": {}
}
// packages/utils/package.json
{
"name": "@scope/utils",
"version": "1.0.0",
"dependencies": {
"@scope/core": "workspace:*" // Resolved to local package
}
}
workspace:* becomes the actual version on publish. pnpm handles the rewriting.
Turborepo for Task Orchestration
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"], // Build deps first
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"],
"cache": false // Don't cache test results
},
"lint": {} // No deps, runs in parallel
}
}
Run turbo build -- it builds in dependency order, caches outputs, skips unchanged packages.
Peer Dependencies
Use peer deps when your package wraps or extends another library:
{
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": { "optional": false }
},
"devDependencies": {
"react": "^19.0.0" // Install for development/testing
}
}
Rules:
- NEVER bundle peer deps. Add them to
externalin your bundler config. - Keep peer dep ranges as wide as possible.
^18.0.0 || ^19.0.0not^19.1.3. - Always install peers as devDependencies for your own tests.
Tree-Shaking Optimization
For consumers to tree-shake your library:
- Ship ESM (
import/export, notrequire/module.exports). - Set
"sideEffects": falsein package.json. - Use named exports, not default exports with namespace objects.
- Avoid top-level side effects (module-scoped
console.log,addEventListener, mutation).
// Bad: barrel file re-exports everything, defeats tree-shaking
export * from "./button";
export * from "./modal";
export * from "./table";
// Good: separate entry points in exports map
// "exports": { "./button": "...", "./modal": "...", "./table": "..." }
Publishing Checklist
# 1. Verify package contents
pnpm pack --dry-run # Shows exactly what ships. No secrets, no tests, no src.
# 2. Check exports resolve
npx publint # Catches exports/types mismatches
npx arethetypeswrong # Tests if types resolve for all consumers
# 3. Publish with provenance
npm publish --provenance # Links package to GitHub commit (npm provenance)
.npmrc for Publishing
# .npmrc (project root)
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
access=public
provenance=true
Scoped Packages
@scope/namerequires"access": "public"for free npm accounts.- First publish:
npm publish --access public. After that, access is remembered. - Private packages:
npm publish(default access is restricted for scoped).
Files Field vs .npmignore
Always use "files" whitelist. Never use .npmignore.
"files": ["dist", "README.md", "LICENSE"]
This is a whitelist. Only these paths end up in the tarball. package.json is always included automatically. node_modules is always excluded automatically. The whitelist approach is safer than blacklisting with .npmignore because new files you add (like .env.local) never accidentally ship.
bin Field for CLIs
{
"bin": { "my-tool": "./dist/cli.js" }
}
The CLI entry file needs a shebang:
#!/usr/bin/env node
// dist/cli.js
import { run } from "./index.js";
run(process.argv.slice(2));
Ensure the built file has the shebang. tsup preserves it if your source has it. Add banner: { js: "#!/usr/bin/env node" } in tsup config if needed.
Wrong Exports Edge Case: ESM/CJS Mismatch Debugging
SYMPTOM: "Cannot find module" or "ERR_REQUIRE_ESM" in consumers
Diagnosis Checklist
# 1. Verify package contents
npx publint # Catches exports issues
npx arethetypeswrong # Tests TypeScript types across formats
# 2. Check what Node.js resolves
node -p "require.resolve('@scope/my-lib')" # CJS resolution
node --input-type=module -e "import('@scope/my-lib')" # ESM resolution
# 3. Inspect actual exports in published package
npm pack --dry-run
tar -tzf *.tgz | grep dist/
Common Mismatches
// ❌ BAD: Exports mismatch with actual files
{
"type": "module",
"exports": {
".": {
"import": "./dist/index.js", // File exists
"require": "./dist/index.cjs" // ❌ File missing or wrong extension
}
}
}
// ✅ GOOD: Match tsup output exactly
// tsup outputs: index.js (ESM), index.cjs (CJS)
{
"type": "module",
"exports": {
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
}
}
}
Fix: ESM-only package causing CJS errors
If your package is ESM-only and consumers using require() fail:
{
"type": "module",
"exports": {
".": {
"import": "./dist/index.js"
// No "require" field = CJS consumers get clear error
}
}
}
Document in README: "This is an ESM-only package. Use import not require."
Fix: Dual package hazard
// ❌ HAZARD: Same code loaded twice if not careful
// app.mjs: import { state } from 'my-lib'
// app.cjs: const { state } = require('my-lib')
// Result: Two separate instances of `state`
Solution: Document that mixing ESM/CJS in the same app can cause state duplication. Recommend consumers stick to one format project-wide.
Testing Both Formats Locally
# Create test CJS consumer
mkdir test-cjs && cd test-cjs
npm init -y
npm install ../my-package
node -e "const x = require('@scope/my-lib'); console.log(x)"
# Create test ESM consumer
mkdir test-esm && cd test-esm
npm init -y && echo '{"type":"module"}' > package.json
npm install ../my-package
node -e "import('@scope/my-lib').then(x => console.log(x))"