Publishing a Package
Publishing a package makes it available on the npm registry so that anyone (or members of your organisation) can install it with npm install. The process involves three steps: preparing package.json with the right metadata, bumping the version, and running npm publish. Each step has guardrails that help you ship clean, predictable releases.
Key package.json fields for publishing
Section titled “Key package.json fields for publishing”A package ready to publish typically carries these fields, on top of name and version:
{ "name": "@myorg/my-util-lib", "version": "1.0.0", "description": "A small utility library", "license": "MIT", "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "files": ["dist", "README.md"], "bin": { "my-util": "./dist/cli.js" }, "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs", "default": "./dist/index.cjs" }, "./helpers": { "types": "./dist/helpers.d.ts", "import": "./dist/helpers.mjs", "require": "./dist/helpers.cjs" } }, "engines": { "node": ">=18.0.0" }}license— the SPDX identifier consumers and registry scanners check before depending on your package.main/module— legacy CJS entry point, and a bundler-only convention some older tools still look for; both are superseded byexportsbut keep tooling compatible.types— the root TypeScript declaration file, used by editors andtscwhen the package has noexportsmap or as a fallback.files— an allowlist of paths included in the published tarball. Everything else (source files, test fixtures, config) is excluded. If omitted, npm still applies a default set of exclusions (node_modules,.git, anything matched by.npmignoreor, absent that,.gitignore).bin— maps a CLI command name to an entry-point script. npm creates a symlink in.bin/so the command is available in the PATH when the package is installed, and vianpx.exports— declares the public API surface. Any path not listed here is inaccessible to consumers. Inside each conditions object,"types"must come first — see Module Resolution for why key order matters.
The files field
Section titled “The files field”{ "files": ["dist", "README.md", "LICENSE"]}"files" is an allowlist, not a denylist: only the paths listed here (plus a few files npm always includes, like package.json and README) end up in the tarball. This keeps source maps, test fixtures, and local config out of what consumers download.
The demo below reimplements that allowlist logic directly with fs, against a fake package tree that includes a .env file and test/source folders never listed in "files", to make the filtering mechanism concrete rather than asserted:
Output (verified on Node 24)
Would ship in the tarball:
README.md
dist/index.js
dist/nested/helper.js
package.json
Excluded (not in "files"):
.env
src/index.ts
test/index.test.js.env, the TypeScript source, and the test file never touch consumers’ machines. A real npm pack --dry-run on this exact layout confirms the same three files ship — this demo makes the mechanism explicit rather than asking you to trust the CLI output.
The bin field
Section titled “The bin field”{ "bin": { "my-util": "./dist/cli.js" }}When the package is installed (locally or globally), npm creates a my-util executable in node_modules/.bin/ (or the global bin directory) pointing at ./dist/cli.js. That is what makes the command available to npx my-util and to npm run scripts in a consuming project without a global install.
npx vs npm exec vs node --run
Section titled “npx vs npm exec vs node --run”All three end up running “some code,” which is exactly why generated snippets swap between them without noticing they are not equivalent:
| Command | Runs | Falls back to installing from the registry? | Runs pre/post lifecycle scripts? |
|---|---|---|---|
npx <bin-name> | A bin entry — local (node_modules/.bin) if present, otherwise a temporary registry install | Yes (prompts unless --yes/-y) | N/A — not a package.json script |
npm exec -- <bin-name> | The same bin lookup as npx (npm 7+ implements npx on top of exec) | Yes, same behavior as npx | N/A — not a package.json script |
node --run <script-name> | A named script in package.json’s "scripts" field, directly — no shell spawned | No — it only runs what’s already in "scripts" | No — this is the sharpest difference from npm run |
Real transcripts, all local and offline (a bin entry say-hi and a script greet both defined in the same package):
npx say-hihi from local binnpm exec -- say-hihi from local binnode --run greethi from local binAll three print the same thing here — but add a pregreet lifecycle script and the difference becomes visible:
npm run greet> [email protected] pregreet> echo PRE-HOOK-RAN
PRE-HOOK-RAN
> [email protected] greet> node cli.js
hi from local binnode --run greethi from local binnode --run skips pregreet entirely — it executes only the named script, with no lifecycle hook resolution and no shell in between, which is also why it starts faster than npm run. It is a substitute for npm run <script>, not for npx/npm exec: it cannot install anything, and it cannot run an arbitrary binary that isn’t already a package.json script.
Versioning with Semantic Versioning
Section titled “Versioning with Semantic Versioning”npm follows semantic versioning (semver): MAJOR.MINOR.PATCH.
- PATCH — backwards-compatible bug fixes.
- MINOR — backwards-compatible new features.
- MAJOR — breaking changes.
Use npm version to bump and tag in one step:
# Patch bump: 1.0.0 → 1.0.1 (bug fixes)npm version patch
# Minor bump: 1.0.1 → 1.1.0 (backwards-compatible new features)npm version minor
# Major bump: 1.1.0 → 2.0.0 (breaking changes)npm version majorEach command updates the "version" field in package.json, commits the change, and creates a signed git tag (e.g. v1.0.1). Push the tag with git push --follow-tags.
Testing before publish with npm pack
Section titled “Testing before publish with npm pack”npm pack bundles your package into a .tgz tarball exactly as npm would if you ran npm publish — but it stays local. This lets you inspect the final artifact before shipping it. --dry-run goes one step further: it lists the tarball contents without writing anything to disk, and needs no registry login.
# Create a local tarball (e.g. myorg-my-util-lib-1.0.0.tgz)npm pack
# Inspect its contents without extractingtar -tzf myorg-my-util-lib-1.0.0.tgz
# Install it locally in another project to smoke-testnpm install ../my-util-lib/myorg-my-util-lib-1.0.0.tgzReal output of npm pack --dry-run against a package with "files": ["dist", "README.md"], a .env, and an unrelated secrets file sitting alongside them:
npm pack --dry-runnpm noticenpm notice 📦 @demo/[email protected]npm notice Tarball Contentsnpm notice 14B README.mdnpm notice 21B dist/index.jsnpm notice 139B package.jsonnpm notice Tarball Detailsnpm notice name: @demo/my-util-libnpm notice version: 1.0.0npm notice filename: demo-my-util-lib-1.0.0.tgznpm notice package size: 284 Bnpm notice unpacked size: 174 Bnpm notice shasum: 59bc21d933556de397e4fa5056ea5a999750a8d3npm notice integrity: sha512-FBadVz2MkDnJl[...]jO8KfPJW6WRuw==npm notice total files: 3.env and the secrets file are simply absent from the listing — exactly the filtering the fs-based demo above reimplements.
Publishing with npm publish
Section titled “Publishing with npm publish”You must be authenticated (npm login) before publishing. npm reads package.json to determine the registry target (defaults to https://registry.npmjs.org).
# Log in to the registrynpm login
# Publish a public (unscoped) packagenpm publish
# Publish a scoped package as public (scoped packages default to restricted)npm publish --access public
# Publish under a dist-tag other than "latest" (e.g. a beta)npm publish --tag betaflowchart TD
A["npm version patch/minor/major"] --> B["prepublishOnly script runs (build, test, lint)"]
B --> C["npm pack --dry-run — inspect the tarball contents"]
C --> D{"Looks right?"}
D -->|"no"| E["Fix files/exports/scripts, repeat from npm pack"]
E --> C
D -->|"yes"| F["npm publish"]
F --> G{"2FA required for this account/package?"}
G -->|"yes"| H["Approve via authenticator or security key"]
G -->|"no"| I["Tarball uploaded to the registry"]
H --> I
I --> J["Provenance attestation attached (if --provenance)"] Provenance, 2FA, and the pre-publish checklist
Section titled “Provenance, 2FA, and the pre-publish checklist”A published version cannot be unpublished after 72 hours (npm’s abuse-prevention window), so the guardrails belong before npm publish runs, not after:
prepublishOnly— apackage.jsonscript npm runs automatically right before packing, and only before a realnpm publish(unlikeprepublish, which historically also ran on plainnpm install). Put your build and test steps here so a broken build cannot ship:{"scripts": {"prepublishOnly": "npm test && npm run build"}}npm pack --dry-run— the free, local, no-login-required check from the previous section. Run it and actually read the file list before every publish.- Two-factor authentication (2FA) —
npm profile enable-2fa auth-and-writesrequires a one-time code (or a hardware security key) for both login and publish, so a leaked npm token alone is not enough to publish a malicious version under your name. - Provenance (
npm publish --provenance) — from a supported CI environment (GitHub Actions, GitLab CI), this cryptographically attaches the exact source commit and build workflow that produced the tarball, publicly visible on the npm registry page. It answers “was this actually built from the public source repo” for anyone auditing a supply-chain incident.
Scoped packages
Section titled “Scoped packages”A scoped package uses the @scope/name format and is typically used for organisation namespacing or to group related packages:
{ "name": "@myorg/my-util-lib", "version": "1.0.0"}Scoped packages are private by default on the npm registry. To publish them publicly, always pass --access public:
# Scaffold a scoped packagenpm init --scope=@myorg
# Publish it publiclynpm publish --access publicPrivate scoped packages require a paid npm account or a private registry.
Mental Model
Section titled “Mental Model”Everything in this lesson exists because npm publish is closer to a one-way door than a save button. A published tarball’s exact bytes are permanent — you cannot edit or silently replace 1.0.0 after the fact — so every guardrail here ("files", prepublishOnly, npm pack --dry-run, 2FA, provenance) exists to catch a mistake in the few seconds before that door closes, because nothing catches it after.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
prepublishOnly running tests/build | A broken build cannot be published — the check runs automatically, no one has to remember it | Adds real time to every publish; a flaky test can block a legitimate release |
| 2FA on publish | A stolen npm token alone cannot publish a malicious version | Every publish needs a second factor available — friction for automated release pipelines unless a granular token is set up |
--provenance | Public, verifiable link between a published tarball and the exact source commit/workflow that built it | Only works from a supported CI provider — cannot be added retroactively to already-published versions |
Strict "files" allowlist | Small, predictable tarballs; nothing accidentally shipped | Forgetting to add a genuinely needed file (a wasm binary, a font) breaks the package for consumers until the next version |
Common Misconceptions
Section titled “Common Misconceptions”- “I can just publish a patch to fix a mistake I shipped.” You can publish a new version, but the broken one stays on the registry forever (npm’s unpublish window is 72 hours and has restrictions even within it) — anyone who already resolved that exact version keeps the mistake.
- “
npm packandnpm publishmight package different files.” They use the identical"files"/.npmignorelogic —npm pack --dry-runis not an approximation, it is the literal tarball-building step, minus the upload. - “2FA only protects my npm account login.”
enable-2fa auth-and-writesadditionally requires a second factor for the publish itself — a stolen or leaked auth token is not sufficient on its own to push a malicious version.
Reading AI-generated code
Section titled “Reading AI-generated code”Generated CI/CD publish scripts frequently omit prepublishOnly and go straight from a version bump to npm publish, because the tutorial-shaped examples most training data comes from are demonstrating “how to publish,” not “how to publish safely.”
🐛 Spot the bug
{"name": "my-cli-tool","version": "1.0.0","bin": { "my-cli": "./dist/cli.js" },"devDependencies": { "commander": "^12.0.0"}}Show the answer
commander is the argument-parsing library the CLI actually calls at runtime — every time my-cli runs, on every consumer’s machine — so it belongs in "dependencies", not "devDependencies". Because npm skips devDependencies on a production install (npm install --omit=dev, or whenever a consumer installs this as a real dependency rather than cloning the repo), any consumer who installs this CLI normally gets Cannot find module 'commander' the moment they run it. The fix is moving it: "dependencies": { "commander": "^12.0.0" }.