TypeScript monorepos in 2026 are a solved problem — if you pick the right pieces. Tools matured; pain points eased. This post is the working stack.
The pieces
- Workspace manager: pnpm or Bun.
- Build orchestration: Turborepo (or Nx).
- TypeScript: project references for fast incremental builds.
- Shared config: tsconfig base, eslint/biome base, prettier (if used).
- Internal packages:
workspace:*protocol.
Layout
my-monorepo/
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.base.json
├── apps/
│ ├── web/ (Next.js / Vite)
│ └── api/ (Hono / Fastify)
├── packages/
│ ├── ui/ (shared React components)
│ ├── db/ (Drizzle schema + queries)
│ ├── config/ (shared eslint/tsconfig)
│ └── types/ (shared types)
└── tooling/
├── tsconfig/
└── eslint/
apps/ deploys; packages/ doesn’t (consumed by apps).
pnpm workspaces
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "tooling/*"
// apps/web/package.json
{
"dependencies": {
"@my/ui": "workspace:*",
"@my/db": "workspace:*"
}
}
workspace:* = use the local version. pnpm symlinks; no publishing needed.
Turborepo
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": { "outputs": [] },
"dev": { "cache": false, "persistent": true }
}
}
pnpm turbo build — runs build in every package. ^build = depends on builds of dependencies first. Caches outputs.
Remote cache
Turborepo’s killer feature: shared cache across team + CI.
turbo login
turbo link # link to Vercel-hosted cache
CI runs turbo build. CI caches output. Your local turbo build hits the cache → done in 2 seconds. Massive time-save.
For self-hosted cache: turbo supports a generic HTTP API; many implementations exist.
TS project references
// tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"incremental": true
}
}
// packages/ui/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist"
},
"references": []
}
// apps/web/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"references": [
{ "path": "../../packages/ui" },
{ "path": "../../packages/db" }
]
}
tsc -b builds the graph incrementally. Combined with Turbo’s caching: very fast.
Shared package as just-types
// packages/types/package.json
{
"name": "@my/types",
"main": "./src/index.ts",
"types": "./src/index.ts"
}
No build step needed if all bundled apps support TS source. Cuts build time in half. Use for pure-types packages.
Internal package patterns
UI components (with build)
{
"name": "@my/ui",
"exports": {
".": "./dist/index.js",
"./button": "./dist/button.js"
},
"scripts": {
"build": "tsup src/index.ts src/button.ts --dts --format esm"
}
}
tsup for libraries; ESM only in 2026 (Node 20+ supports it natively).
Source-only packages
{
"name": "@my/types",
"exports": "./src/index.ts"
}
Apps consume the TS source directly via TS path resolution; no build for the internal package.
Shared ESLint / Biome config
// tooling/biome/biome.base.json
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"linter": { "rules": { "recommended": true } },
"formatter": { "indentStyle": "space" }
}
// apps/web/biome.json
{ "extends": ["../../tooling/biome/biome.base.json"] }
DRY config. Override per package only when truly different.
CI
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build lint test --filter=...[origin/main]
--filter runs tasks only for changed packages and their dependents. PR with a 1-line UI change doesn’t rebuild the API.
Bun workspaces
Bun supports workspaces too:
// package.json
{
"workspaces": ["apps/*", "packages/*"]
}
Combined with bun run, bun test, bun build. Faster than pnpm + tsc for the simple cases. Less ecosystem in 2026 but rapidly catching up.
Versioning
Within the monorepo: workspace:* — always the local version.
For external publishing:
- Changesets: per-PR changeset files; bumps versions on release.
- Lerna (legacy): still works but Changesets is more popular.
- Semantic-release + Nx: Nx-native.
For internal-only packages: no need to version externally.
Common mistakes
1. One huge package.json
Putting all deps in the root. Apps drag in deps they don’t use. Each app/package has its own.
2. Circular deps
Package A imports B; B imports A. Refactor to break the cycle (extract shared into C).
3. No CI cache
Every CI run rebuilds everything. Use Turbo remote cache; saves hours.
4. Mixed workspace:* and explicit versions
Inconsistent linking. Always workspace:* for internal.
5. Buildless package shipped as dist/
Consumers can’t find source for source maps; debugging painful. Either build with sourcemaps or ship source-only.
What I’d ship today
For a TS monorepo:
- pnpm workspaces (or Bun if all-in on Bun).
- Turborepo + remote cache.
- TS project references +
incremental: true. - Shared config in
tooling/. - Source-only internal packages where possible.
- Biome for lint/format (one tool across all).
- Changesets if publishing externally.
Read this next
If you want my Turborepo + pnpm + TS project references starter, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .