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.
The order during npm publish is: prepublishOnly → prepack → prepare → pack the tarball → postpack → publish → postpublish. 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.
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.
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
preparefast, 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-scriptsdeliberately. Installing with it (a common supply-chain hardening step) also skips your ownprepare; 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 places lifecycle hooks within the wider script model.
- Smoke-Testing a Tarball with npm pack verifies that the build really made it into the package.
- Dry-Running a Publish Before Release shows hook output without uploading anything.
- Blocking Malicious Install Scripts with --ignore-scripts explains how install-time hooks interact with hardening.