on this page
- What Node even is, quickly
- Why the version matters more than you’d think
- Check what you actually have
- Pin the version so everyone matches
- A .nvmrc file
- The engines field in package.json
- Version managers, and what each one is for
- Why CI, your host, and your teammates all run different Node
- Pin it for real, end to end
- Classic traps a mismatch sets for you
- Why this is worth two minutes of setup
You push code that runs perfectly on your laptop. A teammate clones it, runs npm install, and gets a wall of red. Or the deploy that worked yesterday now dies in the build log with some error you’ve never seen. You changed nothing. The code is identical, character for character.
Welcome to the most common ghost in early development: two machines running two different versions of Node, quietly disagreeing about what your code even means.
This guide is about that ghost, where it comes from, and how to pin it down so every machine that touches your project agrees on one thing.
What Node even is, quickly
Node.js (usually just “Node”) is the program that runs JavaScript outside a browser. Your browser has its own JavaScript engine baked in. Node is the version you install on your computer and your servers so JavaScript can run on the command line, on a backend, in a build step. When you type npm run dev or node server.js, Node is the thing actually executing the code.
Here’s the part nobody tells beginners: Node is not one fixed thing. It ships in versions, and they are released constantly. Node 18, Node 20, Node 22, and so on. Each major version is a meaningfully different runtime.
That difference is the whole problem.
Why the version matters more than you’d think
When the Node version changes, three things underneath your code change with it.
The JavaScript features available. Newer Node versions understand newer JavaScript syntax and methods. Take Array.prototype.findLast, which lets you search an array from the end. It landed in Node 18. Write code using it, run it on your Node 20, and it works fine. Hand that same code to someone on Node 16 and they get findLast is not a function. The code didn’t break. Their runtime simply never learned that word.
The built-in APIs. Node ships with its own libraries for reading files, making network requests, hashing things. These move between versions. The global fetch function, the one you use to make HTTP requests, was experimental in Node 16, then became stable in Node 18. On an older Node, fetch is just undefined, and you get a confusing crash about calling something that isn’t a function.
The bundled npm. Node comes with npm (Node Package Manager, the tool that installs the libraries your project depends on) baked in. Different Node versions bundle different npm versions. A newer npm can write a package-lock.json (the file that records the exact versions you installed) in a format an older npm chokes on. So an install that’s clean on your machine can fail on a machine running older Node, before a single line of your actual code runs.
Stack those three up and you get the real headache. “Works on my machine” is not a personality flaw. It’s usually two different Node versions producing two different realities.
Check what you actually have
Before fixing anything, find out what you’re running. Two commands:
node -v
# prints something like v20.11.1
That’s your Node version. The format is major.minor.patch, so v20.11.1 means major version 20. The major number is the one that matters most for compatibility, because that’s where the big behavioral changes land.
npm -v
# prints something like 10.2.4
That’s the npm version that came bundled with your Node. You usually don’t pick this directly. It rides along with whatever Node you installed.
Run both on your machine, then ask a teammate to run them on theirs. If the major Node numbers differ, you’ve found a likely source of “it works for me but not you” before it ever bites.
Pin the version so everyone matches
The fix is to write the intended Node version down somewhere the whole project can see it, instead of leaving each person to guess. There are a few places to write it, and they do different jobs.
A .nvmrc file
A .nvmrc is a one-line text file in your project root that says which Node version this project wants. That’s the entire file:
# .nvmrc
20
You can be more specific (20.11.1) or just name the major version (20) and let the tooling grab the latest 20.x. This file does nothing on its own. It’s a note. The thing that reads it is a version manager, which we’ll get to in a second. Commit this file. It belongs to the project, not to you.
The engines field in package.json
Your package.json can declare which Node versions your project supports, in a field called engines:
{
"name": "my-app",
"engines": {
"node": ">=20.0.0"
}
}
This says “this project expects Node 20 or higher.” It does two useful things. First, it documents intent, so anyone reading your package.json knows the target. Second, some hosting providers read engines and use it to pick which Node to run during a build, which is exactly what you want.
Here’s the catch beginners trip on: by default, npm only warns when someone’s Node is outside this range. It prints a yellow message and installs anyway. If you want npm to actually refuse, you have to opt in by adding a file called .npmrc with one line:
# .npmrc
engine-strict=true
With that, an install on the wrong Node version fails loudly instead of mumbling a warning into the void. For a small project that’s often what you want, because a warning nobody reads is barely better than no warning at all.
Version managers, and what each one is for
A version manager lets you install multiple Node versions side by side and switch between them, so you’re not stuck with one global Node for every project you’ll ever touch. This matters the moment you have two projects: an old one on Node 18 and a new one on Node 22. Without a version manager, you’re uninstalling and reinstalling Node by hand like an animal. There are three common ones.
nvm (Node Version Manager) is the classic. You switch versions per terminal session by hand:
nvm install 20 # download Node 20
nvm use 20 # use it in this shell
The thing to understand: nvm use only affects the terminal window you ran it in. Open a new terminal and you’re back to your default Node. It’s reliable and widely used, but it won’t switch automatically, so it’s easy to forget and end up on the wrong version without noticing.
fnm (Fast Node Manager) is a faster, drop-in alternative. Same idea, but it can auto-switch: set it up to watch for a .nvmrc, and when you cd into a project folder, fnm reads that file and switches Node for you. You stop thinking about it, which is the point. If you’re choosing today and don’t have a reason to pick otherwise, fnm is a comfortable default.
Volta takes a different angle. Instead of switching per shell, Volta pins the toolchain per project and picks the right version automatically whenever you run a command inside that project. You pin once:
volta pin node@20
After that, anyone with Volta who runs node or npm in your project gets Node 20, no manual switching, no remembering. The pin lives in package.json, so it travels with the repo.
Here’s how they line up:
| Tool | What it does | Where it reads the version | Switches automatically? |
|---|---|---|---|
.nvmrc | A one-line file naming the Node version | It’s the file itself; read by nvm and fnm | No, it’s just a note |
engines (package.json) | Declares the supported Node range | package.json; read by npm and some hosts | No, it warns or blocks |
| nvm | Installs and switches Node versions | Reads .nvmrc when you ask it to | No, manual nvm use |
| fnm | Faster nvm-style manager | Reads .nvmrc on entering a folder | Yes, on cd |
| Volta | Pins the toolchain per project | A pin stored in package.json | Yes, per command |
You only need one version manager. The .nvmrc and engines entries are worth having no matter which manager you (or your teammates) use, because they’re plain files anyone can read.
Why CI, your host, and your teammates all run different Node
Left to their own devices, no two environments default to the same Node, and that’s the part that quietly wrecks deploys.
Your CI (Continuous Integration, the service like GitHub Actions that runs your tests and builds when you push) starts from a clean machine every time. Whatever Node that machine defaults to is what you get, unless you tell it otherwise in the workflow config.
Your hosting provider does the same.
And your teammates? Whatever they happened to install whenever they set up their machine. Could be anything.
This is the connective tissue to deployment. A build that passes on your laptop and fails on the host is, more often than any other single cause, a Node mismatch. The host built your code on a Node your code never met. If you’ve read How Localhost Becomes a Real Website, this is the unglamorous reason behind a chunk of those “works locally, breaks on deploy” mysteries.
Pin it for real, end to end
Concretely, three small moves cover the whole chain.
1. Add a .nvmrc so your local tooling and your teammates’ tooling switch to the right Node:
# .nvmrc
20
2. Add engines to package.json so npm and some hosts know the allowed range:
{
"engines": {
"node": ">=20.0.0"
}
}
3. Tell your host explicitly. Most hosts read an environment variable. On Cloudflare Pages, Railway, and Render you can set NODE_VERSION in the project settings:
NODE_VERSION=20
If pinning the host’s Node through a setting feels unfamiliar, the mechanics are the same as any other config value: Environment variables explained walks through how these get set per environment. Some CI setups also let you commit the Node version straight into the workflow file. Here’s that pin in a GitHub Actions step, which reads your .nvmrc so there’s a single source of truth:
# .github/workflows/ci.yml
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
Now the laptop, CI, and the host all read the same number. That’s the goal: one version, written in places everything can see.
Classic traps a mismatch sets for you
These are the symptoms that look like four different bugs but share one root cause. When you hit one, check Node versions before you lose an afternoon.
| Symptom | What’s really happening | The fix |
|---|---|---|
npm install fails on the host, works for you | Host’s older npm can’t read your newer package-lock.json | Pin the host to your Node version with NODE_VERSION |
| Syntax or API error only in CI | CI runs a Node missing a feature your code uses | Pin CI’s Node via .nvmrc and setup-node |
Error: ... was compiled against a different Node.js version | A native module was built for the wrong Node | Match the Node version, then delete node_modules and reinstall |
| Passes locally, breaks on deploy | Host defaulted to a different Node than yours | Set the host’s Node explicitly; don’t trust the default |
The native module one deserves a flag. Some packages (database drivers, image tools, anything with a C++ part under the hood) compile themselves against the exact Node you installed them on. Switch Node versions without reinstalling and that compiled piece is now built for a runtime that’s gone. The reinstall, after deleting node_modules, rebuilds it for the Node you’re actually on.
Why this is worth two minutes of setup
A pinned Node version means the runtime that builds your app on the host is the same runtime that ran it on your laptop, which is the same one your teammate has, which is the same one CI tested against. The build that passes locally is the build that ships, because there’s nothing left to disagree about.
When you go to put a real project online, half the deploy failures people blame on “the host being weird” are just a version nobody pinned. Write the number down in a .nvmrc, declare it in engines, set it on your host, and you’ve removed one of the loudest, dumbest sources of “but it worked for me” from your life. Same Node everywhere, same build everywhere, one less ghost in the machine.