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 .