
Build a TypeScript Monorepo with pnpm Workspaces
A monorepo can make a TypeScript codebase easier to maintain when several applications share types, validation rules, utilities, or configuration. It can also become painfully slow and tightly coupled if every package reaches into every other package.
This tutorial builds a small but production-friendly monorepo with pnpm workspaces, TypeScript project references, explicit package exports, filtered commands, and CI-friendly scripts. The goal is not to add a complicated build platform. It is to create clear package boundaries with tools that TypeScript and pnpm already provide.
What we are building
The example repository contains one API and two reusable packages:
typescript-monorepo/
├── apps/
│ └── api/
│ ├── src/index.ts
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ ├── contracts/
│ │ ├── src/index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── utils/
│ ├── src/index.ts
│ ├── package.json
│ └── tsconfig.json
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.base.json
└── tsconfig.json
The contracts package owns shared public types. The utils package contains runtime helpers. The API consumes both through normal package imports rather than fragile relative paths.
1. Create the workspace
Start with an empty directory and initialize the root package:
mkdir typescript-monorepo
cd typescript-monorepo
pnpm init
pnpm add -D typescript @types/node
Create pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
pnpm uses this file to discover workspace projects. Keep the patterns narrow enough that build output, fixtures, and unrelated folders are not accidentally treated as packages.
2. Configure the root package
The root should coordinate the repository, not become another application. Mark it private so it cannot be published accidentally:
{
"name": "typescript-monorepo",
"private": true,
"packageManager": "pnpm@10",
"scripts": {
"build": "tsc -b",
"build:packages": "pnpm --filter './packages/*' build",
"dev": "pnpm --parallel --filter './apps/*' dev",
"typecheck": "tsc -b --pretty false",
"test": "pnpm -r --if-present test",
"clean": "tsc -b --clean && pnpm -r --if-present clean"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
}
}
Pin the exact pnpm and dependency versions in your real project through packageManager and pnpm-lock.yaml. The versions above are examples, not a reason to overwrite versions selected by an existing repository.
3. Share strict TypeScript defaults
Create tsconfig.base.json for compiler options shared by every project:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true
}
}
Packages can extend this file and override only their input and output directories. Keeping module and module-resolution modes aligned avoids a large class of ESM resolution surprises.
4. Build the contracts package
Create packages/contracts/package.json:
{
"name": "@example/contracts",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsc -b",
"clean": "rm -rf dist tsconfig.tsbuildinfo"
}
}
The exports map defines the package's public entry point. Consumers cannot rely on arbitrary internal files, which gives you freedom to reorganize src without silently breaking them.
Create packages/contracts/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
Referenced projects must enable composite. TypeScript then emits declarations and build metadata that allow tsc --build to determine what is already up to date.
Add a simple public type in packages/contracts/src/index.ts:
export interface UserSummary {
id: string;
displayName: string;
createdAt: string;
}
5. Add a runtime utility package
Give packages/utils the same basic package.json and TypeScript configuration, changing its name to @example/utils. Its public source might contain:
export function assertNever(value: never): never {
throw new Error(`Unexpected value: ${String(value)}`);
}
export function toIsoDate(value: Date): string {
return value.toISOString();
}
A shared package should have one clear purpose. Avoid a giant common package that becomes a dumping ground for unrelated code.
6. Connect the API with workspace dependencies
Create apps/api/package.json:
{
"name": "@example/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -b",
"start": "node dist/index.js",
"dev": "tsc -b --watch"
},
"dependencies": {
"@example/contracts": "workspace:*",
"@example/utils": "workspace:*"
}
}
The workspace: protocol tells pnpm that these dependencies must resolve to local workspace packages. Installation fails instead of quietly downloading a registry package when the expected local package is missing.
Create apps/api/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"references": [
{ "path": "../../packages/contracts" },
{ "path": "../../packages/utils" }
],
"include": ["src/**/*.ts"]
}
Now the API can import packages by name:
import type { UserSummary } from "@example/contracts";
import { toIsoDate } from "@example/utils";
const user: UserSummary = {
id: "usr_123",
displayName: "Ada",
createdAt: toIsoDate(new Date())
};
console.log(user);
7. Define the build graph at the root
Create a solution-style root tsconfig.json:
{
"files": [],
"references": [
{ "path": "./packages/contracts" },
{ "path": "./packages/utils" },
{ "path": "./apps/api" }
]
}
Run the complete build:
pnpm install
pnpm build
tsc -b reads the reference graph and builds dependencies before their consumers. On later runs, TypeScript can skip projects whose inputs and dependencies have not changed.
8. Use filters for focused development
pnpm filters make it possible to target one package, its dependencies, or its dependents:
# Build only the API
pnpm --filter @example/api build
Build the API and all workspace dependencies
pnpm --filter @example/api... build
Run tests only in changed package selections defined by your CI logic
pnpm --filter @example/contracts test
Run a script in every package that defines it
pnpm -r --if-present test
Filtering reduces local feedback time, but the full repository build should still run in CI so a missing reference or broken public contract cannot hide indefinitely.
9. Add a reproducible CI workflow
A minimal CI sequence is intentionally boring:
corepack enable
pnpm install --frozen-lockfile
pnpm typecheck
pnpm test
pnpm build
Cache pnpm's package store through your CI platform, but do not cache node_modules blindly across incompatible operating systems or Node.js versions. Treat pnpm-lock.yaml as required reviewable source, and use --frozen-lockfile so CI fails when package manifests and the lockfile disagree.
Common monorepo problems
- A package imports another package's
srcdirectory: import its public package name instead and expose intentional entry points throughexports. - TypeScript cannot build a reference: confirm the referenced project enables
composite, includes all source files, and emits declarations. - The editor works but Node fails: path aliases can teach TypeScript names that Node cannot resolve. Prefer real workspace packages for code that runs at runtime.
- Old declarations remain after a refactor: run
pnpm clean, remove stale output, and rebuild the graph. - A registry package is installed unexpectedly: use
workspace:*for local dependencies instead of a normal version range. - Builds become serial and slow: inspect unnecessary project references and keep package boundaries coarse enough to be meaningful.
- Circular dependencies appear: move the shared contract to a lower-level package or redesign the boundary; do not hide the cycle with relative imports.
Production checklist
- The root package is private and owns shared scripts.
pnpm-workspace.yamlincludes only intended packages.- Local package dependencies use the
workspace:protocol. - Every referenced TypeScript project enables
composite. - Runtime packages expose only supported entry points.
- Applications import package names, not another package's source folder.
- The lockfile is committed and CI uses
--frozen-lockfile. - The complete typecheck, test, and build graph runs before deployment.
- Generated
distandtsbuildinfofiles are handled consistently. - Package ownership and boundaries are documented for contributors.
A useful monorepo is not one giant application in a single repository. It is a collection of packages with explicit public contracts, reproducible dependency resolution, and a build graph that tools can understand.