Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Choosing Between prepare, prepack and prepublishOnly

npm has three lifecycle hooks that sound interchangeable — prepare, prepack and prepublishOnly — and choosing the wrong one produces packages published without their build output, installs that suddenly run a full TypeScript compile on every developer's machine, or git dependencies that install but do not work. The hooks differ in exactly when they fire. This guide maps each hook to the commands that trigger it, recommends one per task, and shows how the same rules apply in pnpm and Yarn.

Symptoms of picking the wrong hook

Wrong hook choices rarely produce an error at the moment of the mistake. They show up downstream:

# A consumer installing the published package
Error: Cannot find module '/app/node_modules/your-lib/dist/index.js'

The build ran in prepublishOnly, but the release pipeline used npm pack followed by npm publish ./your-lib-2.0.0.tgz, and prepublishOnly does not run for npm pack. The tarball was built without dist/.

# Every developer after cloning the repository
> your-lib@2.0.0 prepare
> tsc -p tsconfig.build.json
... 45 seconds later

The build was placed in prepare, which runs on every local npm install, slowing installs and failing entirely in environments without dev dependencies.

# A consumer installing from a git URL
npm error code ENOENT ... your-lib/dist/cli.js

The package is installed from github:acme/your-lib#main, and only prepare runs for git dependencies — the build in prepack or prepublishOnly never happened.

When each hook runs

The hooks are triggered by different commands, and that table is the whole decision. How lifecycle scripts fit into the wider script model is covered in Root-Level vs Package-Level Scripts.

Which commands trigger which lifecycle hook A matrix of the prepare, prepack and prepublishOnly hooks against local install, git dependency install, npm pack and npm publish. local npm install git dependency install npm pack npm publish prepare runs runs runs runs prepack no runs runs runs prepublishOnly no no no runs postpack no after packing runs runs
prepack is the only hook that runs for both npm pack and npm publish without also running on every local install.

The order during npm publish is: prepublishOnlyprepackprepare → pack the tarball → postpackpublishpostpublish. During npm pack, the same sequence runs without prepublishOnly, publish and postpublish. During a plain npm install in the package's own directory, prepare runs after dependencies are installed; prepack and prepublishOnly do not.

Hook order during npm publish The publish command runs prepublishOnly, then prepack, then prepare, then packs the tarball, runs postpack, uploads, and runs postpublish. prepublishOnly publish only: final checks prepack build dist/ here prepare also runs on install pack tarball then postpack upload publish postpublish notifications, tags
A build in prepack always runs before the tarball is created, whether you pack or publish.

Recommended assignment

Task Hook Reason
Build dist/ for the tarball prepack Runs for npm pack and npm publish, not on every install
Build for git-URL consumers prepare The only hook that runs for git dependencies
Install git hooks (Husky) prepare Must run after every local install
Last-chance checks (tests, clean tree) prepublishOnly Blocks a manual publish, never slows installs
Generate derived manifest fields prepack Output lands in the packed manifest

A typical library manifest:

{
  "name": "your-lib",
  "version": "2.0.0",
  "files": ["dist"],
  "scripts": {
    "build": "tsup",
    "test": "vitest run",
    "prepack": "npm run build",
    "prepublishOnly": "npm test && git diff --exit-code",
    "prepare": "husky"
  }
}

The prepublishOnly step runs tests and refuses to publish from a dirty working tree, protecting manual publishes from a maintainer's laptop. CI release pipelines usually run tests as their own job, so the hook is a safety net rather than the main gate.

When prepare should build

If you support installing the package straight from git — common for internal packages or forks — prepare must build, because nothing else runs. Guard it so it does not fail on machines without dev dependencies:

{
  "scripts": {
    "prepare": "node -e \"try{require.resolve('tsup')}catch{process.exit(0)}\" && tsup || true"
  }
}

That guard is ugly, which is itself a signal: publishing to a registry, even a private one, is almost always better than asking consumers to build from git.

pnpm, Yarn and workspaces

pnpm follows npm's semantics for these hooks when packing and publishing, with one important difference: pnpm does not run lifecycle scripts of dependencies by default in pnpm 10, only of the workspace's own packages, unless the dependency is listed in onlyBuiltDependencies. Your own prepare still runs on pnpm install in the workspace. Running pnpm install in a large workspace therefore triggers every package's prepare — another reason to keep builds out of it.

Yarn Berry runs prepack and postpack for yarn pack and yarn npm publish, but does not implement prepublishOnly and does not run prepare as part of yarn install in the same way. If your release uses Yarn, put the build in prepack, which behaves consistently across all three tools.

In workspaces, release tools such as Changesets call the package manager's publish command per package, so each package's own prepack runs. Many teams skip per-package hooks and instead build everything once with a task runner before publishing; in that case keep prepack as a cheap verification (for example, checking that dist/index.js exists) so a manual npm publish of a single package cannot ship an unbuilt tarball.

Supply-chain hardening changes the picture

Install-time scripts are the main vector for malicious packages, so more teams now install with scripts disabled, and pnpm 10 no longer runs dependency lifecycle scripts unless they are explicitly allowed. That shift affects which hook you choose for your own packages in two ways.

First, anything your consumers need must not depend on their lifecycle scripts running. A package that builds itself in prepare or compiles a native addon in install will break for every consumer who installs with --ignore-scripts or uses pnpm 10's defaults. Ship prebuilt output in the tarball — the prepack pattern — and your package keeps working under any hardening policy.

Second, your own repository's prepare hook also stops running when contributors install with scripts disabled. If prepare installs git hooks, that is a minor inconvenience; if it generates code the build needs, fresh clones break. Keep generated code behind an explicit generate script that the build depends on, so it runs regardless of install flags. The broader hardening approach is covered in Blocking Malicious Install Scripts with --ignore-scripts.

Choosing a hook for a new task The trigger a task needs decides the hook, from every tarball creation to every local install to manual publishes only. When must the task run? pick the narrowest trigger prepack builds, manifest generation every tarball prepare git hooks, git-URL builds every install prepublishOnly tests, clean-tree checks manual publish
Match the hook to the event the task must follow, not to what the name sounds like.

Worked example: a release job that shipped empty tarballs

A team splits its release into two jobs: one runs npm pack in each package and uploads the tarballs as artifacts for review; the second publishes the approved tarballs with npm publish ./pkg.tgz. The first release under the new process ships packages with no dist/. The build was in prepublishOnly, which runs for npm publish of a directory but not for npm pack — and publishing a tarball runs no hooks at all, because the tarball is already built. Moving the build to prepack fixes it: the build now runs inside npm pack, the reviewed tarballs contain dist/, and the publish job uploads exactly what was reviewed. The team also adds a check to the pack job that lists each tarball's files and fails if dist/index.js is missing, as described in Smoke-Testing a Tarball with npm pack.

Validation commands

# See which hooks run, in order, without publishing
npm publish --dry-run 2>&1 | grep -E "^> |prepack|prepare|prepublishOnly"

# Confirm the build ran and output is in the tarball
npm pack --dry-run 2>&1 | grep -E "dist/index\.(js|cjs)"

# Check that a plain install does not trigger a build
rm -rf node_modules && npm install 2>&1 | grep -c "> tsup" || true

Prevention and CI/CD guardrails

  • Put builds in prepack. It is the hook that matches "whenever a tarball is created".
  • Keep prepare fast, limited to git hooks and similar setup, unless you truly support git-URL installs.
  • Assert tarball contents in CI after packing, independent of which hook produced them.
  • Use --ignore-scripts deliberately. Installing with it (a common supply-chain hardening step) also skips your own prepare; make sure nothing essential depends on it.

Frequently Asked Questions

What happened to the plain prepublish hook? Historically prepublish ran on both npm install and npm publish, which confused everyone. npm deprecated that behaviour, split it into prepare and prepublishOnly, and today prepublish is only kept for compatibility. Do not use it in new packages.

Do hooks run when publishing a prebuilt tarball? No. npm publish ./pkg.tgz uploads the tarball as it is; no build hooks run, because the contents are already fixed. Build before packing.

Does npm ci run prepare? Yes. npm ci runs the root package's prepare after installing, unless --ignore-scripts is set. That is another reason to keep it quick.

Should tests run in prepublishOnly if CI already runs them? It is harmless and protects manual publishes, but it duplicates work in automated releases. Many teams keep it for the manual path and skip it in CI with an environment check.

Can a prepack script modify package.json before packing? Yes, and it is a legitimate pattern for generating derived fields such as typesVersions from exports. Restore the original in postpack if you do not want the change left in your working tree, or generate the field into the committed file so the diff is reviewed.

Why does my prepare script run twice in CI? Because both npm ci and a later npm publish or npm pack trigger it. Keep it idempotent and cheap, or move the expensive work to prepack so it runs once per tarball.

Related

Root-Level vs Package-Level Scripts