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 |
What Changes When One Package Becomes Several
A workspace is not a bigger version of a single package; it introduces three new failure modes that do not exist when there is only one manifest. Each has a specific symptom and a specific place to fix it.
The phantom-dependency case is the one most likely to reach the registry, because everything works locally: the workspace symlinks the internal package into node_modules, so imports resolve and tests pass. Only a consumer installing from the registry discovers that @acme/internal-utils does not exist there. Two defences catch it before publish — marking genuinely internal packages "private": true so they cannot be published by accident, and verifying the packed manifest of every publishable package against what is actually on the registry:
# every dependency of a publishable package must exist publicly
node -e '
const pkg = require("./package.json");
const deps = Object.keys(pkg.dependencies ?? {});
const { execSync } = require("child_process");
for (const d of deps) {
try { execSync(`npm view ${d} version`, { stdio: "pipe" }); }
catch { console.error(`FAIL: ${d} is not published — ${pkg.name} cannot be installed`); process.exit(1); }
}
console.log(`OK: all ${deps.length} dependencies are published`);
'
Verifying the Packed Manifest, Not the Source One
The package.json in your repository and the one inside the tarball are different files in a workspace. workspace:^ becomes a real range, publishConfig fields are merged in, and some tools rewrite main/exports paths for a dist-relative layout. Every workspace publishing bug ultimately comes down to a difference between those two files, so the highest-value check is simply to look at the packed one:
npm pack --silent >/dev/null && tar -xzOf *.tgz package/package.json | head -30
{
"name": "@acme/utils",
"version": "1.3.0",
"dependencies": {
"@acme/core": "^2.1.0"
},
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" }
}
}
Seeing "@acme/core": "^2.1.0" rather than "workspace:^" is the single confirmation that the rewrite happened. Automating that assertion is three lines and removes the most common workspace publishing incident entirely:
tar -xzOf *.tgz package/package.json | grep -q '"workspace:' \
&& { echo "FAIL: unrewritten workspace protocol in the tarball"; exit 1; } \
|| echo "OK: all internal ranges rewritten"
There is a second, subtler difference worth checking in a workspace: the repository.directory field. Without it, every package in the workspace points readers at the repository root, and provenance consumers comparing the attested source path against the manifest find a mismatch between what the attestation records (the workspace path) and what the manifest claims (the root).
Releasing a Graph Instead of a Package
The order in which packages publish matters because npm resolves dependencies at install time, not at publish time. Publishing @acme/[email protected], which depends on @acme/core@^2.0.0, before @acme/[email protected] exists creates a window — often only seconds, but real — in which anyone installing @acme/app gets a resolution failure.
A topological publish is therefore not a nicety. The package manager can compute the order for you, and the practical form is a filtered, dependency-ordered command rather than a hand-maintained list:
# pnpm publishes in topological order across the workspace
pnpm -r publish --access public --no-git-checks
Scope: 3 of 3 workspace projects
packages/core | Publishing @acme/[email protected]
packages/utils | Publishing @acme/[email protected]
packages/app | Publishing @acme/[email protected]
Two refinements make this robust in CI. First, make the publish idempotent per package — check the registry for each version before attempting it — so a partial failure can be retried without the already-published packages failing the whole run. Second, publish everything under a temporary dist-tag and promote the tags only after the entire graph succeeded; that way a failure halfway through never leaves consumers resolving a half-released set through latest. The tag mechanics are the same as for a single package, and are covered in Managing Prerelease and Dist-Tags on npm.
Sharing Configuration Without Coupling Packages
Every package in a workspace needs a tsconfig.json, a build script, and a set of lint rules, and copying them produces drift within weeks. The pattern that scales is a base configuration each package extends, with only genuinely per-package values left local.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"composite": true,
"skipLibCheck": true
}
}
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "./dist", "rootDir": "./src" },
"references": [{ "path": "../core" }],
"include": ["src/**/*.ts"]
}
Only three things stay in the package-level file: where output goes, where source lives, and which siblings it references. Everything else is inherited, so raising the compilation target or turning on a new strictness flag is a one-file change reviewed once.
The same logic applies to build scripts, and here the leverage is larger. A shared build script published as an internal (private) package — or simply a script at the workspace root invoked with the package directory as an argument — means a change to the build pipeline lands everywhere at once. The alternative, twenty near-identical tsup.config.ts files, guarantees that at least one package quietly stops emitting declarations and nobody notices until a consumer reports missing types.
Two caveats keep shared configuration from becoming its own problem. First, extends resolves paths relative to the extending file for include/exclude but relative to the base file for some other options, which is why base configs should avoid path-bearing options entirely — no outDir, no rootDir, no paths. Second, a package that genuinely needs to differ should override explicitly rather than have the base grow a conditional; a base config with special cases for two packages is harder to reason about than two packages with three extra lines each.
Where the shared configuration touches published output — target, module, declaration settings — treat a change to it as a change to every package’s public artefacts. Raising target from ES2020 to ES2022 across a workspace changes the syntax every consumer receives, and although it is usually safe, it is a change worth its own release note rather than one buried in a chore commit. The same goes for a change to the shared build script: it alters the artefacts of every package in the workspace simultaneously, which is exactly the kind of blast radius that deserves a deliberate release rather than a routine merge. Running the full validation chain across every package after such a change, rather than only the package that was edited, is the cheapest way to find out which of the twenty packages had been quietly depending on the old behaviour. The compiler-option trade-offs themselves are covered in Optimizing tsconfig.json for Library Distribution.
Source Manifest Versus Packed Manifest
The two files differ in exactly three ways, and knowing which three makes the verification step quick to read.
Extracting the packed manifest takes one command, and it answers all three questions at once — which is why it belongs in the release script rather than in a maintainer’s memory.
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.