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

Workspace Configuration Deep Dive

The moment a repository holds more than one package, the questions multiply: how do internal packages depend on each other, which dependencies hoist to the root, and how do you stop a published package from accidentally shipping a file:../ path that only worked on your laptop? A workspace is the package manager's answer — a single root that discovers many local packages, symlinks them together, and resolves their shared dependencies into one coherent tree.

This page lives under Core JavaScript Package Workflows and goes deep on how npm, pnpm, and Yarn each model a workspace: root initialization, the workspace protocol for internal links, security overrides, and the CI wiring that keeps the graph honest. The dependency classifications it relies on are defined in Understanding package.json Fields, and the single root lockfile that ties the graph together is covered in Lockfile Management Strategies.

Workspace topology and the workspace protocol A private root discovers packages and apps via globs; internal dependencies declared with the workspace protocol resolve to local symlinks instead of registry downloads. workspace root private: true · packageManager globs: packages/* apps/* @org/design-tokens packages/ @org/ui-components packages/ @org/web-app apps/ discover "@org/design-tokens": "workspace:^" resolves to local symlink, never the registry
A private root discovers packages by glob; internal deps declared with the workspace protocol link locally instead of downloading from the registry.

Root initialization and package-manager constraints

A workspace begins with a private root that pins the toolchain and scopes package discovery. Pinning packageManager makes Corepack resolve the exact CLI, so every contributor produces lockfiles in the same format.

Workspace root The root controls the resolver, the package glob and hoisting policy. packageManager pin the resolver workspaces / yaml package globs .npmrc hoist policy phantom-dep guard shared config tsconfig, eslint base
The root manifest sets the rules every package inherits.
{
  "name": "@org/monorepo-root",
  "private": true,
  "packageManager": "pnpm@10.4.1",
  "engines": {
    "node": ">=20.0.0",
    "pnpm": ">=10.0.0"
  },
  "workspaces": [
    "packages/*",
    "apps/*",
    "!packages/**/test-fixtures",
    "!**/node_modules"
  ]
}
  • private: true blocks npm publish / pnpm publish at the root, so the monorepo scaffold can never be pushed to a registry by accident. This is not optional.
  • packageManager enforces Corepack resolution; a contributor on the wrong CLI version fails fast instead of silently churning the lockfile.
  • The workspaces globs use negative patterns (!) to exclude fixtures and build artifacts from package discovery.

For pnpm, discovery lives in a dedicated file instead of the workspaces array:

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
corepack enable
corepack prepare pnpm@10.4.1 --activate

The root manifest is where a workspace establishes the rules every package inherits, and pinning the package manager is the first of them. Declaring packageManager and enabling Corepack guarantees every checkout activates the identical resolver, so the installed tree is a function of the lockfile alone rather than of whatever version happens to be installed — the difference between a reproducible workspace and one where two engineers produce different node_modules on the same commit. The package globs come next, defining which directories are packages, and the hoisting policy after that, deciding how strict the dependency boundaries are.

Strictness at the root is what prevents phantom dependencies from accumulating. Disabling broad hoisting forces every package to declare what it imports, so a package cannot accidentally work because a sibling hoisted a dependency it never listed. Phantom dependencies are insidious because they pass local testing and break the moment the graph is rearranged — a new package changes what gets hoisted, and suddenly an import that always worked resolves to nothing. Structural prevention at the root beats hunting for phantoms after they cause a failure.

A strict workspace root declares its packages, pins its resolver, and disables broad hoisting so every package must declare what it imports:

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
# .npmrc
hoist-pattern[]=
public-hoist-pattern[]=
only-built-dependencies[]=esbuild
only-built-dependencies[]=sharp
// package.json
{ "packageManager": "pnpm@10.4.1", "engines": { "node": ">=20" } }

The empty hoist patterns force a strict, symlinked layout that surfaces phantom dependencies immediately; the only-built-dependencies allow-list re-enables lifecycle scripts for exactly the vetted native builds that need them while everything else runs script-free; and packageManager plus Corepack guarantees every checkout uses the identical resolver. Together these make the installed tree a function of the lockfile alone and the dependency boundaries something the tooling enforces rather than convention.

The workspace protocol and dependency scoping

The defining feature of a workspace is the workspace: protocol. Instead of a fragile file:../packages/lib path or a bare semver range that points at the registry, an internal dependency declared with workspace: resolves to a local symlink during development and is rewritten to a concrete semver range at publish time.

workspace: protocol Internal ranges resolve to symlinks and rewrite on publish. workspace:* declare internal dep symlink in dev live local source publish rewrite pinned version
The workspace protocol links locally and rewrites to real versions at publish.
{
  "name": "@org/ui-components",
  "version": "1.0.0",
  "dependencies": {
    "@org/design-tokens": "workspace:^",
    "@org/utils": "workspace:*"
  },
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  },
  "devDependencies": {
    "typescript": "^5.7.0",
    "vite": "^6.0.0"
  }
}
Protocol Resolution during development Published as
workspace:^ local version ^ range — standard internal dependency
workspace:* exact local version * — tightly coupled packages that must move in lockstep
workspace:~ local version ~ range — patch-only compatibility

Two scoping rules keep isolated builds honest. First, declare devDependencies per workspace rather than relying on root hoisting — hoisting masks a missing dependency until the package is built in isolation, where it suddenly fails. Second, give peerDependencies bounded ranges; unbounded peers push resolution conflicts downstream into consumer applications. Getting these classifications right is the subject of Understanding package.json Fields.

The workspace: protocol is what lets internal packages depend on each other without the stale-copy problems that plague version-based internal references. During development it resolves to a symlink into the local package, so a change to a shared library is live in every consumer immediately; at publish time the package manager rewrites the specifier to the real published version, so external consumers receive a normal semver range. The choice between workspace:* and workspace:^ encodes how the packages couple after publish — a pinned reference within a repo that always builds together, or a caret range that lets external consumers deduplicate.

Dependency scoping in a workspace also determines which packages a given package can reach. A strict, symlinked layout gives each package a node_modules containing only its declared dependencies, so an import of an undeclared package fails immediately rather than resolving by accident through hoisting. This scoping is the mechanism behind phantom-dependency prevention: the graph the tooling sees matches the graph the code actually uses, which is the precondition for accurate affected detection, correct build ordering, and safe refactoring.

Architecture: how each tool models the graph

The three package managers differ most in how they lay out node_modules, which directly determines whether phantom dependencies are possible.

Workspace models How npm, pnpm and Yarn model the workspace graph. Tool node_modules Strictness npm hoisted flat loose, phantom-prone pnpm symlinked store strict, isolated Yarn PnP no node_modules .pnp resolver
Each tool trades hoisting looseness for install strictness differently.
Concern npm pnpm Yarn Berry
Discovery manifest workspaces in package.json pnpm-workspace.yaml workspaces in package.json
Default linker hoisted, flat node_modules isolated, symlinked store node-modules or PnP
Phantom deps possible yes (hoisting leaks) no (strict isolation) configurable
Overrides field overrides pnpm.overrides resolutions

pnpm's content-addressable store is the strictest model: each package only sees the dependencies it actually declares, because the symlinked layout refuses to expose hoisted siblings. That strictness is what catches missing declarations at install time rather than in production, and it is why the single root lockfile described in Lockfile Management Strategies can faithfully represent the whole graph.

The on-disk model a package manager chooses determines which class of bug is even possible. npm's flat, hoisted node_modules permits phantom dependencies — a package importing something it never declared because a sibling hoisted it — which pass local testing and break when the graph is rearranged. pnpm's strict, symlinked layout makes that import fail immediately, because a package's node_modules contains only its declared dependencies. Yarn's Plug'n'Play eliminates node_modules entirely in favor of a resolver that enforces declarations strictly. Choosing the model is therefore choosing which mistakes the tooling catches for you versus which it lets pass silently.

Security overrides and lockfile integrity

Force deterministic vulnerability patching with root-level overrides, which apply recursively across every workspace, then verify with a frozen install.

Security overrides and lockfile integrity Force deterministic vulnerability patching with root-level overrides, which apply recursively across every workspace, th Security overrides and lockfile integrity Force deterministic vulnerability patching with root-level overrides, which apply recursively across every workspace, then verify with a frozen install.
Security overrides and lockfile integrity — the core idea of this section at a glance.
{
  "pnpm": {
    "overrides": {
      "semver@<7.5.2": ">=7.5.2",
      "follow-redirects@<1.15.4": ">=1.15.4"
    }
  }
}
# Strict install — fails if the lockfile is out of sync
pnpm install --frozen-lockfile --prefer-offline

# Production-only vulnerability scan, gate CI on high/critical
pnpm audit --prod --audit-level=high

# Regenerate the lockfile after editing overrides, then commit it
pnpm install --lockfile-only

Always run an install after editing overrides so the override is baked into pnpm-lock.yaml, and wire audit into a pre-publish hook so a high or critical finding fails the pipeline. The full hardening discipline — provenance, audit thresholds, lockfile validation — lives in Supply-Chain Security Hardening.

A workspace has a single root lockfile pinning the entire graph, which makes it both the strongest reproducibility guarantee and the largest unreviewable file. Enforce it with a frozen install in CI so a manifest change unaccompanied by a lockfile regeneration fails loudly, and route dependency bumps through dedicated pull requests where reviewers read the manifest diff while CI verifies the regenerated lockfile resolves cleanly. Overrides live at the root too, applying across every package, which is what makes a single root pin the right place to remediate a transitive vulnerability that appears under many packages at once.

CI/CD integration

A workspace CI job should install once at the root with a frozen lockfile, then fan out work to only the packages that changed.

CI/CD integration A workspace CI job should install once at the root with a frozen lockfile, then fan out work to only the packages that c CI/CD integration A workspace CI job should install once at the root with a frozen lockfile, then fan out work to only the packages that changed.
CI/CD integration — the core idea of this section at a glance.
# .github/workflows/workspace-ci.yml
name: Workspace CI
on: [pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: corepack enable
      # 1. One frozen install at the root resolves the entire graph.
      - name: Install
        run: pnpm install --frozen-lockfile --prefer-offline
      # 2. Scope work to a package subset; never run -r unscoped in CI.
      - name: Test changed packages
        run: pnpm --filter "@org/*" test
      # 3. Fail fast on a broken graph (missing symlinks, invalid peers).
      - name: Validate graph
        run: pnpm list --recursive --depth=0 --long | grep -E "MISSING|INVALID" && exit 1 || true

Scope husky / lint-staged hooks to modified files so full-workspace linting does not run on every commit:

{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
    "package.json": ["pnpm install --frozen-lockfile"]
  }
}

A workspace's CI configuration is where its reproducibility guarantees are actually enforced, so the essentials are worth making explicit. The install step uses a frozen lockfile against the committed root lockfile so a manifest change without a lockfile regeneration fails loudly; it runs with ignored scripts so a compromised dependency cannot execute during install; and it fetches enough git history that affected detection can compute a diff against the base. These three — frozen, script-free, deep-enough history — are the preconditions every other optimization builds on, and omitting any one reintroduces a class of non-reproducible or unsafe behavior.

On top of that foundation, CI targets the workspace with change-aware execution. A task runner or a filter selects the packages a change affects — the changed packages plus their dependents — and runs build, test, and lint only across that set, replaying anything already cached. The result is a pipeline whose cost tracks the change rather than the repo size, which is what keeps CI fast as the workspace grows to dozens of packages. The configuration that makes this work is the same accurate dependency graph that makes resolution and ordered builds correct.

Migrating and standardizing across tools

Teams move between managers as they scale. The directives map across tools cleanly once you know the equivalents.

Migrating and standardizing across tools Teams move between managers as they scale. Migrating and standardizing across tools Teams move between managers as they scale.
Migrating and standardizing across tools — the core idea of this section at a glance.
npm (package.json) pnpm Yarn Berry
"workspaces": ["packages/*"] pnpm-workspace.yaml packages: "workspaces": ["packages/*"]
"overrides": {} "pnpm": { "overrides": {} } "resolutions": {}
hoisted linker (default) node-linker=isolated (default) nodeLinker: node-modules

Three child guides cover the concrete paths. A team standing up its first monorepo should follow Setting Up npm Workspaces for Small Teams; a repository moving off Yarn Classic should work through Migrating from Yarn 1 to pnpm Workspaces; and any workspace that wants one source of lint rules across packages should apply Setting Up Shared ESLint Configs in Workspaces.

Verifying the graph after every change

A workspace fails quietly: a dependency that hoisted yesterday disappears when a sibling drops it, and nothing complains until a build runs in isolation. Build a short verification habit into the workflow so the graph is checked rather than assumed. After any dependency edit, run a recursive list at top level and scan for the words MISSING or INVALID, which surface broken symlinks and unsatisfied peers before they reach a runner. Trace a specific package with pnpm why <pkg> whenever you are unsure why a version resolved the way it did — it prints the full chain from the workspace root down to the resolution, which is the fastest way to find an accidental duplicate or a sibling pulling a registry copy instead of the local link.

Verifying the graph after every change A workspace fails quietly: a dependency that hoisted yesterday disappears when a sibling drops it, and nothing complains Verifying the graph after every change A workspace fails quietly: a dependency that hoisted yesterday disappears when a sibling drops it, and nothing complains until a build runs in isolation.
Verifying the graph after every change — the core idea of this section at a glance.

The deeper guarantee is that the workspace tree on disk matches the committed lockfile. A frozen install (pnpm install --frozen-lockfile) is the assertion: if the on-disk graph the resolver would produce differs from the lockfile in any way, it exits non-zero. Run it locally before pushing a dependency change and as the very first CI step, and the class of "works on my machine" workspace bugs largely vanishes — the determinism that makes this work is the whole point of Lockfile Management Strategies.

Because a workspace's correctness depends on an accurate dependency graph, verifying that graph after structural changes is a first-class task. After adding a package, moving code between packages, or changing a dependency, confirm the graph is what you expect: run pnpm why or npm ls on shared dependencies to check they resolve to a single copy, and — where you enforce boundaries — run the boundary lint to confirm no illegal edge slipped in. A change that looks right in the manifests is only proven correct by the resolver and the linter agreeing, not by a clean install alone.

Publishing from a workspace

Workspaces shine in development but the publish boundary is where the workspace protocol earns its keep. When a package declared with workspace:^ is published, the package manager rewrites that reference to a concrete semver range derived from the dependency's current version — so consumers outside the monorepo receive a normal ^1.4.0 range and never see the workspace: token. Verify this before your first release: pack a tarball and inspect the resulting package.json to confirm no workspace: strings leaked into what consumers download.

Publishing from a workspace Workspaces shine in development but the publish boundary is where the workspace protocol earns its keep. Publishing from a workspace Workspaces shine in development but the publish boundary is where the workspace protocol earns its keep.
Publishing from a workspace — the core idea of this section at a glance.
# Inspect exactly what would be published, without publishing
pnpm --filter @org/ui-components pack
tar -xzO -f org-ui-components-*.tgz package/package.json | grep -A6 '"dependencies"'
# Every internal dep should show a concrete range, never "workspace:*"

Keep "private": true on the root and on any package that should never reach a registry, and publish each public package explicitly rather than from the root. This pairs naturally with the script-placement decisions in Root-Level vs Package-Level Scripts, where build-then-publish orchestration lives.

Publishing from a workspace adds a wrinkle that a single-package repo does not have: internal dependencies must be rewritten from the workspace: protocol to real version ranges as part of the publish. The package managers handle this automatically at pack time, replacing workspace:^ with a caret range against the dependency's published version, so external consumers receive a normal package with normal dependencies. Verifying the packed output — that the internal references were rewritten and no workspace: specifier leaked into the published manifest — is worth doing, because a leaked protocol specifier makes a package uninstallable for external consumers.

Scoped, independent versioning is the common publishing model for a workspace, where each package moves on its own semver cadence based on the changes that touched it. A release tool computes each package's next version, walks the graph to bump packages that must move because a dependency did, and publishes the affected set — so a consumer of one package is not forced to upgrade because an unrelated sibling shipped a major. This per-package precision is effectively impossible by hand across many packages, which is why workspace publishing is usually automated end to end.

Common Mistakes

Mistake Impact Remediation
file:../packages/lib paths Breaks in CI, bypasses lockfile resolution Replace with workspace:^ or workspace:*
Omitting packageManager Contributors on mismatched CLIs churn the lockfile Pin via packageManager + Corepack
Hoisting all devDependencies to root Masks missing deps, breaks isolated builds Declare per package; use node-linker=isolated
Missing private: true at root Accidental publication of the monorepo scaffold Add "private": true immediately
Running pnpm -r run unscoped in CI Wasted minutes building untouched packages Scope with --filter to changed packages
Ignoring overrides for transitive CVEs Workspace stays exposed to known vulnerabilities Patch at root, audit recursively, commit the lockfile
Common Mistakes Common Mistakes in production JavaScript package workflows. Common Mistakes Common Mistakes in production JavaScript package workflows.
Common Mistakes — the core idea of this section at a glance.

The recurring workspace mistakes are sins of omission that pass local testing. Hardcoding a version instead of the workspace: protocol reintroduces stale internal copies; leaving broad hoisting on lets phantom dependencies accumulate until the graph is rearranged; mixing package managers in one repo produces conflicting lockfile state; and letting a workspace: specifier leak into a published manifest makes the package uninstallable externally. Each is invisible in the symlinked development path and total for the affected consumer, which is why a workspace benefits from CI that builds and consumes internal packages through their published entry points, exercising the resolution an external install actually hits.

How each package manager models the workspace

The three package managers implement workspaces differently, and the differences change which mistakes are possible. npm uses the workspaces array and hoists to a flat node_modules, which is simple and familiar but permits phantom dependencies. pnpm uses pnpm-workspace.yaml and a strict, symlinked tree backed by a content-addressed store, so a package can only import what it declares and shared dependencies are stored once — the strictest and most disk-efficient model. Yarn Berry uses the same workspaces field but can resolve through Plug'n'Play, eliminating node_modules entirely in favor of a .pnp resolver.

Workspace models npm, pnpm, and Yarn PnP compared. Tool node_modules Guarantee npm hoisted flat familiar, phantom-prone pnpm symlinked store strict, dedup Yarn PnP none, .pnp deterministic, needs support
Each model trades familiarity against strictness and install efficiency.

Choosing among them is a trade between familiarity, strictness, and ecosystem compatibility. pnpm's strictness catches undeclared-dependency bugs early and is the common choice for new monorepos that value correctness and install speed; npm's flat model is the path of least resistance for teams already on npm; Yarn PnP offers the fastest, most deterministic installs at the cost of tooling that must understand its resolver. Whichever you pick, the manifest-level concepts — the workspace: protocol, per-scope dependencies, root overrides — carry across, so the decision is about the on-disk model and its guarantees, not about relearning how a workspace is declared.

Migrating and standardizing across tools

Migrating a workspace between package managers is mostly a matter of translating the workspace declaration and regenerating the lockfile, but the subtle work is in the hoisting differences. Moving from npm's flat model to pnpm's strict one commonly surfaces phantom dependencies that worked only because npm hoisted them — packages that import something they never declared. These are not migration bugs but pre-existing latent ones that the stricter model exposes, and fixing them (by declaring the missing dependencies) leaves the workspace more correct than before.

Migrating and standardizing across tools Migrating a workspace between package managers is mostly a matter of translating the workspace declaration and regenerat Migrating and standardizing across tools Migrating a workspace between package managers is mostly a matter of translating the workspace declaration and regenerating the lockfile, but the subtle work is
Migrating and standardizing across tools — the core idea of this section at a glance.

Standardizing on one tool across an organization matters because the lockfile format is manager-specific, so a repository must commit to a single manager, and mixing them within one repo produces conflicting state. Across separate repositories teams can differ, but pinning each repo's manager with packageManager and Corepack keeps every checkout deterministic. The migration payoff is usually worth the phantom-dependency cleanup: a stricter, faster, more reproducible workspace whose correctness the tooling now enforces rather than leaving to convention.

Frequently Asked Questions

Should I use the workspace:* or workspace:^ protocol for internal dependencies? Use workspace:^ for standard internal packages so consumers get semver-compatible patches after publish. Reserve workspace:* for tightly coupled packages that must always resolve to the exact local version and move in lockstep.

How do I prevent dependency hoisting from breaking isolated workspace builds? Use a strict linker — pnpm's default isolated, symlinked node_modules (node-linker=isolated) — and declare every runtime and build dependency explicitly in each package rather than leaning on root-level hoisting. A package that builds in isolation will build in CI.

What is the production-safe way to patch a vulnerable transitive dependency across all workspaces? Pin a secure version in the root overrides (npm) or pnpm.overrides, verify it is compatible with the parent package, run the full workspace test suite, then regenerate and commit the lockfile with --frozen-lockfile validation enforced in CI.

Can I mix package managers within a single monorepo? No. Mixing managers produces conflicting node_modules topologies and two lockfiles competing to describe the same graph. Enforce one manager via the packageManager field, a CI check, and .npmrc / .yarnrc.yml.

Is the workspaces field in package.json still needed for pnpm? pnpm itself only reads pnpm-workspace.yaml for discovery, so the field is not required. Keeping it does no harm and preserves compatibility with tooling that expects the npm/Yarn convention.

Why does moving to pnpm surface dependency errors that npm didn't?

pnpm's strict, symlinked tree only lets a package import what it declares, so it exposes phantom dependencies — packages that worked under npm only because a sibling hoisted something they never listed. These are latent bugs the stricter model reveals; declaring the missing dependencies fixes them.

Can I use different package managers in the same repo?

No — the lockfile format is manager-specific, so one repository must commit to a single manager. Across separate repos you can differ; pin each repo's manager with packageManager and Corepack so every checkout is deterministic.

Which package manager should a new monorepo use?

pnpm is the common default for its strict, symlinked layout that eliminates phantom dependencies and its disk-efficient content-addressed store. npm workspaces suit a small team that wants no extra tooling, and Yarn PnP suits teams wanting the fastest, most deterministic installs. Pin whichever you choose with packageManager.

How do I verify a workspace is configured correctly?

After structural changes, run a frozen install, run pnpm why/npm ls on shared dependencies to confirm single copies, and run the boundary lint if you enforce one. A workspace is correct when the resolver and the linter agree, not just when a clean install succeeds.

Related

Core JavaScript Package Workflows