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.
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.jsonand runnpm ciin CI for deterministic installs — never a barenpm installon a runner. - Add
npm ci --ignore-scriptsas a pre-flight step so artifacts are consistent and install-time scripts cannot run unexpectedly. - Run
npm ls --all --workspacesperiodically to catch phantom dependencies or version drift across the tree. - Keep
"private": trueat the root and publish per package withnpm 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 — the workspaces array, hoisting, and the workspace protocol in full.
- Migrating from Yarn 1 to pnpm Workspaces — the next step if you outgrow npm's flat hoisting.
- Lockfile Management Strategies — committing and enforcing the unified package-lock.json.