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.
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:
{
"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
# 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.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.
-
Set the root package
private: trueso the monorepo umbrella cannot be published. -
Add root delegating scripts with
--if-presentso a heterogeneous package set runs cleanly. -
Use
npm ciin CI to enforce the lockfile rather thannpm install. -
Run
npm ls <pkg>after dependency changes to confirm a shared package resolves to one copy. -
Keep the root package
private: trueso the monorepo umbrella can never be published. -
Add root delegating scripts with
--if-presentso a heterogeneous package set runs cleanly. -
Use
npm ci --ignore-scriptsin 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.
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.
- 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.
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 — 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.