Home

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.json and package.json.

Now: pnpm + Turborepo

The new repo uses three tools that each solve one problem well:

ToolRole
pnpmPackage manager with strict dependency resolution
pnpm workspacesNative workspace protocol (workspace:*)
TurborepoTask 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.4TypeScript 5.8
tsc for buildsVite + vite build
ESLint (legacy config)ESLint 10 + typescript-eslint flat config
rimraf for cleanuprm -rf scripts
yarn.lockpnpm-lock.yaml
Turborepo caching

What You Actually Gain

  1. Speed — pnpm installs are 2-3x faster than Yarn. Turborepo’s cache skips redundant builds entirely. Parallel task execution replaces Lerna’s sequential runs.

  2. Strictness — pnpm’s node_modules structure prevents phantom dependencies. You can only import what’s declared in your package.json.

  3. Security — pnpm blocks install scripts by default. No more surprise postinstall exploits.

  4. Simplicity — One config file for workspaces (pnpm-workspace.yaml), one for tasks (turbo.json). No lerna.json, no extra CLI to learn.

  5. 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:

  1. Replace lerna.json + package.json.workspaces with pnpm-workspace.yaml
  2. Install dependencies with pnpm import (converts yarn.lock → pnpm-lock.yaml)
  3. Add turbo.json with your task graph
  4. Replace lerna run with turbo in your scripts
  5. Add onlyBuiltDependencies to 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.