Skip to content

Writing

The TypeScript monorepo playbook: workspaces, project references and release flow

When a monorepo earns its keep and when it is overhead, the two build-graph decisions that determine whether it stays fast, and the versioning choice that decides how painful releases are.

5 min read

A monorepo is not a productivity tool. It is a trade: you give up independent repositories in exchange for atomic cross-package changes, and you take on a build graph you now have to keep fast. It is worth it under specific conditions and overhead outside them.

When it earns its keep

ConditionWhy a monorepo helps
Packages change togetherOne PR, one review, one CI run — instead of three coordinated releases
Shared types between client and serverA contract change fails at compile time, in the same commit
One team owns everythingNo cross-team coordination cost to absorb
You want one toolchainLint, format and test configured once
The first two are the real arguments. If neither applies, separate repositories are simpler and you should keep them.

Layout: apps consume, packages are consumed

code
apps/
  web/          Next.js
  api/          the service
  mobile/       React Native
packages/
  ui/           components
  core/         domain logic and types
  config/       shared tsconfig, biome, tailwind preset

One rule carries most of the value: packages/ never imports from apps/. An app is a composition root. The moment a package reaches back into one, the graph has a cycle and nothing can be extracted again.

The two decisions that determine build speed

One: project references, or a bundler per package?

TypeScript project references let tsc build incrementally across packages and give you correct type-checking without building everything. The cost is configuration and discipline.

json
// packages/ui/tsconfig.json
{
  "extends": "@repo/config/tsconfig/base.json",
  "compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" },
  "references": [{ "path": "../core" }]
}

composite: true is what makes a package referenceable, and references is what makes the build order derivable rather than declared twice.

  1. Publish types from source during development

    Point the types condition at src in development and dist in the published package. Otherwise every change to a shared package needs a rebuild before the consumer sees it — which is the single most common reason people find monorepos slow.

  2. Do not build packages that only export types

    A types-only package needs no output at all.

Two: caching, local and remote

A task runner that hashes inputs and skips unchanged work is what makes a monorepo scale. The important part is not the tool — it is declaring inputs and outputs honestly.

json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      "outputs": ["dist/**"]
    },
    "test": { "dependsOn": ["^build"], "inputs": ["src/**", "tests/**"] },
    "lint": { "inputs": ["src/**", "biome.json"] }
  }
}

Remote caching is where the payoff lands: CI reuses what a developer already built, and a second CI run on an unchanged package does nothing at all.

Versioning: fixed or independent

Fixed (one version)Independent
ReleaseEverything, togetherOnly what changed
Consumer experienceVersion numbers move for no reasonA bump means a change
Internal depsTrivially consistentMust be updated on release
Best forOne product in partsA family of independent libraries
Fixed is simpler and noisier. Independent is quieter and needs tooling. Pick by whether your packages are one product or several.

Either way, drive it from changesets committed alongside the code, so the changelog is written by the person who made the change rather than reconstructed later from commit subjects.

Internal dependencies: use the workspace protocol

json
{ "dependencies": { "@repo/core": "workspace:*" } }

It resolves to the local package during development and is rewritten to a real version on publish. Without it, you get a copy from the registry — usually an older one — and a class of bug where local and CI disagree.

What CI should actually run

  1. Only what the change affects

    --filter=...[origin/main] builds the changed packages and everything that depends on them. On a large repository this is the difference between four minutes and forty.

  2. One command, the same locally and in CI

    A red build must be reproducible in one line, or CI failures become somebody else's problem.

  3. Enforce the dependency direction

    A lint rule that forbids packages/ importing apps/, and forbids deep imports past a package's entry point. A convention nobody can violate beats ten everyone agrees with.

Where monorepos actually go wrong

  1. A `shared` or `utils` package that everything depends on

    It becomes a bottleneck: every change invalidates every cache and rebuilds everything. Split by domain rather than by "things that had nowhere else to go".

  2. Cyclic dependencies between packages

    Usually a sign two packages are one package. Merge them rather than adding an interface layer to break the cycle.

  3. Inputs declared too narrowly

    See above. This is the failure that produces green builds for untested code.

  4. Package boundaries drawn by layer instead of by domain

    components, hooks, utils means every feature touches every package. billing, catalogue, auth means a feature touches one.

Start here

Tell us what you are building

Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.