Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

Setting Up npm Workspaces for Small Teams

A small team rarely needs Nx or Turborepo to share code across two or three packages — native npm workspaces already do dependency hoisting, cross-package symlinks, and a single unified lockfile. The friction is almost always the same first failure: a peer-dependency ERESOLVE because sibling packages disagree on a version, or because the root never declared its workspaces. This guide gets a clean npm workspace running and shows how to clear that error for good.

Exact symptoms and error messages

npm ERR! ERESOLVE unable to resolve dependency tree
npm ERR! Could not resolve dependency:
npm ERR! peer react@"^18.0.0" from @team/shared-ui@1.0.0
npm ERR! Conflicting peer dependency: react@17.0.2

This halts npm install, which blocks CI builds, local dev setup, and the symlink hoisting that makes the workspace usable in the first place.

Root cause analysis

npm v7+ enforces strict peer-dependency resolution and automatic workspace linking. The ERESOLVE failure triggers when sibling packages declare mismatched ranges, when the root package.json omits the workspaces array, or when internal references use a bare semver range instead of the workspace:* protocol. Without an explicit workspaces array, npm treats each subdirectory as an isolated project and resolves internal packages against the public registry rather than creating local symlinks. The field-level mechanics of that array and npm's hoisting behavior are detailed in the Workspace Configuration Deep Dive, which is the foundational concept this fix builds on within your Core JavaScript Package Workflows.

npm resolves an internal package: registry versus workspace symlink Without a workspaces array npm resolves a sibling from the registry and hits a peer conflict; with the array and the workspace protocol it links the local package. import @team/shared-ui from @team/web-app no workspaces array resolves from registry ERESOLVE peer conflict workspaces + workspace:* local symlink to packages/ resolves, no registry lookup
Declare the workspaces array and use the workspace protocol so npm links the local package instead of hitting a registry peer conflict.

Resolution and configuration patch

Step 1 — Declare the workspace glob at the root so npm links subdirectories instead of isolating them:

{
  "name": "@team/monorepo",
  "private": true,
  "workspaces": ["packages/*"],
  "engines": { "node": ">=18.0.0" }
}

Step 2 — Reference siblings with the workspace:* protocol so resolution stays local:

{
  "name": "@team/shared-utils",
  "version": "1.0.0",
  "main": "dist/index.js",
  "dependencies": {
    "@team/config": "workspace:*"
  }
}

Step 3 — Align peer dependencies across packages. The ERESOLVE above is a genuine version disagreement: standardize every package on one compatible range, for example "react": "^18.2.0".

Step 4 — Clean-slate reinstall to regenerate a unified lockfile with correct symlinks:

rm -rf node_modules package-lock.json
npm install

If you need a temporary bypass while aligning ranges during an initial migration, an .npmrc can relax strictness — but revert it once the graph is consistent:

# .npmrc — temporary migration bypass only; remove after stabilizing
auto-install-peers=true
strict-peer-dependencies=false

CLI validation and debug commands

# Confirm the workspaces array is actually declared
node -e "console.log(JSON.stringify(require('./package.json').workspaces))"
# Expected: ["packages/*"]

# Verify internal packages are symlinked, not pulled from the registry
npm ls --workspaces --depth=0
# Success: internal packages show "-> ./packages/<name>" symlink targets

# Surface peer-range disagreements before they fail an install
grep -r '"react":' packages/*/package.json

Prevention and CI/CD guardrails

  • Commit package-lock.json and run npm ci in CI for deterministic installs — never a bare npm install on a runner.
  • Add npm ci --ignore-scripts as a pre-flight step so artifacts are consistent and install-time scripts cannot run unexpectedly.
  • Run npm ls --all --workspaces periodically to catch phantom dependencies or version drift across the tree.
  • Keep "private": true at the root and publish per package with npm publish --workspace packages/<name> to prevent accidental registry pushes.
  • Pin one shared range per widely used peer (React, TypeScript) and enforce it in code review.

Frequently Asked Questions

Do small teams need a dedicated monorepo tool like Nx or Turborepo? No. Native npm workspaces handle hoisting, cross-package symlinking, and a unified lockfile out of the box. Reach for a task runner only when you need distributed caching or complex build orchestration — typically past roughly ten packages or when CI times become a bottleneck.

How does npm handle versioning across workspace packages? npm does not synchronize versions; each package keeps its own version. Using workspace:* in dependencies makes the resolver always reference the current local build state instead of a published registry version.

Why does npm publish fail for workspace packages? The CLI blocks bulk publishing from the root to avoid registry pollution. Publish from the target package directory, or scope it explicitly with npm publish --workspace packages/<name>.

What clears the ERESOLVE peer conflict for good? Align every package on one compatible peer range and reinstall from a clean node_modules + package-lock.json. The .npmrc bypass only hides the conflict; the durable fix is a single agreed range across the workspace.

Related

Workspace Configuration Deep Dive