"A monorepo is just a Node project with N package.json files. The hard part isn't the structure — it's keeping installs fast and builds smart."
Why Monorepo, Not Multi-Repo
You can split your project into many repos and version each independently. That's the multi-repo path. It scales but has costs:
- A change touching three repos needs three PRs, three releases, three CI runs.
- Versioning skew: app A pulls library X@1.2, app B pulls X@1.0; bug in app B is fixed in X@1.3 that app A also depends on.
- Onboarding: clone repo X, then Y, then Z;
npm linkdance for local dev.
Monorepos solve those by keeping everything in one repo, letting the package manager link packages locally, and shipping cross-cutting changes in one PR. Cost: tooling has to handle 10x more package.json files than a single-repo project.
The Layout
The canonical 2026 Node monorepo:
my-repo/
apps/
web/ # Next.js app, references @my/ui and @my/utils
api/ # Node service, references @my/utils
packages/
ui/ # React component library
utils/ # Pure TS utilities
config/ # Shared eslint, tsconfig, prettier presets
package.json # "private": true, workspaces declared
pnpm-workspace.yaml
turbo.json # if using Turborepo
pnpm-lock.yaml
apps/* are deployable applications. packages/* are libraries consumed by apps. The split isn't rigid — pick names that fit your project.
pnpm Workspaces — The Foundation
- Workspaces in pnpm enforce package boundaries — each package only sees its declared dependencies, preventing phantom transitive imports across packages.
- Content-addressable store means installing 10 packages that all use React doesn't copy React 10 times — it's hardlinked from one store entry.
workspace:^protocol lets packagewebreference@my/uias if it were published, while actually pulling from the sibling workspace.
The workspace: Protocol Again
From Track 2 but worth repeating: in a monorepo, your internal packages reference each other with the workspace: prefix:
// apps/web/package.json
{
"dependencies": {
"@my/ui": "workspace:^",
"@my/utils": "workspace:*"
}
}
During development: symlinks point to the sibling. During publish: pnpm rewrites these to real semver ranges based on the sibling's current version. Same import statement (import { Button } from '@my/ui'), different resolution depending on context.
Turborepo — Build Orchestration
Workspaces handle installation. They don't handle build order. turborepo reads your monorepo's package graph, figures out which builds depend on which, and runs them topologically. Plus aggressive caching: if packages/utils didn't change since last build, skip its rebuild even though apps depend on it.
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {}
}
}
Run pnpm turbo run build at the root; turbo walks the graph and runs builds in dependency order, caching outputs in .turbo/. The second run of turbo run build hits the cache for unchanged packages — often 5-10x faster than a clean rebuild.
When Not to Monorepo
Monorepos aren't free:
- One person, one app, one library — overhead exceeds benefit.
- Different release cadences that genuinely can't share lockfiles (a npm-published library + an internal product).
- Strict access controls per project — monorepo means anyone with repo access sees everything.
Start single-repo. Add packages when you have a real need to share code. Convert to monorepo when the pain of npm link or version skew becomes daily. Don't predict-and-monorepo; respond to the actual pain.