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
| Condition | Why a monorepo helps |
|---|---|
| Packages change together | One PR, one review, one CI run — instead of three coordinated releases |
| Shared types between client and server | A contract change fails at compile time, in the same commit |
| One team owns everything | No cross-team coordination cost to absorb |
| You want one toolchain | Lint, format and test configured once |
Layout: apps consume, packages are consumed
apps/
web/ Next.js
api/ the service
mobile/ React Native
packages/
ui/ components
core/ domain logic and types
config/ shared tsconfig, biome, tailwind presetOne 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.
// 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.
Publish types from source during development
Point the
typescondition atsrcin development anddistin 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.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.
{
"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 | |
|---|---|---|
| Release | Everything, together | Only what changed |
| Consumer experience | Version numbers move for no reason | A bump means a change |
| Internal deps | Trivially consistent | Must be updated on release |
| Best for | One product in parts | A family of independent libraries |
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
{ "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
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.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.
Enforce the dependency direction
A lint rule that forbids
packages/importingapps/, 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
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".
Cyclic dependencies between packages
Usually a sign two packages are one package. Merge them rather than adding an interface layer to break the cycle.
Inputs declared too narrowly
See above. This is the failure that produces green builds for untested code.
Package boundaries drawn by layer instead of by domain
components,hooks,utilsmeans every feature touches every package.billing,catalogue,authmeans 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.