Your App LogoYOUR APP EXPERTYAE
    • Services
    • About
    • Portfolio
    • Blog
    • FAQ
    • Build Your App
    1. Home
    2. Blog
    3. TypeScript monorepo: pnpm + Turborepo tradeoffs in 2026
    Tooling

    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.

    YAEL Engineering·02 Jan 2026·7 min read·1,487 words
    On this page
    • When a monorepo is the right call
    • The default stack
    • Workspace dependencies
    • Shared TypeScript config
    • Sharing zod schemas — the killer feature
    • The Turbo cache trade-off
    • Remote cache
    • When the monorepo bites
    • Changesets for versioning
    • What we ship by default
    • Nx — when does it become worth it?
    • FAQ
    • Can I use npm workspaces instead of pnpm?
    • What about Bun workspaces?
    • Single tsconfig at the root or per-package?
    • Should I share my Tailwind config across packages?
    • How do I handle environment variables?
    • Does Turbo support watch mode?
    • Can I have a Python or Go service in my JS monorepo?
    • What's the IDE story?

    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/billing module)
    • 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.json

    pnpm-workspace.yaml:

    yaml
    packages:
      - "apps/*"
      - "packages/*"

    turbo.json:

    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:

    json
    {
      "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.

    json
    // 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
      }
    }
    json
    // 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.

    ts
    // 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:

    1. Cache miss when you expected a hit. Usually because something in the input glob changed (a different env var, a different node_modules state).
    2. 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 inputs explicitly.
    json
    {
      "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.

    bash
    # Enable in CI
    npx turbo login
    npx turbo link
    # Or via env: TURBO_TOKEN, TURBO_TEAM

    When the monorepo bites

    Predictable problems:

    • Single broken commit blocks everyone. A bad commit to packages/lib breaks 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/web and apps/api deploy from the same monorepo, a hotfix to apps/web ships changes from apps/api you 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.json with references) 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:

    1. pnpm workspaces
    2. Turborepo with remote cache
    3. Shared @my-repo/tsconfig, @my-repo/eslint-config, @my-repo/types
    4. Shared @my-repo/lib with zod schemas
    5. apps/web (Next.js) + apps/api (if separate from Next.js)
    6. CI matrix that builds, lints, type-checks in parallel
    7. 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.

    See SaaS service

    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.

    TagsTypeScriptMonorepopnpmTurborepoDX
    ServiceSaaS DevelopmentAPI Integration Services
    PreviousTelegram Mini Apps vs Discord Activities vs web game — picking the platformNext Next.js App Router: server actions vs API routes — when to pick each

    Keep reading

    ToolingBuilding an internal tool instead of buying RetoolWhen the build-your-own internal tool is the right call — the cost comparison most teams skip, and the surprisingly small Next.js + Postgres scaffold that replaces Retool for many use cases.8 min readSaaSHow to build a SaaS MVP in 6 weeks (without a rewrite later)A six-week SaaS MVP plan that doesn't trade speed for technical debt — auth, billing, multi-tenancy, and a real operator dashboard from day one.10 min readPaymentsStripe Billing vs Paddle vs LemonSqueezy for SaaS in 2026An opinionated comparison of the three default billing platforms for B2B SaaS — pricing model coverage, MoR vs not, dev DX, and where each one breaks at scale.8 min read
    On this page
    • When a monorepo is the right call
    • The default stack
    • Workspace dependencies
    • Shared TypeScript config
    • Sharing zod schemas — the killer feature
    • The Turbo cache trade-off
    • Remote cache
    • When the monorepo bites
    • Changesets for versioning
    • What we ship by default
    • Nx — when does it become worth it?
    • FAQ
    • Can I use npm workspaces instead of pnpm?
    • What about Bun workspaces?
    • Single tsconfig at the root or per-package?
    • Should I share my Tailwind config across packages?
    • How do I handle environment variables?
    • Does Turbo support watch mode?
    • Can I have a Python or Go service in my JS monorepo?
    • What's the IDE story?

    YOUR APP EXPERT LTD

    71-75 Shelton Street, LONDON WC2H 9JQ, UK

    +44 20 1234 5678

    [email protected]

    Quick Links

    • Services
    • About Us
    • Portfolio
    • Blog
    • Contact

    Stay Connected

    Newsletter

    Stay updated with our latest innovations and insights.

    © 2026 YOUR APP EXPERT LTD. All rights reserved.

    Engineering the Future of Technology