TypeScript monorepo: pnpm + Turborepo tradeoffs in 2026
When a monorepo helps, when it hurts, and the pnpm + Turborepo + Changesets stack we ship by default — including the parts that hurt at scale.
For a team of two to twenty engineers shipping a SaaS plus a marketing site plus one or two satellite apps (mobile, CLI, internal tools), a pnpm + Turborepo + Changesets monorepo is the right default in 2026. It gives you shared TypeScript types across all surfaces, one place to upgrade dependencies, and parallel builds that scale. It also gives you several specific operational headaches that single-repo teams don't have. The trade-offs are predictable enough that I can list them — and that's the test of whether a tooling stack is mature. The unknown-unknowns of polyrepo (which Slack channel has the truth about that one shared service?) are gone. The known-knowns of monorepo (cache thrashing, dependency-graph confusion) can be managed.
We ship monorepos for most multi-surface customer engagements. This is what we've learned.
When a monorepo is the right call
The case is strongest when you have:
- Multiple consumers of a shared type / API contract (a Next.js app + a mobile RN app talking to the same API → share the types)
- Multiple deployables that share business logic (the marketing site, the app, an internal dashboard — all using the same
lib/billingmodule) - A small team where context-switching cost is high (one PR touches the API, the web client, and the docs — better as one PR than three)
- Frequent end-to-end refactors across surfaces
It's the wrong call when:
- You have one app and no satellites. Don't pre-monorepo.
- Your teams want strong independence (mobile team doesn't want to merge with web team's release cadence)
- You have non-JS components (Go services, Python ML) — a JS monorepo doesn't help them
The default stack
What we ship into customer engagements:
my-repo/
├── apps/
│ ├── web/ # Next.js
│ ├── mobile/ # Expo / React Native
│ └── api/ # Fastify or Hono
├── packages/
│ ├── ui/ # shadcn-style component library
│ ├── tsconfig/ # shared tsconfig presets
│ ├── eslint-config/ # shared ESLint
│ ├── types/ # shared TypeScript types
│ └── lib/ # shared business logic (zod schemas, helpers)
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
└── tsconfig.jsonpnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"lint": {
"dependsOn": ["^build"]
},
"type-check": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}That dependsOn: ["^build"] is the key. It means "build my dependencies before me." Turbo handles the topological ordering.
Workspace dependencies
Packages reference each other via the workspace:* protocol:
{
"name": "@my-repo/web",
"dependencies": {
"@my-repo/ui": "workspace:*",
"@my-repo/lib": "workspace:*"
}
}pnpm symlinks them locally. No npm-link footguns. Hot-reload works because the import resolves to the actual source files in the other package.
Shared TypeScript config
One of the highest-value wins. Define a base config once, extend it everywhere.
// packages/tsconfig/base.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"jsx": "preserve",
"incremental": true
}
}// apps/web/tsconfig.json
{
"extends": "@my-repo/tsconfig/base.json",
"compilerOptions": { "plugins": [{ "name": "next" }] },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}Upgrading TypeScript becomes one PR instead of N PRs across N repos.
Sharing zod schemas — the killer feature
The reason we monorepo most engagements. Define your API request/response shapes in one package; consume them in the API server, the web client, and the mobile client.
// packages/lib/src/schemas/widgets.ts
import { z } from "zod";
export const WidgetCreateInput = z.object({
name: z.string().min(1).max(80),
color: z.enum(["cyan", "amber", "purple"]),
});
export type WidgetCreateInput = z.infer<typeof WidgetCreateInput>;
export const Widget = z.object({
id: z.string(),
name: z.string(),
color: z.enum(["cyan", "amber", "purple"]),
createdAt: z.string(),
});
export type Widget = z.infer<typeof Widget>;Server validates with WidgetCreateInput.parse(body). Client knows the type without hand-syncing. Refactor a field, both ends update at once.
The Turbo cache trade-off
Turbo caches build artifacts. A repeat build of an unchanged package is instant. A change to one package only rebuilds dependents. This is great when it works and a debugging nightmare when it doesn't.
The two failure modes:
- Cache miss when you expected a hit. Usually because something in the input glob changed (a different env var, a different node_modules state).
- Cache hit when you expected a miss. Usually because Turbo didn't see a file that should have been an input — typically a generated file. Add it to
inputsexplicitly.
{
"build": {
"inputs": ["src/**/*.ts", "src/**/*.tsx", "package.json", "tsconfig.json"],
"outputs": [".next/**", "!.next/cache/**"]
}
}Remote cache
Turbo's remote cache (free on Vercel, configurable elsewhere) shares the cache across CI runs and across team members. A teammate builds once, you pull from the cache, your CI builds nothing if main already cached the same SHA.
This is the single biggest win at scale. CI builds drop from 8 minutes to 90 seconds when the cache is hot.
# Enable in CI
npx turbo login
npx turbo link
# Or via env: TURBO_TOKEN, TURBO_TEAMWhen the monorepo bites
Predictable problems:
- Single broken commit blocks everyone. A bad commit to
packages/libbreaks the web build, the mobile build, the docs build. Aggressive CI gates help. - Dependency upgrades are bigger. Upgrading React from 18 to 19 touches every app at once.
- Deploy coupling. If your
apps/webandapps/apideploy from the same monorepo, a hotfix toapps/webships changes fromapps/apiyou may not be ready to release. Plan deploy scopes. - CI complexity. Multi-package CI needs change detection (only run jobs for changed packages). Turbo helps but you still need to wire it up.
- IDE performance. TypeScript LSP can choke on huge monorepos. Use Project References (
tsconfig.jsonwithreferences) to scope what the LSP loads at once.
Changesets for versioning
If you publish packages from the monorepo (e.g. your @yourorg/ui is consumed by external customers), use Changesets. Each PR can attach a .changeset/foo.md describing what changed; on release, Changesets bumps versions and writes changelogs.
If you don't publish packages — you have an entirely private monorepo — skip versioning entirely. Just deploy.
What we ship by default
Our standard monorepo setup at YAEL:
- pnpm workspaces
- Turborepo with remote cache
- Shared
@my-repo/tsconfig,@my-repo/eslint-config,@my-repo/types - Shared
@my-repo/libwith zod schemas apps/web(Next.js) +apps/api(if separate from Next.js)- CI matrix that builds, lints, type-checks in parallel
- Changesets only if we publish packages
For most customer engagements that's the whole structure. It scales from 2 to 20 engineers without needing significant changes.
Nx — when does it become worth it?
Nx is heavier than Turbo. It gives you a richer task graph, code generators, and project crystals (auto-detecting tasks from package.json/tsconfig). For very large monorepos (30+ packages, multi-language workloads, complex caching) Nx is genuinely better.
For most teams under that size, Turbo's simplicity wins. We've migrated teams off Nx onto Turbo more often than the reverse.
Setting up a monorepo for a new product?
We've shipped pnpm + Turborepo monorepos for B2B SaaS, mobile apps, and internal tools.
FAQ
Can I use npm workspaces instead of pnpm?
You can. pnpm is faster, has stricter node_modules semantics (no phantom dependencies), and uses less disk. For a multi-package repo, pnpm is meaningfully better.
What about Bun workspaces?
Bun's monorepo support is improving but still rougher around edges (some workspace protocols, lockfile interop). For production we still default to pnpm. We use Bun for scripts and standalone tools.
Single tsconfig at the root or per-package?
Per-package, all extending a shared base. The root tsconfig is just a "references" stub for IDE convenience.
Should I share my Tailwind config across packages?
Yes. Put the shared theme in packages/ui and have each app's tailwind.config.ts extend it. Same pattern as TS config.
How do I handle environment variables?
Per-app .env.local. Don't try to share env vars across apps — they have different surfaces and different secrets. Use a single source of truth (e.g. a .env.example per app) and a CI step that validates required vars exist.
Does Turbo support watch mode?
turbo watch runs your dev tasks with cross-package awareness. Useful when you're editing a shared package and want the consuming app to reload.
Can I have a Python or Go service in my JS monorepo?
You can put it in the same git repo. Don't put it under pnpm-workspace. Turbo can still orchestrate cross-language tasks but you lose the JS-specific affordances. For polyglot repos, Bazel or Nx are more natural.
What's the IDE story?
VS Code with workspace folders works fine. Make sure TypeScript "use workspace version" is on. The TS LSP scales to ~50 packages comfortably; past that, use Project References.