The fastest way to pin a Node.js version per project is a .nvmrc file containing one line: the version. echo "22" > .nvmrc, commit it, and every tool that reads it (nvm, fnm, and GitHub's actions/setup-node) lands on Node 22 for that repo. That is the answer for most projects. But .nvmrc only suggests a version to a version manager; it does not stop someone running npm install on the wrong Node. If you want a mismatch to actually fail, you also need package.json engines plus engine-strict=true, or Volta. This page covers all four pinning files, which tool reads which one, and how to wire CI so it can never drift from local dev. I have shipped enough "works on my machine, breaks in CI because the Node majors differ" bugs to treat this as non-optional setup on any repo with more than one contributor.
Jump to:
- The one-line answer: .nvmrc
- What goes inside the file
- .nvmrc vs .node-version
- Auto-switching on cd
- package.json engines: pinning that can fail the install
- Volta: pinning that needs no shell hook
- Which file should I commit?
- Keep CI on the same version
- FAQ
- See also
The one-line answer: .nvmrc
Drop a .nvmrc at the repo root with a single version line and commit it:
echo "22" > .nvmrc
git add .nvmrcNow anyone with nvm or fnm runs nvm use (or fnm use) in the project and gets Node 22:
$ nvm use
Found '/home/techearl/projects/api/.nvmrc' with version <v22>
Now using node v22.11.0 (npm v10.9.0)If the version is not installed yet, nvm install (no argument) reads .nvmrc and installs it first. The file is the contract: one place, in source control, that says which Node this project runs on. The catch is that .nvmrc is purely advisory. It does nothing on its own; a human or a tool has to actually read it and switch. Nothing stops a teammate on Node 18 from running the app on Node 18 and never noticing the file exists. That is what engines (below) is for.
What goes inside the file
.nvmrc accepts anything nvm install accepts, not just a bare number:
22 # latest installed/available 22.x
22.11.0 # exact patch
lts/* # latest LTS line of any name
lts/iron # a named LTS line (Iron = Node 20)
lts/jod # Jod = Node 22
node # the absolute latest (Current), rarely what you wantMy rule: pin a major (22), not an exact patch, unless you have a specific reason to lock the patch. Pinning 22 lets everyone pick up security patches within the major automatically; pinning 22.11.0 forces a coordinated bump for every patch and tends to go stale. The named aliases (lts/jod) read nicely but require the reader to remember which codename maps to which number, so I prefer the digit.
A few gotchas:
- No leading
v. Write22, notv22. nvm tolerates both, but.node-versionconsumers andactions/setup-nodeare stricter. - No trailing comments or extra whitespace. The whole file is the version string.
echo "22" > .nvmrcis correct; hand-edited files sometimes pick up a stray blank line that older parsers choke on. - It lives at the repo root, next to
package.json. Version managers walk up the directory tree from your cwd to find it, so a root file covers every subdirectory.
.nvmrc vs .node-version
There are two competing filename conventions, and they hold the exact same content (a bare version string). The only difference is which tools read them:
| File | Read by | Not read by |
|---|---|---|
.nvmrc | nvm, fnm, actions/setup-node | asdf (needs .tool-versions) |
.node-version | fnm, asdf, nodenv, actions/setup-node | nvm (does not read it) |
.nvmrc is the more widely recognised name because nvm popularised it. .node-version is the more portable name: fnm, nodenv, and asdf-adjacent tooling all read it, and it does not carry nvm's branding for a project where nobody uses nvm. fnm reads either, so on an fnm-only team it does not matter which you pick.
If your team is mixed (some on nvm, some on fnm, some on asdf), the safe move is to commit both files with identical contents. They are one line each, they never conflict, and every tool finds the one it knows:
echo "22" | tee .nvmrc .node-versionasdf is the exception that ignores both: it wants a .tool-versions file (nodejs 22.11.0) because it manages multiple languages, not just Node. Volta is the other one that reads neither file: it does not detect .nvmrc or .node-version at all, it only honours the volta key in package.json (covered below). So on a Volta repo these dotfiles still serve nvm, fnm, and CI, but Volta itself ignores them.
Auto-switching on cd
A pinning file is only as good as the habit of reading it. The reliable way to never forget nvm use is to make the shell switch automatically when you cd into a project. With fnm this is one line in your shell rc:
# ~/.zshrc or ~/.bashrc
eval "$(fnm env --use-on-cd)"# PowerShell $PROFILE
fnm env --use-on-cd | Out-String | Invoke-ExpressionNow cd into any directory with a .nvmrc or .node-version and fnm switches Node before your next command, installing the version first if needed. This is the single biggest reason I moved from nvm to fnm: nvm's equivalent cd hook spawns a subshell on every directory change and is noticeably slow, while fnm (a Rust binary) is effectively instant.
nvm can auto-switch too, but you have to paste a longer shell function from its README into your rc file (it is not a built-in flag). Volta sidesteps the question entirely: it does not hook cd, it shims node/npm themselves, so the right version is selected the moment you run a command in the project, no shell init required.
package.json engines: pinning that can fail the install
.nvmrc tells a version manager what to use. engines in package.json is a different lever: it declares the Node (and optionally npm) range your code supports, and the package manager checks it at install time.
{
"name": "my-app",
"engines": {
"node": ">=22.0.0 <23.0.0",
"npm": ">=10.0.0"
}
}By default this is advisory only: npm prints a warning on a mismatch and installs anyway. To make it a hard error, add engine-strict=true to an .npmrc committed alongside it:
# .npmrc
engine-strict=trueWith that flag, npm install on Node 18 against the range above fails instead of warning:
npm error code EBADENGINE
npm error engine Unsupported engine
npm error engine Not compatible with your version of node/npm: my-app@1.0.0
npm error notsup Required: {"node":">=22.0.0 <23.0.0"}
npm error notsup Actual: {"npm":"9.6.7","node":"v18.19.0"}
The behaviour differs by package manager, which trips people up:
| Package manager | Default on engines mismatch | How to make it fail |
|---|---|---|
| npm | Warns, installs anyway | engine-strict=true in .npmrc |
| Yarn (classic v1) | Errors by default | (already strict) |
| pnpm | Warns | engine-strict=true in .npmrc (pnpm honours the same key) |
engines is the right tool for libraries you publish: it stops a build from an unintended Node major shipping to npm, and it tells consumers which Node your package needs. For an application, pair it with .nvmrc (the version manager picks the version; engines is the seatbelt that catches a wrong one). The two are complementary, not redundant: .nvmrc is a specific version to switch to, engines is the acceptable range to validate against.
Volta: pinning that needs no shell hook
Volta collapses all of the above into one mechanism. Instead of a separate dotfile plus a shell hook plus an engines range, you volta pin and it writes the exact versions into package.json:
volta pin node@22
volta pin npm@10That adds a "volta" key:
{
"volta": {
"node": "22.11.0",
"npm": "10.9.0"
}
}Because Volta shims node, npm, and npx globally, anyone with Volta installed who runs a command inside that repo transparently gets exactly those versions, with no nvm use, no cd hook, no remembering. It also pins the package manager version, which .nvmrc cannot do. This is why I reach for Volta on a team monorepo: the version contract lives in the file everyone already edits, and there is no per-developer setup step to forget.
The trade-offs: Volta pins an exact version (no "latest 22.x" range, so patch bumps are a committed change), and its handling of Yarn Berry and pnpm is less seamless than its Node and classic-npm support. For a deeper comparison of when to choose Volta over a .nvmrc-based manager, see nvm vs fnm vs Volta.
Which file should I commit?
Short version, by situation:
| Situation | Commit | Why |
|---|---|---|
| Solo project, you use fnm/nvm | .nvmrc | One file, universally understood |
| Mixed team (nvm + fnm + asdf) | .nvmrc and .node-version | Each tool finds the name it reads |
| Published library | .nvmrc + engines + engine-strict | Stop a wrong-Node build from shipping |
| Team monorepo | package.json volta | Pins Node and the package manager, zero setup |
| asdf shop | .tool-versions | asdf ignores .nvmrc/.node-version |
There is no harm in having more than one of these present; the tools read different files and never fight. The combination I run on a typical team app is .nvmrc (for the version managers and CI) plus engines with engine-strict=true (for the hard guarantee). That covers both "switch me to the right version" and "refuse to install on the wrong one".
Keep CI on the same version
The whole point of pinning is that CI runs the same Node your developers run. actions/setup-node reads your pinning file directly with node-version-file, so there is one source of truth:
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc' # or '.node-version', or 'package.json'
cache: 'npm'
- run: npm ci
- run: npm testnode-version-file: 'package.json' reads the volta.node key (and falls back to engines.node if there is no Volta key), so a Volta-pinned repo needs no .nvmrc at all for CI. Point the action at whichever file you committed and CI can never drift from local dev: change .nvmrc, both move together.
When you do need to test across multiple majors (typical for libraries), use an explicit matrix instead of the file, since the file pins exactly one version:
strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'For the full CI wiring (caching, matrices, reading the pin file), see GitHub Actions setup-node. And if the upgrade that prompted the re-pin is bigger than one repo, the Node.js update guide walks through every install path and the native-module rebuild step that a major bump forces.
FAQ
See also
- How to update Node.js: nvm, fnm, Volta, direct install: every upgrade path and the native-module rebuild step a major bump forces, with the pinning files in context.
- nvm vs fnm vs Volta: which version manager to reach for, and why a Volta repo pins differently from a
.nvmrcone. - GitHub Actions setup-node: caching, matrices, and reading your pin file so CI mirrors local dev exactly.
Sources
Authoritative references this article was fact-checked against.
- nvm (Node Version Manager) README, .nvmrc behaviourgithub.com
- fnm (Fast Node Manager) READMEgithub.com
- package.json engines field (npm docs)docs.npmjs.com
- actions/setup-node, node-version-file (GitHub Actions)github.com





