pnpm Workspaces: Set Up a JavaScript Monorepo Step by Step
How to set up a monorepo with pnpm workspaces: pnpm-workspace.yaml, linking internal packages with workspace:*, running scripts across packages with --filter and -r, sharing dependency versions with catalogs, approving build scripts in pnpm 10, and deploying a single app from the repo.
A monorepo keeps several related projects — say a web app, an API and a shared UI library — in one repository. pnpm workspaces are one of the simplest ways to run one: fast installs, one lockfile, and internal packages linked together without publishing to npm. (Monorepo vs Polyrepo, npm vs pnpm vs Yarn vs Bun)
The layout
my-product/
├── pnpm-workspace.yaml
├── package.json
├── apps/
│ ├── web/ (Next.js or Vite)
│ └── api/ (Express, Fastify…)
└── packages/
├── ui/ (shared components)
└── db/ (schema + queries)
Step 1: Declare the workspace
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
The root package.json is private and holds repo-wide scripts and dev tools:
{
"name": "my-product",
"private": true,
"scripts": {
"dev": "pnpm -r --parallel dev",
"build": "pnpm -r build",
"test": "pnpm -r test"
},
"packageManager": "pnpm@10.18.0"
}
packageManager pins the pnpm version for everyone through Corepack (set it to the version you actually use).
Step 2: Link internal packages
Give each package a name:
// packages/ui/package.json
{ "name": "@my/ui", "version": "0.0.0", "main": "src/index.ts" }
Depend on it from an app with the workspace: protocol:
// apps/web/package.json
{ "dependencies": { "@my/ui": "workspace:*" } }
Run pnpm install at the root. pnpm links packages/ui into apps/web/node_modules/@my/ui, so edits show up immediately. workspace:* guarantees you get the local copy, never a same-named package from npm.
Step 3: Run commands in the right place
pnpm --filter web dev # one package (by name or folder)
pnpm --filter web add zod # add a dependency to one package
pnpm --filter "./packages/**" build # a group of packages
pnpm --filter web... build # web and everything it depends on
pnpm -r build # every package, in dependency order
pnpm add -D -w typescript # a dev tool at the root (-w)
-r runs packages in topological order, so @my/ui builds before the apps that use it.
Step 4: One version of each dependency with catalogs
Monorepos drift: web on React 19.3, admin on 19.1. Catalogs define versions once:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
catalog:
react: ^19.3.0
zod: ^4.1.0
{ "dependencies": { "react": "catalog:" } }
Step 5: Approve build scripts (pnpm 10)
pnpm 10 doesn't run dependencies' install scripts (postinstall and friends) by default — a defence against supply-chain attacks. Packages that genuinely need them (native modules, Prisma, esbuild) must be allowed. Run:
pnpm approve-builds
and commit the resulting allowlist in your workspace config. (npm Supply Chain Security)
Step 6: Speed up builds (optional)
For a handful of packages, pnpm -r is enough. As the repo grows, a task runner like Turborepo or Nx caches build outputs and skips unchanged packages.
Deploying one app from the monorepo
Most hosts let you set a root directory (apps/web) and a build command. For Docker or a plain server, pnpm deploy creates a standalone folder with one package and only its production dependencies:
pnpm --filter api deploy --prod --legacy /tmp/api-out
For the pnpm 10 setup above, --legacy allows deployment without injected workspace dependencies. The default implementation requires injectWorkspacePackages: true; configure that deliberately if you prefer it. Build the app first and make sure its compiled output is included in the deployed package. See pnpm 10's deploy documentation.
(Production Dockerfile for Node)
TypeScript tips
Point packages at each other's source ("main": "src/index.ts") during development, or use TypeScript project references. Keep one tsconfig.base.json at the root and extend it in each package.
The summary
pnpm-workspace.yamllists packages;workspace:*links them.--filterand-rrun commands in the right packages; catalogs keep versions aligned.- pnpm 10 blocks dependency build scripts until you approve them.
EasySpawn handles monorepos on a persistent server — Claude Code can run pnpm --filter builds and tests across packages and deploy just the app that changed. See how it works or join the waitlist.
Related: package-lock.json Explained · node_modules Explained · Semantic Versioning Explained · GitHub Actions CI Basics
Keep reading
OpenAPI vs Swagger: What's the Difference?
OpenAPI is the specification for describing HTTP APIs; Swagger is the original name and a set of tools (Swagger UI, Editor, Codegen). What an OpenAPI document contains, code-first vs design-first, generating docs, clients and types, and why OpenAPI specs help AI tools use your API.
Validating Input With Zod: One Schema for Forms, APIs, and Types
Every trust boundary — request bodies, query strings, webhooks, environment variables, AI output — needs runtime validation TypeScript can't provide. Using Zod schemas at each boundary, sharing them between client and server, stripping unknown keys, and useful errors.