Monorepo & Workspace Publishing
Publish multiple packages from a pnpm or npm workspace with TypeScript project references, composite builds, and correct cross-package exports.
A monorepo that ships more than one npm package only stays correct if the build order matches the dependency graph and every internal reference resolves the same way for local development and for a consumer who installs from the registry. This guide covers wiring TypeScript project references across a pnpm or npm workspace, turning on composite builds so tsc --build compiles packages incrementally in the right order, and publishing each package with the workspace: protocol correctly rewritten. It assumes familiarity with the compiler options covered in Optimizing tsconfig.json for Library Distribution.
Prerequisites
Canonical Configuration Block
The root workspace manifest and a package-level tsconfig.json are the two files every package in the repository depends on. Get these right before adding project references.
# pnpm-workspace.yaml — at the repository root
packages:
- "packages/*"
{
"name": "@acme/utils",
"version": "1.3.0",
"dependencies": {
// The workspace: protocol pins to the local sibling package during
// development and is rewritten to a real semver range on publish.
"@acme/core": "workspace:^"
}
}
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src"
},
"references": [
{ "path": "../core" }
],
"include": ["src/**/*.ts"]
}
composite: true is what allows tsc --build to treat this package as a dependency another package can reference; without it, a references entry pointing at this folder throws Referenced project must have setting "composite": true.
Step-by-Step Implementation
Step 1 — Define the workspace packages
List every package directory in the workspace manifest, then give each one a scoped name and declare internal dependencies with the workspace: protocol so the package manager symlinks siblings instead of fetching them from the registry.
{
"name": "@acme/app",
"dependencies": {
"@acme/core": "workspace:^",
"@acme/utils": "workspace:^"
}
}
Run pnpm install at the root. Expected result: node_modules/@acme/core inside packages/app is a symlink back to packages/core, so edits to core are visible to app without a publish step.
HAZARD PREVENTION
Symptom:
pnpm installsucceeds, but@acme/utilsresolves an old published version of@acme/coreinstead of the local sibling.Root cause: The dependency was declared with a plain semver range (
"^1.0.0") instead ofworkspace:^, so pnpm treats it as an external registry dependency even though a local package of the same name exists.Fix: Change every internal dependency to
workspace:^(orworkspace:*for an exact pin) and reinstall.
Step 2 — Add TypeScript project references
Add a references array to each package’s tsconfig.json pointing at the relative path of every internal package it imports from, and mark every referenced package "composite": true.
{
"compilerOptions": { "composite": true, "declaration": true },
"references": [
{ "path": "../core" },
{ "path": "../utils" }
]
}
Expected result: opening packages/app/src/index.ts in an editor resolves types from @acme/core and @acme/utils directly from their .ts source during development, and from their emitted .d.ts files once built — no manual path aliasing required. Full alias-avoidance is covered in tsconfig Project References for Monorepos.
Step 3 — Enable composite builds with tsc --build
Add a root tsconfig.json that references every package, then run tsc --build once from the repository root instead of invoking tsc separately inside each package.
{
"files": [],
"references": [
{ "path": "packages/core" },
{ "path": "packages/utils" },
{ "path": "packages/app" }
]
}
tsc --build --verbose
Expected output on a clean checkout:
Project 'packages/core/tsconfig.json' is out of date because output file 'dist/index.js' does not exist
Building project '/repo/packages/core/tsconfig.json'...
Project 'packages/utils/tsconfig.json' is out of date because its dependency 'packages/core' is newer
Building project '/repo/packages/utils/tsconfig.json'...
Project 'packages/app/tsconfig.json' is out of date...
Building project '/repo/packages/app/tsconfig.json'...
Running tsc --build again with no source changes prints nothing and exits 0 — the incremental .tsbuildinfo cache short-circuits work that is already up to date. See Composite Builds for Multi-Package Repos for cache invalidation details.
HAZARD PREVENTION
Symptom:
tsc --buildreportsProject 'packages/app' is not listed within the file list of project 'packages/core'or silently rebuilds everything on every run.Root cause: A
.tsbuildinfofile was committed to source control, oroutDiroverlaps between packages, corrupting the incremental cache.Fix: Add
*.tsbuildinfoto.gitignoreand give every package its own non-overlappingoutDir.
Step 4 — Establish publish order and versioning
Publish leaf packages (no internal dependents) before packages that depend on them, so the registry never serves a package whose declared dependency version does not yet exist.
# Publish in dependency order — core has no internal deps, so it goes first
pnpm --filter "@acme/core" publish --access public
pnpm --filter "@acme/utils" publish --access public
pnpm --filter "@acme/app" publish --access public
pnpm publish automatically rewrites any workspace:^ range in the packed package.json to the sibling package’s current published version, so consumers never see the workspace: protocol string. Full mechanics of this rewrite, plus the npm-workspaces equivalent, are covered in Publishing Packages from a pnpm Workspace. For automating this ordering and the version bump itself, pair it with Versioning and Changelog Automation.
HAZARD PREVENTION
Symptom: A consumer installs
@acme/[email protected]and getsnpm error notarget No matching version found for @acme/core@workspace:^.Root cause: The package was packed or published with
npm pack/npm publishdirectly instead of a tool that rewrites theworkspace:protocol, so the literal stringworkspace:^was published as a dependency range.Fix: Always publish through pnpm (
pnpm publish), Yarn (yarn npm publish), or a release tool such as Changesets that performs the rewrite. Never run barenpm publishinside a package that still hasworkspace:ranges in its manifest.
Tooling Validation
Run these checks from the workspace root before publishing any package:
# Confirm the build graph compiles cleanly in dependency order
tsc --build --clean && tsc --build
# Confirm no package still has an un-rewritten workspace: range
grep -r "workspace:" packages/*/package.json && echo "FAIL: unresolved workspace ranges" || echo "PASS"
# Validate exports and types per package
pnpm --filter "./packages/*" exec npx publint
pnpm --filter "./packages/*" exec npx attw --pack .
Sample passing output:
Project 'packages/core/tsconfig.json' up to date
Project 'packages/utils/tsconfig.json' up to date
Project 'packages/app/tsconfig.json' up to date
PASS
Compatibility Matrix
| Feature | pnpm 9+ | npm 10+ workspaces | Yarn 4+ (Berry) | TypeScript 5.4 | TypeScript 5.6+ |
|---|---|---|---|---|---|
workspace: protocol rewrite on publish |
Yes | No (use "*" + manual bump) |
Yes | N/A | N/A |
| Symlinked local package resolution | Yes | Yes | Yes (or PnP virtual fs) | N/A | N/A |
tsc --build project references |
N/A | N/A | N/A | Full | Full |
Composite build .tsbuildinfo caching |
N/A | N/A | N/A | Full | Full, faster invalidation |
--filter / recursive per-package scripts |
Yes (--filter) |
Yes (-w / --workspace) |
Yes (workspaces foreach) |
N/A | N/A |
Guides in This Section
- tsconfig Project References for Monorepos — wiring the
referencesarray so packages build in dependency order and share types without circular errors. - Publishing Packages from a pnpm Workspace — resolving
workspace:ranges, orderingpnpm publishcalls, and shipping correct per-packageexports. - Composite Builds for Multi-Package Repos — how
tsc --buildcaches and invalidates incremental output across interdependent packages.
Related
- Optimizing tsconfig.json for Library Distribution — the compiler options each package-level
tsconfig.jsonin a workspace should start from. - Versioning and Changelog Automation — automating the version bump and changelog generation that must precede the publish order described above.
- Mastering the package.json Exports Field — every package in the workspace still needs a correct
exportsmap of its own once it leaves the monorepo. - Navigating the Dual-Package Hazard — a risk that compounds in monorepos where one internal package can be pulled in as both a workspace sibling and a transitive registry dependency.