From Lerna to pnpm Workspaces + Turborepo
A few years ago I wrote about scaffolding monorepos with Lerna. Back then, Lerna plus Yarn workspaces was the de facto standard for managing multi-package repositories. I set up lerna-basic-setup to demonstrate it — lerna.json with version locking, yarn workspaces for hoisting, lerna run build across packages, and lerna-changelog for release notes.
Recently I built workspaces-2026 with a completely different stack. No Lerna. No Yarn. Here is what changed and why.
Then: Lerna + Yarn Workspaces
The old setup revolved around a lerna.json:
// lerna.json (2019)
{
"packages": ["packages/*"],
"npmClient": "yarn",
"npmClientArgs": ["--no-lockfile"],
"version": "1.1.0",
"useWorkspaces": true
}
Packages lived under packages/*, Lerna delegated installation to Yarn, and lerna run build executed scripts across all packages sequentially. Versioning was fixed — every package shared the same version. Publishing meant lerna publish, which bumped, tagged, and released in one command.
Changelogs were generated from conventional commits or via lerna-changelog by labelling PRs. It worked, but it had friction:
- Slow installs — Yarn hoisted everything to the root, but resolution was single-threaded.
- Sequential builds — Lerna ran tasks serially unless you hacked around it with
--parallel. - No caching — Every build started from scratch.
- Phantom dependencies — Hoisting made every dependency available everywhere, even when it shouldn’t be.
- Lerna’s complexity — The CLI had a dozen subcommands, and configuration was split between
lerna.jsonandpackage.json.
Now: pnpm + Turborepo
The new repo uses three tools that each solve one problem well:
| Tool | Role |
|---|---|
| pnpm | Package manager with strict dependency resolution |
| pnpm workspaces | Native workspace protocol (workspace:*) |
| Turborepo | Task orchestration with caching and parallelism |
pnpm-workspace.yaml
Instead of lerna.json + package.json:workspaces, everything lives in a single YAML file:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
allowBuilds:
"@prisma/client": true
esbuild: true
prisma: true
Notice allowBuilds. pnpm blocks install scripts by default — a security win over npm/yarn that run them unconditionally. You opt in per package.
workspace:* Protocol
Internal dependencies use pnpm’s workspace protocol instead of symlink-based hoisting:
// apps/app-two/package.json
{
"dependencies": {
"shared": "workspace:*"
}
}
This pins the dependency to the monorepo’s local package, and pnpm resolves it without downloading from the registry. You can also use workspace:^ or workspace:~ for semver-range matching.
Turborepo for Tasks
The old lerna run build is replaced by:
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
}
}
}
turbo build runs builds in parallel respecting the dependency graph (^build means “wait for dependencies’ build first”). It caches outputs based on file hashes — subsequent runs skip work entirely. The speed difference is dramatic.
Project Layout: Apps vs Packages
The old repo had only packages/* — libraries meant for npm publishing. The new repo splits into apps and packages:
workspaces-2026/
├── apps/
│ ├── app-one/ # Fastify + Prisma + GraphQL API
│ └── app-two/ # React + shadcn + Tailwind frontend
├── packages/
│ └── shared/ # Shared React components & logic
├── pnpm-workspace.yaml
├── turbo.json
├── eslint.config.js
└── tsconfig.json
- apps/ — deployable applications, each with their own build pipeline
- packages/ — internal libraries consumed by apps
This mirrors real projects where you have multiple deployables sharing common code.
Modern Tooling Stack
The diff in dev dependencies tells the story:
| 2019 (Lerna) | 2026 (pnpm + turbo) |
|---|---|
| TypeScript 3.4 | TypeScript 5.8 |
tsc for builds | Vite + vite build |
| ESLint (legacy config) | ESLint 10 + typescript-eslint flat config |
| rimraf for cleanup | rm -rf scripts |
| yarn.lock | pnpm-lock.yaml |
| — | Turborepo caching |
What You Actually Gain
-
Speed — pnpm installs are 2-3x faster than Yarn. Turborepo’s cache skips redundant builds entirely. Parallel task execution replaces Lerna’s sequential runs.
-
Strictness — pnpm’s node_modules structure prevents phantom dependencies. You can only import what’s declared in your
package.json. -
Security — pnpm blocks install scripts by default. No more surprise
postinstallexploits. -
Simplicity — One config file for workspaces (
pnpm-workspace.yaml), one for tasks (turbo.json). No lerna.json, no extra CLI to learn. -
Developer Experience — One-click Stackblitz setup via
.stackblitz.json. Watch mode across all packages. Flat ESLint config.moduleResolution: "bundler"in tsconfig.
Migration Path
If you have an existing Lerna monorepo, the migration is straightforward:
- Replace
lerna.json + package.json.workspaceswithpnpm-workspace.yaml - Install dependencies with
pnpm import(converts yarn.lock → pnpm-lock.yaml) - Add
turbo.jsonwith your task graph - Replace
lerna runwithturboin your scripts - Add
onlyBuiltDependenciesto pnpm-workspace.yaml for packages that need install scripts
The existing repo at lerna-basic-setup and the new one at workspaces-2026 show both approaches in full.