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.

{
  "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 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.

{
  "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.

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.

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.

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.

{
  "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.

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.

# .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"]
  }
}

Migrating and standardizing across tools

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

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.

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.

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.

# 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.

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

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.

Related

Core JavaScript Package Workflows