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

Package Management: NuGet vs. npm/pnpm

For .NET engineers who know: NuGet, MSBuild, dotnet add package, packages.lock.json, and the NuGet cache at %USERPROFILE%\.nuget\packages You’ll learn: How npm and pnpm package management maps to NuGet, where it diverges, and why those differences cause real problems if you don’t understand them Time: 15-20 minutes


The .NET Way (What You Already Know)

NuGet is the package manager for the .NET ecosystem. You reference packages in your .csproj file, restore them with dotnet restore, and the runtime uses the package graph to resolve dependencies. The key properties of this system:

  • Centralized registry: NuGet.org is the default and dominant package source. Private feeds (Azure Artifacts, GitHub Packages) are opt-in.
  • Version pinning by default: When you dotnet add package Newtonsoft.Json, you get an exact version in your .csproj. No ranges unless you write them manually.
  • Global cache: All packages are stored once in ~/.nuget/packages and shared across every project on your machine. Two projects using Newtonsoft.Json 13.0.3 share the same on-disk files.
  • MSBuild integration: Package restore is part of the build pipeline. dotnet build runs restore implicitly.
  • Lockfile is optional: packages.lock.json exists but most projects don’t use it. Reproducibility is ensured by version pinning in .csproj.
  • Transitive dependency resolution: NuGet resolves the full dependency graph and selects the lowest applicable version that satisfies all constraints — a “nearest wins” strategy when conflicts arise.
<!-- .csproj — explicit, versioned, readable -->
<ItemGroup>
  <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
  <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
  <PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
</ItemGroup>

This is the mental model you’re bringing to npm. Some of it maps cleanly. A significant portion does not.


The npm/pnpm Way

package.json vs. .csproj

The package.json file is the npm equivalent of .csproj — it declares your package identity, dependencies, and scripts. The structural difference is that .csproj is XML with a tight MSBuild contract, while package.json is a freeform JSON document with only a few reserved keys.

// package.json — the JS equivalent of .csproj
{
  "name": "my-api",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "express": "^4.18.2",
    "zod": "^3.22.4"
  },
  "devDependencies": {
    "typescript": "^5.3.3",
    "@types/express": "^4.17.21",
    "vitest": "^1.2.0"
  },
  "scripts": {
    "build": "tsc",
    "dev": "ts-node-dev src/main.ts",
    "test": "vitest run",
    "lint": "eslint src"
  }
}

The dependencies / devDependencies split is the first conceptual shift: devDependencies contains packages only needed during development and build (compilers, test runners, linters). They are not installed in production environments when you run npm install --production or pnpm install --prod. In .NET there is no equivalent concept at the package reference level — build tools are either part of the SDK or handled by MSBuild targets.

Semver Ranges: The ^ and ~ Problem

This is the most operationally significant difference from NuGet.

In .csproj, Version="13.0.3" means exactly 13.0.3. In package.json, version strings are ranges by default:

SyntaxMeaning.NET equivalent
"4.18.2"Exactly 4.18.2Version="4.18.2"
"^4.18.2">=4.18.2 and <5.0.0 (compatible minor/patch)No direct equivalent
"~4.18.2">=4.18.2 and <4.19.0 (patch only)No direct equivalent
">=4.0.0"4.0.0 or higherNo direct equivalent
"*"Any versionClosest: omit the version entirely

When you run npm install or pnpm install, the package manager resolves each range to a specific version based on what’s currently published on the registry. Run it again six months later and you may get different versions — not because you changed anything, but because new patch releases were published within the allowed range.

This is why lockfiles exist and must be committed.

Lockfiles: Your Reproducibility Guarantee

In .NET, version pinning in .csproj provides reproducibility. In npm/pnpm, version ranges in package.json mean the lockfile is the reproducibility mechanism.

ConceptNuGetnpmpnpm
Manifest.csprojpackage.jsonpackage.json
Lockfilepackages.lock.json (optional)package-lock.jsonpnpm-lock.yaml
Install from lockfiledotnet restore --locked-modenpm cipnpm install --frozen-lockfile
Cache location~/.nuget/packages~/.npm (content-addressed)~/.local/share/pnpm/store

The lockfile records the exact resolved version of every dependency and transitive dependency. It must be committed to source control. It must be used in CI. Without it, two engineers cloning the same repo may install different package versions.

In CI, always use the locked install command:

# CI pipelines — install exactly what the lockfile specifies
pnpm install --frozen-lockfile    # fails if lockfile is out of date
npm ci                             # npm equivalent

Never use npm install or pnpm install without --frozen-lockfile in CI — these commands update the lockfile if ranges are satisfied by newer versions, defeating the purpose of locking.

node_modules vs. the NuGet Cache

The NuGet cache stores packages once at ~/.nuget/packages and all projects share them. npm installs packages directly into a node_modules folder inside each project. This has significant consequences:

The node_modules size problem: A freshly bootstrapped Next.js project can have 300MB+ in node_modules. If you work on five projects, that’s potentially 1.5GB of packages — most of them duplicates. This is not a hypothetical. It is a daily reality. The node_modules folder is famously joked about as the heaviest object in the universe for a reason.

You never commit node_modules. Every .gitignore for a Node.js project must include node_modules/. This is not optional. Committing node_modules is one of the few genuinely catastrophic mistakes a .NET engineer new to the JS ecosystem can make — it adds hundreds of megabytes to the repository and breaks everything downstream.

Why pnpm solves this: pnpm uses a content-addressed global store (similar to NuGet’s cache) and creates symlinks in node_modules rather than copying files. A package used by five projects is stored once on disk. This is the primary reason we use pnpm instead of npm.

graph TD
    subgraph NuGet["NuGet (all projects share one cache)"]
        NC["~/.nuget/packages/newtonsoft.json/13.0.3/\n← stored once"]
    end

    subgraph npm["npm (each project has its own copy)"]
        NA["project-a/node_modules/lodash/\n← full copy"]
        NB["project-b/node_modules/lodash/\n← another full copy"]
        NC2["project-c/node_modules/lodash/\n← another full copy"]
    end

    subgraph pnpm["pnpm (symlinks to one global store)"]
        PS["~/.local/share/pnpm/store/v3/lodash/4.17.21/\n← stored once"]
        PA["project-a/node_modules/lodash"]
        PB["project-b/node_modules/lodash"]
        PC["project-c/node_modules/lodash"]
        PA -->|symlink| PS
        PB -->|symlink| PS
        PC -->|symlink| PS
    end

Installing and Managing Packages

The CLI commands map cleanly once you understand the structure:

# Adding a package
dotnet add package Newtonsoft.Json --version 13.0.3
pnpm add zod                          # adds to dependencies, installs latest
pnpm add -D typescript                # adds to devDependencies (-D)
pnpm add zod@3.22.4                   # exact version

# Removing a package
dotnet remove package Newtonsoft.Json
pnpm remove zod

# Restoring/installing all packages
dotnet restore
pnpm install

# Listing installed packages
dotnet list package
pnpm list

# Updating packages
dotnet add package Newtonsoft.Json   # re-add to get latest compatible
pnpm update zod                       # updates within semver range
pnpm update zod --latest              # updates to latest, ignores range

Scripts in package.json vs. MSBuild Targets

MSBuild provides a task system with BeforeBuild, AfterBuild, custom Target elements, and rich dependency modeling. The scripts section in package.json is a simpler equivalent: named shell commands that can be invoked with pnpm run <name>.

<!-- .csproj MSBuild target -->
<Target Name="GenerateApiClient" BeforeTargets="Build">
  <Exec Command="nswag run nswag.json" />
</Target>
// package.json scripts
{
  "scripts": {
    "build": "tsc --project tsconfig.json",
    "build:full": "pnpm run generate && pnpm run build",
    "generate": "openapi-typescript api.yaml -o src/api-types.ts",
    "dev": "ts-node-dev --respawn src/main.ts",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "lint": "eslint src --ext .ts",
    "lint:fix": "eslint src --ext .ts --fix",
    "typecheck": "tsc --noEmit"
  }
}

Scripts run with pnpm run <name>, or for common ones like build, test, dev, and start, you can omit run:

pnpm build          # runs the "build" script
pnpm dev            # runs the "dev" script
pnpm run lint:fix   # "run" is required for names with colons/custom names

There is no direct equivalent to MSBuild’s dependency graph between targets. If you need script A to run before script B, you either chain them explicitly ("build:full": "pnpm run generate && pnpm run build") or use a tool like Turborepo (covered in Article 6.5).

You can also run scripts from packages directly without adding them to scripts:

pnpm exec tsc --version        # run a locally installed binary
pnpm dlx create-next-app@latest  # run without installing (like dotnet tool run)

Global vs. Local Installs

In .NET, global tools are installed with dotnet tool install -g and available everywhere. In npm/pnpm, global installs exist but are discouraged:

# Global install (works, but avoid if possible)
pnpm add -g typescript
tsc --version    # now available globally

# Local install (preferred)
pnpm add -D typescript
pnpm exec tsc --version    # run via pnpm exec
# or add to package.json scripts and run via pnpm run

The reason to prefer local installs: reproducibility. If TypeScript is installed globally at version 5.2 on your machine but a colleague has 5.0, you will get different compilation behavior. Local installs pin the version in package.json and the lockfile, guaranteeing every developer and CI pipeline uses the same version.

The one exception is project scaffolding tools you run once (create-next-app, nest new). For those, use pnpm dlx (equivalent to npx) to run them transiently without installing:

pnpm dlx create-next-app@latest my-app
pnpm dlx @nestjs/cli@latest new my-api

Auditing Dependencies for Vulnerabilities

npm and pnpm have built-in audit commands that check your dependency tree against a vulnerability database:

pnpm audit                    # show all vulnerabilities
pnpm audit --audit-level high # fail only for high/critical
pnpm audit --fix              # attempt to fix by upgrading within ranges

The output maps severity levels (critical, high, moderate, low, info) to CVE identifiers and the affected package. A typical audit finding:

┌─────────────────────────────────────────────┐
│                    moderate                   │
│   Prototype Pollution in lodash               │
│   Package: lodash                             │
│   Patched in: >=4.17.21                       │
│   Dependency of: my-lib > some-package        │
│   Path: my-lib > some-package > lodash        │
│   More info: https://npmjs.com/advisories/... │
└─────────────────────────────────────────────┘

The Path field is important — it shows that the vulnerable lodash is a transitive dependency (your dependency some-package depends on it, not your code directly). Fixing it may require waiting for some-package to publish an updated version, or overriding the transitive dependency version using pnpm’s overrides field:

// package.json — override a transitive dependency version
{
  "pnpm": {
    "overrides": {
      "lodash": ">=4.17.21"
    }
  }
}

This is the equivalent of the <PackageReference> version floor override in NuGet. It forces pnpm to use at least 4.17.21 regardless of what transitive dependencies request.

For team-wide continuous scanning, we integrate Snyk (covered in Article 7.3) into the CI pipeline. The built-in pnpm audit is useful for immediate checks; Snyk provides richer reporting and automated fix PRs.


Key Differences

ConceptNuGet (.NET)npm/pnpm (JS/TS)
Package manifest.csproj XMLpackage.json
RegistryNuGet.orgnpmjs.com
Version defaultExact (13.0.3)Range (^13.0.3)
Lockfilepackages.lock.json (optional)pnpm-lock.yaml (required)
Package storageGlobal cache ~/.nuget/packagesLocal node_modules/ + global pnpm store
Disk efficiencyHigh (single global cache)Low (npm), High (pnpm)
Commit packagesNeverNever (same)
Build scriptsMSBuild targetsscripts in package.json
Global toolsdotnet tool install -gpnpm add -g (avoid) or pnpm dlx
Locked CI installdotnet restore --locked-modepnpm install --frozen-lockfile
Vulnerability auditdotnet list package --vulnerablepnpm audit
Dependency scopeAll deps compile to the projectdependencies vs devDependencies
Phantom depsNot possible (explicit references)Possible with npm/yarn (not with pnpm)

Gotchas for .NET Engineers

1. Phantom Dependencies Will Burn You — Use pnpm’s Strict Mode

In .NET, if you want to use a library, you must add a <PackageReference> to your .csproj. The compiler will not let you use code from a package you haven’t explicitly declared.

npm’s flat node_modules structure breaks this contract. When package A depends on lodash, npm hoists lodash to the top-level node_modules folder. Your code can now import lodash and it will work — even though lodash is not in your package.json. This is a phantom dependency: you’re using a package you never declared.

The problem: when package A later drops its lodash dependency or pins a different version, your code silently breaks at runtime with a module-not-found error or, worse, a subtle behavior change.

pnpm prevents this by design. Its node_modules structure uses symlinks and only makes explicitly declared packages importable. Attempting to import a phantom dependency throws an error immediately, at development time. This is a primary reason we use pnpm.

# With npm (phantom dependency works silently)
npm install some-package   # some-package depends on lodash
# now "import lodash" works in your code — dangerous

# With pnpm (phantom dependency caught immediately)
pnpm add some-package      # some-package depends on lodash
# "import lodash" throws: Cannot find module 'lodash'
# Correct fix: pnpm add lodash (make it explicit)

If you inherit a project that was using npm and migrate to pnpm, the phantom dependency audit can reveal dozens of packages your code relies on but never declared.

2. Peer Dependency Warnings Are Not Optional — Address Them

When you install a package in .NET, NuGet resolves the dependency graph and silently picks compatible versions. In npm/pnpm, some packages declare peer dependencies: packages they require you to have installed separately, at a specific version range, in your own project.

A common example is a React component library that lists react as a peer dependency rather than a direct dependency, because it should use your version of React, not install its own.

 WARN  Issues with peer dependencies found
└─┬ @tanstack/react-query 5.18.1
  └── ✕ unmet peer react@^18.0.0: found 17.0.2

This warning means: @tanstack/react-query@5.18.1 requires React 18, but your project has React 17. In .NET, NuGet would refuse to install or show a binding redirect. In npm, the installation succeeds with a warning — and you get subtle runtime failures or simply broken behavior.

Peer dependency warnings must be resolved, not ignored. The fix is either to upgrade the peer (upgrade React to 18) or to find a version of the package that supports your current peer version.

3. The node_modules Folder Is Not the Global Cache — Delete It Freely

NuGet’s global cache at ~/.nuget/packages is precious — clearing it forces re-downloading everything. node_modules is not the cache; it is a local installation folder that can always be recreated from the lockfile.

The correct mental model: node_modules is a build artifact, like your bin/ and obj/ folders. You can and should delete it when things behave strangely:

# The Node.js equivalent of "clean solution"
rm -rf node_modules
pnpm install

# In a monorepo — delete all node_modules recursively
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
pnpm install

This is standard practice. Engineers run it several times a week when debugging dependency issues. Unlike deleting the NuGet cache, it does not trigger a network re-download if pnpm’s global store already has the packages.

4. The ^ Range Bites You in Lockfile-Free Environments

If you ever run pnpm install without a lockfile present — which happens when you clone a fresh repo before someone committed the lockfile, or when a lockfile is incorrectly gitignored — you will install whatever the latest versions within each range are at that moment. Two engineers cloning the same repo on different days can end up with different transitive dependency versions.

The lockfile must be committed. If pnpm-lock.yaml is in your .gitignore, remove it immediately. Check your .gitignore template — some generic Node.js templates include lockfiles in .gitignore by mistake.

# Verify your lockfile is tracked
git ls-files pnpm-lock.yaml   # should output the filename if tracked
git ls-files package-lock.json

If the lockfile exists but shows constant churn in git diff with no actual dependency changes, the cause is usually different engineers using different package manager versions. Standardize the pnpm version in package.json:

{
  "packageManager": "pnpm@8.15.1"
}

5. npm Scripts Have No Dependency Graph — Order Is Your Responsibility

MSBuild targets can declare BeforeTargets, AfterTargets, and DependsOnTargets, and the build system executes them in the correct order. package.json scripts have none of this. They are independent shell commands. Ordering is enforced only by explicit chaining:

{
  "scripts": {
    "prebuild": "pnpm run generate",  // "pre" prefix runs before "build"
    "build": "tsc",
    "postbuild": "pnpm run copy-assets"  // "post" prefix runs after "build"
  }
}

The pre<name> and post<name> convention exists for simple sequencing, but it becomes unwieldy for complex pipelines. For monorepos with inter-package dependencies, use Turborepo (Article 6.5), which provides the dependency graph that package.json scripts lack.


Hands-On Exercise

This exercise sets up a minimal TypeScript project from scratch using pnpm and exercises the core package management concepts covered in this article.

Prerequisites: pnpm installed (npm install -g pnpm or via brew install pnpm), Node.js 20+.

Step 1: Create the project

mkdir pkg-exercise && cd pkg-exercise
pnpm init

This creates a minimal package.json. Open it and observe the structure.

Step 2: Add dependencies with intentional version variation

pnpm add zod                    # adds with ^ range (e.g., "^3.22.4")
pnpm add -D typescript          # dev dependency
pnpm add -D @types/node

Inspect package.json — note the ^ prefixes. Inspect pnpm-lock.yaml — note the exact resolved versions.

Step 3: Understand the lockfile

# Simulate a fresh clone
rm -rf node_modules
pnpm install --frozen-lockfile   # installs exact versions from lockfile

Now modify the zod version range in package.json to "zod": "^3.0.0" without running pnpm install. Then run:

pnpm install --frozen-lockfile   # this should fail

The --frozen-lockfile flag fails because your package.json range no longer matches the lockfile. This is how CI catches drift. Revert the change.

Step 4: Audit the dependency tree

pnpm list                         # show installed packages
pnpm list --depth 3               # show transitive dependencies
pnpm audit                        # check for vulnerabilities

Step 5: Add a script and run it

Add to package.json:

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc"
  }
}

Create a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

Create src/index.ts:

import { z } from "zod";

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

const user: User = UserSchema.parse({ name: "Alice", email: "alice@example.com" });
console.log(user);

Run it:

pnpm typecheck    # type-checks without emitting files
pnpm build        # compiles to dist/
node dist/index.js

Step 6: Observe pnpm’s strict dependency isolation

Create a second file src/attempt-phantom.ts:

// This would work with npm (if zod's deps included something useful)
// With pnpm, you can only import what you explicitly installed
import { z } from "zod";  // works — declared in package.json

// If you tried to import something not in package.json:
// import _ from "lodash";  // would fail: Cannot find module 'lodash'
// Fix: pnpm add lodash

This reinforces that pnpm enforces explicit dependency declarations — the same contract NuGet enforces in .NET.


Quick Reference

Command Mapping

dotnet CLIpnpm equivalent
dotnet restorepnpm install
dotnet restore --locked-modepnpm install --frozen-lockfile
dotnet add package Foopnpm add foo
dotnet add package Foo --version 1.2.3pnpm add foo@1.2.3
dotnet remove package Foopnpm remove foo
dotnet list packagepnpm list
dotnet list package --outdatedpnpm outdated
dotnet list package --vulnerablepnpm audit
dotnet buildpnpm build (runs build script)
dotnet testpnpm test (runs test script)
dotnet tool install -g foopnpm add -g foo (prefer pnpm dlx)
dotnet tool run foopnpm exec foo
dotnet new foo (one-off scaffolding)pnpm dlx create-foo@latest

package.json Structure Reference

{
  "name": "my-project",
  "version": "1.0.0",
  "private": true,
  "packageManager": "pnpm@8.15.1",

  "dependencies": {
    "zod": "^3.22.4"
  },

  "devDependencies": {
    "typescript": "^5.3.3",
    "@types/node": "^20.11.0",
    "vitest": "^1.2.0"
  },

  "scripts": {
    "build": "tsc",
    "dev": "ts-node-dev src/main.ts",
    "test": "vitest run",
    "test:watch": "vitest",
    "typecheck": "tsc --noEmit",
    "lint": "eslint src"
  },

  "pnpm": {
    "overrides": {
      "some-vulnerable-dep": ">=4.17.21"
    }
  }
}

Version Range Quick Reference

RangeAllowsExample resolves to
"1.2.3"Only 1.2.31.2.3
"^1.2.3">=1.2.3 <2.0.0Latest 1.x.x
"~1.2.3">=1.2.3 <1.3.0Latest 1.2.x
">=1.2.3"1.2.3 or higherLatest overall
"1.x">=1.0.0 <2.0.0Latest 1.x.x
"*"Any versionAbsolute latest

Critical .gitignore Entries

# Always ignore — recreated by pnpm install
node_modules/

# Never ignore — required for reproducibility
# (make sure these lines are NOT in your .gitignore)
# pnpm-lock.yaml
# package-lock.json
# yarn.lock

Common Diagnostic Commands

pnpm why <package>         # why is this package installed? (like NuGet dependency graph)
pnpm list --depth 10       # full dependency tree
pnpm store path            # location of pnpm's global store
pnpm store prune           # clean up unused packages from global store
pnpm dedupe                # optimize lockfile by deduplicating dependencies

Further Reading