Back to System Design
System Design
medium
mid

How would you build and publish your own npm package?

Scaffold with package.json, build to ESM (+ CJS) with TypeScript declarations, set exports/main/module/types fields and the files allowlist, version with semver, test and lint, then npm publish (with CI, provenance, and a changelog). Keep the API small and well-documented.

7 min read·~15 min to think through

Publishing an npm package is straightforward; publishing a good one is about correct packaging, clear API, and release hygiene.

1. Scaffold

  • npm initpackage.json. Pick a clear, available name (or a scope: @yourorg/pkg).
  • Decide scope: public vs private ("private": true or publishConfig.access).
  • Choose a license, write a real README (install, usage, API, examples).

2. Build & module formats

Ship compiled, consumable output — not raw TS/JSX:

  • TypeScript source → emit ESM (and CJS if you need to support older consumers) plus .d.ts type declarations.
  • Use a bundler/build tool — tsup, unbuild, Rollup, or Vite library mode — they handle ESM+CJS+types cleanly.
  • Configure package.json fields correctly — this is where most packages get it wrong:

``json { "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" } }, "files": ["dist"], "sideEffects": false } ``

  • exports is the modern source of truth for entry points.
  • files allowlist (or .npmignore) so you don't publish tests/src/config.
  • sideEffects: false helps consumers tree-shake.
  • peerDependencies for things like react — don't bundle the consumer's framework.

3. Quality gates

  • Tests (Vitest/Jest), lint, type-check.
  • npm pack --dry-run (or publint / arethetypeswrong) to inspect exactly what ships and verify the package resolves correctly.
  • Keep the public API small and intentional — every export is a forever contract.

4. Version & publish

  • Semver — patch (fix), minor (feature, backward-compatible), major (breaking).
  • npm login, then npm publish (--access public for a scoped public package).
  • A prepublishOnly script to build + test so you never publish stale artifacts.
  • Use npm version to bump + tag; maintain a CHANGELOG.

5. Automate (for anything real)

  • CI publish — GitHub Actions on a release tag; changesets or semantic-release for automated versioning + changelog.
  • npm provenance (--provenance in CI) for supply-chain trust.
  • Test it for real: npm pack and install the tarball in a sample project, or npm link.

6. Maintain

  • Respond to issues, keep deps updated, document breaking changes, deprecate gracefully (npm deprecate).

The framing

"Scaffold package.json, build TS to ESM+CJS with .d.ts types, and — the part people get wrong — set exports/types/files correctly so it resolves in every consumer. Gate with tests/lint and publint, version with semver, and automate publishing via CI with changesets and provenance. Keep the API minimal — every export is a contract you'll maintain."

Follow-up questions

  • Why does the package.json 'exports' field matter and what does it replace?
  • When do you use peerDependencies vs dependencies?
  • How do you support both ESM and CJS consumers?
  • How would you automate versioning and changelog generation?

Common mistakes

  • Publishing raw source instead of compiled output, or shipping no type declarations.
  • Misconfigured main/module/exports so it doesn't resolve for some consumers.
  • Publishing tests/src/config because there's no files allowlist.
  • Bundling react (or another framework) instead of declaring it a peerDependency.
  • A huge, accidental public API surface.

Performance considerations

  • Ship ESM and mark sideEffects:false so consumers tree-shake. Keep the dependency footprint small — every dep becomes the consumer's bundle weight. Provide granular entry points if the package is large.

Edge cases

  • Dual ESM/CJS support and the 'dual package hazard'.
  • Scoped packages needing --access public.
  • Breaking changes requiring a major bump and migration notes.
  • Tree-shaking broken by missing sideEffects:false.

Real-world examples

  • A shared internal component library or hooks package published to a private registry.
  • tsup-built dual ESM/CJS package, released via changesets in GitHub Actions with provenance.

Senior engineer discussion

Seniors emphasize correct packaging (the exports/types/files fields, ESM+CJS, peerDependencies) and verification with publint/arethetypeswrong, treat the public API as a long-lived contract to keep minimal, and automate the release pipeline (CI, changesets/semantic-release, semver, provenance, changelog). They mention the dual-package hazard and tree-shaking hygiene.

Related questions