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

Exact symptoms and error messages This halts npm install, which blocks CI builds, local dev setup, and the symlink hoisting that makes the workspace usabl Exact symptoms and error messages This halts npm install, which blocks CI builds, local dev setup, and the symlink hoisting that makes the workspace usable in the first place.
Exact symptoms and error messages — the core idea of this section at a glance.
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.

The friction small teams hit setting up npm workspaces is usually not a bug but a mismatch between npm's flat, hoisted model and the expectation that each package is fully isolated. npm installs all workspace dependencies into a single root node_modules, hoisting shared versions, which is fast and simple but means a package can import something it never declared because a sibling hoisted it — a phantom dependency that works until the graph is rearranged. Understanding that npm optimizes for simplicity over strictness explains most of the surprises.

The second common cause of trouble is running the wrong command scope. npm run build at the root looks only at the root manifest's scripts, so a build defined in each package is not found; targeting packages needs --workspaces or --workspace. Small teams migrating from a single package expect root commands to descend automatically, and learning that scope is explicit removes most of the early confusion.

Resolution and configuration patch

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

Resolution and configuration patch Step 1 — Declare the workspace glob at the root so npm links subdirectories instead of isolating them: Resolution and configuration patch Step 1 — Declare the workspace glob at the root so npm links subdirectories instead of isolating them:
Resolution and configuration patch — the core idea of this section at a glance.
{
  "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

Declare the workspaces and add thin root scripts that fan across packages, using --if-present so packages lacking a script are skipped rather than erroring:

{
  "name": "acme-monorepo",
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "build": "npm run build --workspaces --if-present",
    "test": "npm run test --workspaces --if-present"
  }
}

Use npm ci in CI for a frozen install, reference internal packages by name (npm resolves them to the local workspace), and keep the root private: true so the umbrella package is never accidentally published.

CLI validation and debug commands

CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows. CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows.
CLI validation and debug commands — the core idea of this section at a glance.
# 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

Confirm the workspace is wired correctly by checking that internal packages resolve locally and shared dependencies deduplicate:

# List the workspaces npm sees
npm query '.workspace'
# Confirm an internal dependency resolves to the local package
npm ls @acme/ui
# Confirm a shared dependency is a single copy
npm ls react

An internal package showing a local path rather than a registry version confirms the workspace link works, and a single entry for a shared dependency confirms hoisting deduplicated it rather than installing two copies.

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.
Prevention and CI/CD guardrails Prevention and CI/CD guardrails in production JavaScript package workflows. Prevention and CI/CD guardrails Prevention and CI/CD guardrails in production JavaScript package workflows.
Prevention and CI/CD guardrails — the core idea of this section at a glance.
  • Set the root package private: true so the monorepo umbrella cannot be published.

  • Add root delegating scripts with --if-present so a heterogeneous package set runs cleanly.

  • Use npm ci in CI to enforce the lockfile rather than npm install.

  • Run npm ls <pkg> after dependency changes to confirm a shared package resolves to one copy.

  • Keep the root package private: true so the monorepo umbrella can never be published.

  • Add root delegating scripts with --if-present so a heterogeneous package set runs cleanly.

  • Use npm ci --ignore-scripts in CI to enforce the lockfile and block install-time code.

  • Run npm ls <pkg> after dependency changes to confirm a shared package resolves to one copy.

  • Add a task runner or migrate to pnpm only when build time or phantom-dependency pain appears.

When a small team outgrows npm workspaces

npm workspaces are the right starting point for a small team precisely because they need no extra tooling — the workspaces field and root scripts are enough to share code across a handful of packages. The signs that a team is outgrowing them are specific: builds that rebuild everything on every change because there is no caching or affected detection, phantom-dependency bugs that appear when the package set is rearranged, and CI times that grow with the repo rather than the change. None of these is a reason to abandon npm workspaces early, but each points at a capability a dedicated tool provides.

Outgrowing npm workspaces Slow builds and phantom bugs signal when to add tooling. npm workspaces simple start slow / phantom bugs the signal add turbo / pnpm incremental upgrade
Start simple; add a task runner or pnpm when the specific pain appears.

The migration path is incremental rather than a rewrite. Adding a task runner like Turborepo on top of existing npm workspaces brings caching and affected builds without changing how packages are declared, and moving to pnpm brings a strict, symlinked layout that eliminates phantom dependencies — surfacing, in the process, any latent undeclared-dependency bugs that npm's hoisting was hiding. A small team is well served by starting simple and adopting these only when the specific pain appears, rather than paying the configuration cost of a platform before the repo is large enough to need it.

A minimal CI setup for a small workspace

A small team's workspace CI does not need a task runner to be correct and reasonably fast; it needs a frozen install, a fan-out across packages, and enough git history for any change-awareness you add later. The essentials fit in a few lines: check out the code, install with npm ci so the lockfile is enforced and no lifecycle scripts run, then run build, test, and lint across the workspaces with --if-present so packages that lack a script are skipped rather than failing the job.

Minimal workspace CI Checkout with history, frozen install, fan-out with if-present. checkout depth 0 room to grow npm ci enforce lockfile --workspaces --if-present run across packages
A frozen install plus a guarded fan-out gives correct whole-workspace CI with no extra tooling.
- uses: actions/checkout@v4
  with: { fetch-depth: 0 }
- run: npm ci --ignore-scripts
- run: npm run build --workspaces --if-present
- run: npm run test --workspaces --if-present

The fetch-depth: 0 costs little and leaves the door open to add affected-only execution later without reconfiguring the checkout. This setup gives a small team reproducible, whole-workspace CI with no extra tooling, and it is the right baseline to grow from — adding a task runner's caching and affected detection only when the build time or the phantom-dependency pain actually appears, rather than paying for a platform before the repo needs one.

Publishing packages from a small npm workspace

A small team's workspace often exists to develop several packages together, and publishing them cleanly is part of the setup. npm workspaces let you publish an individual package with npm publish --workspace <name>, which packs and publishes just that package using its own manifest, so a monorepo can ship its packages to the registry without a separate release tool for simple cases. Keeping each publishable package's files allowlist tight and its private flag off (while the root stays private: true) ensures only the intended packages and their built output reach the registry.

Workspace publishing Publish per package; adopt Changesets as the count grows. npm publish --workspace single package files + private control what ships Changesets later coordinate versions
Start with per-package publish and add a release tool when version coordination gets hard.

As the number of published packages grows, coordinating their versions becomes the harder problem, and this is where a small team eventually adopts a release tool like Changesets even if it stays on npm workspaces. Changesets works on top of any workspace layout, computing each package's next version from intent files and publishing the changed set, which removes the manual version-bumping that gets error-prone past a couple of packages. Starting with npm publish --workspace for occasional releases and adopting Changesets when the release cadence or package count grows is the same start-simple-and-add-tooling-on-demand pattern that suits a small team's workspace overall.

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.

Do I need pnpm or Turborepo for a small monorepo?

Not at first. npm workspaces plus root scripts handle a handful of packages with no extra tooling. Add a task runner when builds get slow from rebuilding everything, and consider pnpm when phantom-dependency bugs appear — adopt them for the specific pain, not preemptively.

Why isn't my root npm run build finding the package builds?

npm run looks only at the current package's scripts, and the root manifest usually has no build. Add a delegating root script (npm run build --workspaces --if-present) or target packages with --workspace.

How do I confirm my npm workspace links are working?

Run npm ls @scope/internal-pkg and check it resolves to a local path rather than a registry version, and npm ls <shared-dep> to confirm a shared dependency shows a single copy. Both confirm the workspace is linking and deduplicating as expected.

How do I publish a single package from an npm workspace?

Use npm publish --workspace <name>, which packs and publishes just that package from its own manifest. Keep the package's files allowlist tight and its private flag off, while the root stays private: true so the umbrella is never published.

Can I run a script in just one workspace package?

Yes — npm run <script> --workspace <name> runs it in that package only, with the package's directory as the working directory. Use --workspaces (plural) with --if-present to fan it across every package that defines it.

Related

Workspace Configuration Deep Dive