pnpm workspaces: managing monorepos without the headache
Monorepos are great in theory. All your code in one place, shared dependencies, atomic commits across packages. In practice, they can be a pain to manage. pnpm workspaces make it significantly less painful.
The basics
A pnpm workspace starts with a pnpm-workspace.yaml file at the root of your repo:
packages:
- "apps/*"
- "packages/*"This tells pnpm that any package.json inside apps/ or packages/ is a workspace member. Each member is its own package with its own dependencies, but they share a single lockfile and can reference each other.
Referencing workspace packages
If your apps/web package needs to use packages/ui, add it as a dependency with the workspace: protocol:
{
"dependencies": {
"@myproject/ui": "workspace:*"
}
}pnpm resolves this to the local package. No publishing, no linking hacks. It just works.
Running scripts across packages
pnpm has built-in support for running scripts across workspace members:
# Run build in all packages
pnpm -r run build
# Run build only in packages that changed since main
pnpm -r --filter "...[origin/main]" run build
# Run dev in a specific package
pnpm --filter @myproject/web run devThe --filter flag is powerful. You can filter by package name, by directory, by what changed in git, or by dependency relationships. This matters a lot for CI, where you do not want to rebuild everything on every commit.
Shared configuration
One of the biggest wins is sharing configuration. Put your ESLint config, TypeScript config, and Tailwind config in a shared package:
packages/
config-eslint/
package.json
index.js
config-typescript/
package.json
base.json
Then reference them from each app:
{
"extends": "@myproject/config-typescript/base.json"
}Changes to the shared config propagate to all packages automatically.
Dependency hoisting
pnpm's strict dependency model means packages only have access to dependencies they explicitly declare. This is stricter than npm, which hoists everything and lets packages accidentally use undeclared dependencies. It catches real bugs. If you forget to add a dependency to package.json, it fails locally instead of passing locally and breaking in CI or production.
Common patterns
Shared types: Put your TypeScript types in a shared package that both frontend and backend import. This gives you actual type safety across the stack, not just within each app.
Internal packages over copy-paste: If you find yourself copying utility functions between apps, that is a sign to extract a shared package. It takes two minutes to create a new workspace member.
Turborepo for orchestration: pnpm workspaces handle dependency management and script running. For build orchestration with caching, add Turborepo. They complement each other well. Turborepo uses the workspace structure pnpm already defines.
When to use workspaces
Not every project needs a monorepo. If you have a single app with no shared code, workspaces add complexity for no benefit. But the moment you have two or more packages that share dependencies or code, workspaces start paying off immediately.
I use them for any project where the frontend and backend live together, or where I have shared UI components across multiple apps. The coordination cost drops significantly compared to managing separate repos.
Sources
Related posts
Why shadcn/ui changed how I build React interfaces
What makes shadcn/ui different from traditional component libraries, and why copying components into your project is actually the better approach.
Building a personal knowledge base with Obsidian
How I use Obsidian to organize notes, documentation, and ideas with linked thinking and plain Markdown files.
Why Joplin is my go-to note-taking app
What makes Joplin stand out for note-taking, how I use it for technical documentation and daily notes, and why I picked it over the alternatives.
Enjoying the blog? Subscribe via RSS to get new posts in your reader.
Subscribe via RSS