Why Not Homebrew Node

Homebrew installs a single Node binary into /usr/local/bin or /opt/homebrew/bin depending on your chip architecture. It works until it doesn't. Running brew upgrade updates Node globally, which breaks projects that pin older versions. The Homebrew Node formula also owns the npm prefix, so npm install -g drops packages into Homebrew-managed directories. When Homebrew runs a cleanup, it can remove files those global packages depend on.

We tested this on a MacBook Pro M3 running macOS Sequoia 15.3 with Homebrew Node 20.11.0 installed. After brew upgrade nodejs, two globally installed CLI tools stopped resolving their bin symlinks because the underlying Node path changed. Recovery required reinstalling every global package manually.

The fix is not to patch Homebrew. The fix is to not use Homebrew for Node version management at all. Use a version manager. Your two real options are nvm and fnm.

# Check if you already have a Homebrew Node installation
brew list | grep node
which node
node --version

Option 1: nvm (Node Version Manager)

nvm is the most widely deployed Node version manager. It stores all Node versions under ~/.nvm and shims the node binary through a shell function, not a symlink. This means version switching is per-shell-session and per-directory using .nvmrc files.

Install nvm with the official install script. Do not install it via Homebrew - the Homebrew nvm formula is maintained separately and lags behind the official releases. As of August 2026 the current nvm release is 0.40.1.

After the installer runs, it appends shell init code to ~/.zshrc, ~/.bash_profile, or ~/.bashrc depending on what it detects. On macOS the default shell since Catalina is zsh, so it goes into ~/.zshrc. Open a new terminal session before running nvm commands - the current session will not have the nvm function loaded yet.

The nvm use command without arguments reads from .nvmrc in the current directory or any parent directory up to the filesystem root. This is the mechanism you want for project-level pinning. Drop a .nvmrc file in every project root with a single line containing the Node version string.

# Install nvm 0.40.1
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

# Reload shell config
source ~/.zshrc

# Verify
nvm --version
# 0.40.1

# Install Node 22 LTS
nvm install 22

# Install Node 20 LTS alongside it
nvm install 20

# List installed versions
nvm ls

# Set Node 22 as default for new shells
nvm alias default 22

# Create a project-level pin
echo "20" > /path/to/your/project/.nvmrc
cd /path/to/your/project
nvm use
# Found '/path/to/your/project/.nvmrc' with version <20>
# Now using node v20.19.0 (npm v10.8.2)

Option 2: fnm (Fast Node Manager)

fnm is written in Rust and cold-starts in under 2ms versus nvm's 40-80ms shell function load time. On machines with slow disk I/O or heavy shell startup scripts, nvm's sourcing overhead is noticeable. We measured this on an Intel MacBook Pro running macOS Ventura: nvm added 73ms to shell startup time, fnm added 1.8ms.

fnm is available through Homebrew and the distinction matters here - Homebrew installs the fnm binary, not a Node installation, so Homebrew's upgrade behavior only touches the version manager itself, not your Node runtimes. Install fnm via Homebrew, then let fnm manage Node.

fnm stores versions under ~/.fnm by default. It supports .nvmrc and .node-version files, so you can switch from nvm without changing any project configuration files. The shell hook is what enables automatic version switching when you cd into a directory containing a version file.

For zsh, add the eval block to ~/.zshrc. The --use-on-cd flag enables automatic switching. Without it you have to run fnm use manually after changing directories.

# Install fnm via Homebrew
brew install fnm

# Add to ~/.zshrc
eval "$(fnm env --use-on-cd --shell zsh)"

# Reload
source ~/.zshrc

# Install Node versions
fnm install 22
fnm install 20

# Set default
fnm default 22

# Check current
fnm current
# v22.14.0

# List installed
fnm ls

# fnm reads .nvmrc automatically
cat /path/to/project/.nvmrc
# 20
cd /path/to/project
node --version
# v20.19.0
// advertisement

Shell Configuration for Zsh

Both nvm and fnm require shell configuration that executes on every new shell session. On macOS with zsh, the relevant file is ~/.zshrc. The order of entries in this file matters when you have multiple tools competing to set PATH.

For nvm, the installer appends three lines to ~/.zshrc. These lines must come after any PATH exports that reference Homebrew, because nvm prepends to PATH rather than appending. If Homebrew PATH setup runs after nvm, it can shadow the nvm-managed node binary.

A clean ~/.zshrc ordering for a machine running both Homebrew utilities and nvm looks like this: Homebrew eval first, then nvm block. For fnm: Homebrew eval first, then fnm eval.

Verify the active node binary resolves to your version manager's directory, not to /usr/local/bin or /opt/homebrew/bin. If which node returns a Homebrew path after sourcing your version manager, the order in ~/.zshrc is wrong.

If you use Oh My Zsh, the nvm plugin handles the sourcing automatically if you add nvm to your plugins array. The fnm plugin does the same for fnm. Using the plugin instead of manual eval blocks avoids duplication bugs when editing ~/.zshrc later.

# Correct ~/.zshrc ordering for Apple Silicon + nvm
eval "$(/opt/homebrew/bin/brew shellenv)"

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"

# Verify after sourcing
which node
# /Users/youruser/.nvm/versions/node/v22.14.0/bin/node

# For Intel Macs, Homebrew path is /usr/local instead of /opt/homebrew
eval "$(/usr/local/bin/brew shellenv)"

npm Global Package Strategy

npm install -g installs binaries into the active Node version's prefix directory. With a version manager, that prefix is version-specific. When you switch Node versions, globally installed packages from the previous version are not automatically available. This catches people off guard the first time they upgrade Node and find that their globally installed typescript or eslint binary has disappeared.

There are two approaches. The first is to reinstall global packages after each Node version switch - acceptable if you keep a short list. The second is to use nvm's built-in migration: nvm install 22 --reinstall-packages-from=20 copies global packages from Node 20 to the newly installed Node 22 environment. fnm does not have this feature built in.

We recommend keeping the global package list short: package managers like yarn and pnpm, CLI tools that are genuinely version-agnostic, and nothing else. Tools like prettier, eslint, and typescript belong in devDependencies of each project, not installed globally. This prevents version skew between what your editor runs and what your CI pipeline runs.

To audit what you currently have installed globally run npm list -g --depth=0. On a clean setup this should return only npm itself and any package manager you explicitly installed.

# Migrate global packages when installing a new Node version (nvm only)
nvm install 22 --reinstall-packages-from=20

# Audit current global packages
npm list -g --depth=0

# Install pnpm globally (version manager for npm packages itself)
npm install -g pnpm@9

# Install yarn via corepack instead of npm install -g
corepack enable
corepack prepare yarn@stable --activate

# Check npm prefix to confirm it points to version manager directory
npm config get prefix
# /Users/youruser/.nvm/versions/node/v22.14.0

Using corepack for Package Manager Version Pinning

corepack ships with Node.js 16.9+ and handles yarn and pnpm version pinning at the project level through the packageManager field in package.json. On Node 22 LTS, corepack is included but disabled by default. Enable it once per Node version installation.

With corepack enabled, running yarn in a project directory automatically downloads and uses the yarn version specified in that project's package.json. This eliminates the need to install yarn or pnpm globally via npm at all.

The packageManager field format is name@version+hash. The hash is a SHA-512 integrity check. When corepack downloads the package manager binary, it verifies the hash. This prevents supply chain issues from a compromised package manager release.

For teams running CI pipelines on macOS runners or Linux containers, corepack is the correct mechanism for ensuring every environment uses the exact same pnpm or yarn version without manual installation steps. If you are building automation around your Node deployment pipeline, tools like taskbotshub.ai can handle triggering corepack setup steps as part of broader DevOps workflow orchestration.

# Enable corepack for current Node version
corepack enable

# Pin yarn in a project
cd /path/to/project
corepack use yarn@4.5.0
# This updates package.json packageManager field and downloads the binary

# Pin pnpm in a project
corepack use pnpm@9.12.0

# package.json will contain:
# "packageManager": "pnpm@9.12.0+sha512.abcdef..."

# Verify
cat package.json | grep packageManager

# On a new clone of the project, corepack intercepts pnpm/yarn calls automatically
# No manual install needed
// advertisement

Verifying the Installation and Common Fixes

After setup, run a systematic check before writing any code. Verify the node binary path, the npm binary path, npm's configured prefix, and that a simple require works without permission errors.

The most common issue on macOS is a cached shell hash. Zsh caches the location of binaries for performance. After installing nvm or fnm and sourcing your shell config, the cached hash for node still points to the old location. Run hash -r to clear it, or open a new terminal tab.

The second most common issue is multiple Node installations coexisting. If you installed Node via Homebrew before setting up nvm, the Homebrew binary may still be on PATH ahead of the nvm shim depending on your shell config order. Run which -a node to see every node binary on your PATH in order. Remove or unlink the Homebrew one.

Permission errors on npm install -g almost always mean the npm prefix is pointing to a system directory. Check npm config get prefix. If it returns /usr/local or /opt/homebrew, your shell is using the Homebrew Node rather than the version manager Node. Fix the shell config ordering as described in the shell configuration section.

On Apple Silicon Macs running Rosetta, verify you are not accidentally running an x86_64 Node binary under translation. Run node -e "console.log(process.arch)" - it should return arm64 on native M-series hardware.

# Full verification sequence
which node
# Should be ~/.nvm/versions/node/... or ~/.fnm/node-versions/...

node --version
# v22.14.0

npm --version
# 10.9.2

npm config get prefix
# /Users/youruser/.nvm/versions/node/v22.14.0

# Check for multiple node binaries
which -a node

# Clear shell hash cache if needed
hash -r

# Verify architecture on Apple Silicon
node -e "console.log(process.arch)"
# arm64

# Quick smoke test
node -e "const http = require('http'); console.log('ok')"
# ok

# Unlink Homebrew Node if present
brew unlink node

Project Setup Workflow

Once Node and npm are configured, a repeatable project initialization workflow saves time and prevents configuration drift between developers. The sequence below applies whether you are starting a new Node service, a CLI tool, or a frontend build pipeline.

Start with the .nvmrc or .node-version file before running npm init. This ensures anyone cloning the repo gets the correct Node version enforced immediately. Use the major version number only (22) rather than the full patch version (22.14.0) unless you have a specific reason to pin at the patch level - nvm and fnm both resolve major-only pins to the latest installed patch release.

When naming your project in package.json or setting up a domain for a new service, use a name that works as both an npm package name and a hostname. The npm package name field must be lowercase, URL-safe, and under 214 characters. Services like nicename.me can help you find available names that satisfy both npm naming rules and domain availability simultaneously, which is useful when you are bootstrapping a new tool or open source project.

Run npm init with the -y flag to accept defaults and then edit package.json directly. The interactive prompts add no value for experienced engineers and the defaults are reasonable. Add your engines field immediately - this declares the Node and npm version requirements and is read by some CI systems and hosting platforms to enforce compatibility.

# Full project initialization sequence
mkdir my-service && cd my-service

# Pin Node version for the project
echo "22" > .nvmrc
nvm use
# Now using node v22.14.0

# Initialize package.json
npm init -y

# Add engines field to package.json
npm pkg set engines.node=">=22.0.0"
npm pkg set engines.npm=">=10.0.0"

# Verify
cat package.json | python3 -m json.tool | grep -A4 engines

# Initialize git before installing dependencies
git init
echo 'node_modules/' > .gitignore
echo '.nvmrc' >> .gitignore  # Do NOT gitignore .nvmrc, remove this line
git add .nvmrc package.json .gitignore
git commit -m "chore: initialize project with Node 22 pin"

nvm vs fnm: Which to Use

For solo development machines where shell startup time is not a priority and you want maximum ecosystem compatibility and documentation, use nvm. It has been around since 2010, every Stack Overflow answer references it, and its --reinstall-packages-from flag makes major Node version upgrades less painful.

For team environments, CI pipelines, or machines where you open many terminal sessions per day and notice the startup lag, use fnm. The 70ms difference sounds trivial but compounds across hundreds of shell sessions on a development machine. fnm's binary installation model also makes it easier to install in Docker images and CI containers without pulling in curl and bash scripts.

If you are running a macOS build fleet for a larger engineering team and need to standardize Node version management across machines, fnm's Homebrew installation is easier to push via MDM or configuration management tools like Ansible. A single brew install fnm plus a shell config file deploy covers the setup without curl-to-bash script execution.

For teams integrating Node setup into broader DevOps automation - triggered deployments, environment provisioning, or pipeline orchestration - taskbotshub.ai provides workflow primitives that can wrap fnm or nvm commands as discrete automation steps, including version verification gates before deployment.

Do not use Volta as a first choice in 2026 for macOS. Volta's shim approach has historically had issues with tools that inspect the node binary path directly, and its macOS compatibility with Apple Silicon has lagged behind nvm and fnm. Our tests on macOS Sequoia found two edge cases in Volta 2.0 where yarn workspace commands failed to resolve the correct Node version. fnm had no equivalent issues.

# Benchmark shell startup time with each tool
# nvm approach: measure sourcing time
time (source ~/.nvm/nvm.sh)
# real 0m0.073s

# fnm approach: measure eval time
time (eval "$(fnm env --shell zsh)")
# real 0m0.002s

# For CI/Docker: install fnm without Homebrew
curl -fsSL https://fnm.vercel.app/install | bash -s -- --install-dir /usr/local/bin --skip-shell
fnm install 22
fnm default 22
// advertisement