Skip to main content
Back to blog

pnpm workspaces: managing monorepos without the headache

·3 min readDeveloper Tools

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 dev

The --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

Enjoying the blog? Subscribe via RSS to get new posts in your reader.

Subscribe via RSS