Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

6.9 — Local Development Environment Setup

For .NET engineers who know: Install Visual Studio, install the .NET SDK, clone the repo, press F5 You’ll learn: The Node.js ecosystem has more moving parts — Node version management, pnpm, shell config, and a setup script that gets a new engineer from zero to running in under 20 minutes Time: 15-20 min read


The .NET Way (What You Already Know)

Setting up a .NET project on a new machine is a well-understood procedure. Download the .NET SDK installer for the version your project targets, install Visual Studio or Rider, clone the repository, and open the solution. The IDE handles package restore on first build. If the project targets .NET 8, you install .NET 8. Two projects targeting different versions coexist cleanly — the SDK installer handles side-by-side installs. The only variable is whether the project requires SQL Server locally (Docker or LocalDB), but even that is well-documented.

The predictability is real and worth acknowledging before explaining why the Node.js equivalent requires more deliberate setup.


Why Node Setup Has More Moving Parts

Several factors combine to make Node.js environment setup less deterministic by default:

Node.js does not ship with a global version manager. The .NET SDK installer handles multiple side-by-side versions transparently. With Node.js, you install a version manager separately, and if you do not, you end up with a single system Node version that conflicts with every project requiring a different version.

The global vs. local package distinction matters more. In .NET, tools like the EF CLI are installed globally but versioned per-project via <PackageReference>. In Node.js, the boundary between global tools and project dependencies is blurrier, and installing tools globally in the wrong version causes subtle failures.

Shell configuration affects the toolchain. Version managers (nvm, fnm) inject themselves into your shell via .bashrc/.zshrc. If those files are not configured correctly, the version manager exists but does not activate on shell start, and engineers spend 20 minutes debugging why node -v returns the wrong version.

The solution is to make setup explicit, scripted, and reproducible — which is what this article covers.


Step 1: Node.js Version Management

Never install Node.js directly from nodejs.org for development work. Install a version manager and use it to install and switch Node versions.

fnm (Fast Node Manager) is written in Rust, loads faster than nvm, and handles .nvmrc and .node-version files automatically:

# macOS (Homebrew)
brew install fnm

# Windows (Winget)
winget install Schniz.fnm

# Linux (curl installer)
curl -fsSL https://fnm.vercel.app/install | bash

Add fnm to your shell in ~/.zshrc (macOS) or ~/.bashrc (Linux):

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

The --use-on-cd flag tells fnm to automatically switch Node versions when you cd into a directory that has a .nvmrc or .node-version file. This is the equivalent of global.json for the .NET SDK — the version is declared in the repo, and the toolchain respects it automatically.

# Install and activate a specific Node version
fnm install 22          # install Node 22 LTS
fnm use 22              # activate it in current shell
fnm default 22          # make it the default for new shells

# Verify
node -v                 # v22.x.x
npm -v                  # 10.x.x

Option B: nvm (Widely used, slower)

nvm has the largest community and most documentation, but it is a shell script and adds ~70ms to every shell startup:

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

# ~/.zshrc (added automatically by installer, verify it is there)
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
nvm install 22
nvm use 22
nvm alias default 22

Declaring the Node version in the repository

Add a .nvmrc file (works with both fnm and nvm) to the repository root:

# .nvmrc
22

With fnm’s --use-on-cd flag, cd-ing into the project directory automatically switches to the correct Node version. New engineers who cd into the wrong directory and see the wrong node version will now get a useful error rather than a silent wrong-version build.


Step 2: pnpm Installation

Install pnpm after Node.js is set up. The canonical installer is Corepack, which ships with Node 18+ and manages package manager versions:

# Enable Corepack (ships with Node, just needs activation)
corepack enable

# Install the pnpm version specified in package.json
corepack prepare pnpm@latest --activate

Alternatively, install pnpm globally:

npm install -g pnpm@9

# Verify
pnpm -v    # 9.x.x

Pin the pnpm version in package.json so every engineer and CI uses the same version:

{
  "name": "myapp",
  "packageManager": "pnpm@9.15.0",
  "engines": {
    "node": ">=22.0.0",
    "pnpm": ">=9.0.0"
  }
}

The packageManager field tells Corepack which version to activate. The engines field tells pnpm to warn (or fail with engine-strict=true) if the installed versions do not match.


Step 3: VS Code Extensions for TypeScript / React / Vue

VS Code is the standard editor for this stack. These extensions are non-negotiable for productive TypeScript development:

Required:

ExtensionIDPurpose
ESLintdbaeumer.vscode-eslintLint errors inline as you type
Prettieresbenp.prettier-vscodeAuto-format on save
TypeScript Vue Plugin (Volar)Vue.volarVue 3 SFC support (replaces Vetur)
PrismaPrisma.prismaSchema syntax highlighting, format on save
GitLenseamodio.gitlensInline blame, PR annotations
Error Lensusernamehzq.error-lensInline error messages without hovering

Highly recommended:

ExtensionIDPurpose
Thunder Clientrangav.vscode-thunder-clientREST client embedded in VS Code
Dockerms-azuretools.vscode-dockerDocker Compose management
DotENVmikestead.dotenv.env file syntax highlighting
Import Costwix.vscode-import-costShows bundle size of each import inline
Tailwind CSS IntelliSensebradlc.vscode-tailwindcssAutocomplete for Tailwind classes

Install all at once:

code --install-extension dbaeumer.vscode-eslint
code --install-extension esbenp.prettier-vscode
code --install-extension Vue.volar
code --install-extension Prisma.prisma
code --install-extension eamodio.gitlens
code --install-extension usernamehzq.error-lens
code --install-extension rangav.vscode-thunder-client
code --install-extension ms-azuretools.vscode-docker
code --install-extension mikestead.dotenv
code --install-extension bradlc.vscode-tailwindcss

Workspace settings (.vscode/settings.json committed to the repo):

{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true,
  "[vue]": {
    "editor.defaultFormatter": "Vue.volar"
  },
  "[prisma]": {
    "editor.defaultFormatter": "Prisma.prisma"
  },
  "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue"],
  "files.exclude": {
    "**/.git": true,
    "**/node_modules": true,
    "**/.next": true,
    "**/dist": true
  },
  "search.exclude": {
    "**/node_modules": true,
    "**/.next": true,
    "**/dist": true,
    "pnpm-lock.yaml": true
  }
}

Committing .vscode/settings.json ensures every engineer in the repo gets the same formatter, linter, and TypeScript SDK configuration — equivalent to the .editorconfig + Roslyn analyzer settings in a .NET solution.


Step 4: Essential CLI Tools

# GitHub CLI — pull request and issue management
brew install gh

# Authenticate once
gh auth login

# Docker — local containers for PostgreSQL, Redis
brew install --cask docker
# Start Docker Desktop after install

# Render CLI
npm install -g @render-oss/render-cli
render login

# jq — JSON processing in terminal scripts
brew install jq

# httpie — friendlier alternative to curl for API testing
brew install httpie

Verify the GitHub CLI is authenticated and can reach your organization:

gh auth status
gh repo list your-org --limit 5

Step 5: Shell Configuration for Productivity

A well-configured shell reduces friction on daily tasks. This is not required for productivity but pays off quickly.

~/.zshrc additions:

# fnm — Node version manager (add after fnm install)
eval "$(fnm env --use-on-cd --shell zsh)"

# pnpm shortcuts
alias pi="pnpm install"
alias pd="pnpm dev"
alias pb="pnpm build"
alias pt="pnpm test"
alias ptw="pnpm test --watch"

# Git shortcuts
alias gs="git status"
alias gp="git pull --rebase"
alias gcm="git checkout main && git pull --rebase"

# Render CLI
alias rl="render logs --tail"      # tail logs for a service

# Quick project navigation (adjust paths to match your machine)
alias dev="cd ~/dev2"
alias app="cd ~/dev2/myapp"

# Show current Node version in the prompt (optional — zsh only)
# Add to PROMPT variable if you use a custom prompt

.editorconfig committed to the repository root:

# .editorconfig — respected by VS Code, WebStorm, and most editors
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

[Makefile]
indent_style = tab

[*.{yml,yaml}]
indent_size = 2

[*.json]
indent_size = 2

Setup Script

The following script installs everything on a fresh macOS machine. Run it once on a new machine or hand it to a new engineer on their first day:

#!/usr/bin/env bash
# setup.sh — Bootstrap a macOS development environment for the JS/TS stack
# Usage: bash setup.sh
# Safe to re-run — all steps are idempotent

set -e

echo "==> Checking for Homebrew..."
if ! command -v brew &>/dev/null; then
  echo "Installing Homebrew..."
  /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi

echo "==> Installing CLI tools via Homebrew..."
brew install fnm gh jq httpie

echo "==> Installing Docker Desktop..."
if ! command -v docker &>/dev/null; then
  brew install --cask docker
  echo "Docker Desktop installed. Launch it from Applications before continuing."
fi

echo "==> Configuring fnm in ~/.zshrc..."
ZSHRC="$HOME/.zshrc"
FNM_LINE='eval "$(fnm env --use-on-cd --shell zsh)"'
if ! grep -q "fnm env" "$ZSHRC" 2>/dev/null; then
  echo "" >> "$ZSHRC"
  echo "# fnm — Node version manager" >> "$ZSHRC"
  echo "$FNM_LINE" >> "$ZSHRC"
  echo "fnm configuration added to ~/.zshrc"
else
  echo "fnm already configured in ~/.zshrc"
fi

echo "==> Loading fnm in current shell..."
eval "$(fnm env --use-on-cd --shell bash)"

echo "==> Installing Node.js 22 LTS..."
fnm install 22
fnm default 22
fnm use 22
echo "Node version: $(node -v)"

echo "==> Enabling Corepack and installing pnpm..."
corepack enable
corepack prepare pnpm@latest --activate
echo "pnpm version: $(pnpm -v)"

echo "==> Installing Render CLI..."
npm install -g @render-oss/render-cli

echo "==> Installing VS Code extensions..."
EXTENSIONS=(
  "dbaeumer.vscode-eslint"
  "esbenp.prettier-vscode"
  "Vue.volar"
  "Prisma.prisma"
  "eamodio.gitlens"
  "usernamehzq.error-lens"
  "rangav.vscode-thunder-client"
  "ms-azuretools.vscode-docker"
  "mikestead.dotenv"
  "bradlc.vscode-tailwindcss"
)

if command -v code &>/dev/null; then
  for ext in "${EXTENSIONS[@]}"; do
    code --install-extension "$ext" --force
  done
  echo "VS Code extensions installed."
else
  echo "VS Code CLI (code) not found. Install VS Code and add it to PATH, then re-run."
fi

echo "==> Authenticating GitHub CLI..."
if ! gh auth status &>/dev/null; then
  gh auth login
else
  echo "GitHub CLI already authenticated."
fi

echo ""
echo "==> Setup complete. Next steps:"
echo "    1. Start Docker Desktop from Applications"
echo "    2. Run: render login"
echo "    3. Open a new terminal for fnm changes to take effect"
echo "    4. Clone your project: gh repo clone your-org/your-repo"
echo "    5. cd into the project and run: pnpm install"

First-Run Experience for a New Engineer

After running the setup script:

# Clone the repository
gh repo clone your-org/your-repo
cd your-repo

# fnm reads .nvmrc and switches Node automatically (--use-on-cd)
# Verify the correct version is active
node -v         # should match .nvmrc

# Install project dependencies
pnpm install

# Start the local database
docker compose up -d postgres redis

# Run migrations and seed
pnpm db:migrate
pnpm db:seed

# Start all services in development mode
pnpm dev

# In a separate terminal: run tests in watch mode
pnpm test --watch

The docker-compose.yml that supports local development:

# docker-compose.yml — local development services only
version: '3.9'

services:
  postgres:
    image: postgres:15-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: myapp_dev
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: devpassword
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

The .env.example that engineers copy to .env.local:

# .env.example — copy to .env.local and fill in values
# Never commit .env.local

# Database (matches docker-compose.yml defaults)
DATABASE_URL=postgresql://myapp:devpassword@localhost:5432/myapp_dev

# Redis
REDIS_URL=redis://localhost:6379

# Auth (Clerk — get from https://dashboard.clerk.com)
CLERK_SECRET_KEY=sk_test_your_key_here
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_key_here

# Sentry (optional for local dev)
SENTRY_DSN=

# Stripe (sandbox keys for local dev)
STRIPE_SECRET_KEY=sk_test_your_key_here
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_key_here

Troubleshooting Common Environment Issues

node: command not found after installing fnm fnm changes to ~/.zshrc only take effect in new terminal windows. Open a new terminal and retry. If that fails, confirm the eval "$(fnm env...)" line is in ~/.zshrc and not ~/.bashrc (macOS Catalina+ uses zsh by default).

pnpm install fails with ENOENT: no such file or directory, open '.../pnpm-lock.yaml' The lockfile is not in the repository. Run pnpm install from the repository root (the directory containing package.json), not from a subdirectory.

pnpm install installs a different lockfile version from CI The packageManager field in package.json specifies the exact pnpm version. Run corepack use pnpm@X.Y.Z (matching the version in package.json) to align your local pnpm with CI.

prisma migrate dev fails with P1001: Can't reach database server The PostgreSQL container is not running. Run docker compose up -d postgres and wait for the health check to pass (docker compose ps shows healthy).

VS Code shows TypeScript errors that the CLI does not VS Code may be using its bundled TypeScript version instead of the project’s. Open the command palette (Cmd+Shift+P), run “TypeScript: Select TypeScript Version”, and choose “Use Workspace Version”. The workspace setting "typescript.tsdk": "node_modules/typescript/lib" in .vscode/settings.json sets this automatically, but it only activates if you accept the prompt.

ESLint shows no errors but pnpm lint finds issues The VS Code ESLint extension and the CLI use the same configuration, but the extension may be disabled for certain file types. Check the ESLint output panel (View > Output > ESLint) for errors. If the extension is not running on .ts files, add "typescript" to the eslint.validate workspace setting.

gh auth fails behind a corporate proxy Configure the GitHub CLI to use the proxy: export HTTPS_PROXY=http://proxy.company.com:8080. Add this to ~/.zshrc for persistence.

Port 5432 already in use Another PostgreSQL instance is running (locally installed Postgres or another Docker container). Stop it with brew services stop postgresql@15 or docker ps | grep postgres followed by docker stop <container-id>.


Gotchas for .NET Engineers

1. Node version switches are per-shell, not global, unless you set a default. Running fnm use 22 activates Node 22 in the current terminal only. If you open a new terminal without --use-on-cd or without the eval "$(fnm env...)" line in your shell config, you get the default version. Set the default explicitly with fnm default 22 after installing. This is unlike the .NET SDK, where installing a version makes it globally available immediately.

2. npm install and pnpm install do not do the same thing with lock files. If someone on the team runs npm install in a pnpm repository, npm creates a package-lock.json alongside the existing pnpm-lock.yaml, and the two lockfiles diverge. CI uses pnpm install --frozen-lockfile, which reads pnpm-lock.yaml. The engineer’s npm install effectively becomes invisible to everyone else. Add .npmrc to the repo root to prevent this:

# .npmrc
engine-strict=true

And add to package.json:

{
  "scripts": {
    "preinstall": "npx only-allow pnpm"
  }
}

This causes npm install and yarn install to fail with a clear message pointing to pnpm.

3. pnpm install --frozen-lockfile fails when package.json is changed without running pnpm install locally. CI runs with --frozen-lockfile to prevent accidental lockfile drift. If an engineer adds a dependency to package.json and pushes without running pnpm install locally to update pnpm-lock.yaml, CI fails. The fix is to always run pnpm install after editing package.json and commit both files together.


Quick Reference

# Node version management (fnm)
fnm install 22              # install Node 22
fnm use 22                  # use in current shell
fnm default 22              # set as default for new shells
fnm list                    # list installed versions
fnm list-remote             # list available versions to install
cat .nvmrc                  # see which version this project expects

# pnpm
pnpm install                # install all dependencies
pnpm install --frozen-lockfile  # CI-safe install (no lockfile changes)
pnpm add <pkg>              # add a runtime dependency
pnpm add -D <pkg>           # add a dev dependency
pnpm remove <pkg>           # remove a dependency
pnpm update                 # update all packages within semver ranges
pnpm exec prisma migrate dev  # run prisma CLI via pnpm

# Docker (local services)
docker compose up -d        # start all services in background
docker compose down         # stop and remove containers
docker compose logs -f      # tail all service logs
docker compose ps           # show container status

# GitHub CLI
gh repo clone org/repo      # clone repository
gh pr list                  # list open PRs
gh pr create                # create a PR interactively
gh pr checkout 42           # check out PR #42 locally
gh issue list               # list open issues

# VS Code extensions (install from CLI)
code --install-extension <extension-id>
code --list-extensions      # list all installed extensions

Further Reading