Definition
The workspace protocol is a notation used in a monorepo (a layout where multiple packages live together in a single repository) to depend on an internal package by linking directly to its local source — as in "pkg": "workspace:*" — instead of using a version string. At publish time, a workspace-aware package manager rewrites this notation into a real version range before uploading to the registry.
In other words, it wears two faces: a local link during development, and a real version at the moment of publish.
Why it matters
Suppose the package @scope/ui depends on @scope/utils inside a single repository. If you pin @scope/utils to a version like ^1.0.0, then every time you change a single line in utils you have to bump the version and reinstall or relink for the change to show up locally. Development slows down dramatically.
If you write "@scope/utils": "workspace:*" instead, the local source is linked directly and changes are reflected immediately. But if you publish this workspace:* string to npm as-is, an external consumer's package manager cannot interpret workspace:* and their install breaks. That is why the string must be replaced with a real version at publish time, and that replacement only happens through commands that understand the workspace.
How it works
- During development:
installrecords theworkspace:*dependency in the lockfile aslink:../utils, and symlinks the local package intonode_modules. - At publish:
pnpm publish(or yarn) replaces theworkspace:*inpackage.jsonwith the target package's actual published version just before building the tarball. For example:workspace:*→^1.3.0. - Order: You must publish from the leaves of the dependency graph (the lower-level packages) first, so that the lower version referenced by a higher-level package already exists in the registry.
npm publish does not perform this replacement — workspace:* is uploaded raw, producing an uninstallable tarball.
// package.json of @scope/ui (inside the repository)
{
"dependencies": { "@scope/utils": "workspace:*" }
}
// The form uploaded to the registry after pnpm publish
{
"dependencies": { "@scope/utils": "^1.3.0" }
}
In practice
# Use the workspace protocol for internal dependencies
# "@scope/utils": "workspace:*"
# Publish with a workspace-aware manager — dependencies (leaves) first
pnpm --filter @scope/utils publish
pnpm --filter @scope/ui publish
# CI: pnpm publish cannot do npm OIDC (tokenless), so prepare a token
# env: NODE_AUTH_TOKEN=${{ secrets.NPM_TOKEN }}
Before publishing, open the tarball with pnpm pack and visually confirm that the dependency versions in package.json have been replaced with real values rather than workspace:* — this prevents accidents.
Trade-offs
- What you gain: local development speed (instant reflection), no manual version bookkeeping, atomic changes across a single repository.
- What you pay: the publish pipeline gets tied to a workspace-aware manager. The
npm publishhabit, the expectation of OIDC, and the wrong order all become places that break. - Combining it with a version automation tool (a release tool) is convenient, but that adds just as much CI wiring (runners, tokens, ordering).
When not to use
- If there is only one package, or the packages do not depend on each other, the workspace protocol offers no benefit.
- If you are constrained to publish exclusively with
npm publish, you must not useworkspace:*— it will not be rewritten.
Common mistakes
- Publishing with
npm publish:workspace:*is uploaded as-is and external installs break. Usepnpm publish. - Pinning an internal dependency to a nonexistent external version: if
^1.0.0is not in the registry, CIinstallgives a 404. Switch toworkspace:*and relock. - Reversed publish order: publishing the higher-level package first means it cannot find the latest lower-level version. Publish from the leaves first.
- Expecting OIDC:
pnpm publishdoes not support OIDC → without an automation token for publish permission, you get a 403.
Related concepts
- [[library-esm-packaging-dangling-exports]] — a problem that blows up alongside this in the same publish pipeline, caused by a missing export field