Build a Practical pnpm Workspace Monorepo with Claude Code
Use Claude Code with pnpm workspace to design a small monorepo, wire local packages, filter CI, and avoid common traps.
pnpm workspace is a practical way to avoid repository sprawl
I am Masa, the operator of claudecode-lab.com.
Even a small SaaS or content business splits into several pieces quickly: the customer app, the admin app, shared UI, configuration, mail jobs, content scripts, and test helpers. A single app is fine at the start. The warning sign is when you copy the same button label, Zod schema, API path, or environment variable name into three places.
That is where pnpm workspace helps. A workspace is a way to manage multiple packages inside one Git repository. The official pnpm Workspace documentation explains that a workspace unites multiple projects in one repository and is enabled by a root pnpm-workspace.yaml file.
Claude Code adds value less as a generator and more as a reviewer. It can check whether workspace:* is missing, whether a dependency points in the wrong direction, whether CI is building every package when only one changed, and whether a shared package is becoming a dumping ground. Those are the mistakes that make monorepos feel heavy.
This guide builds a small TypeScript workspace with pnpm 11.5.0. For the broader operating model, read Claude Code monorepo management and Claude Code dependency management as companion pieces.
Target shape: four packages that are enough to start
You do not need a giant platform repository on day one. I usually start with four packages because the boundaries are easy to explain.
flowchart LR
web["apps/web\n@acme/web"] --> ui["packages/ui\n@acme/ui"]
web --> config["packages/config\n@acme/config"]
admin["apps/admin\n@acme/admin"] --> ui
admin --> config
acme-workspace/
apps/
web/
src/main.ts
package.json
admin/
src/main.ts
package.json
packages/
config/
src/index.ts
package.json
ui/
src/index.ts
package.json
pnpm-workspace.yaml
package.json
tsconfig.base.json
.npmrc
CLAUDE.md
There are at least three practical use cases. First, packages/ui can hold shared display helpers and small UI primitives used by apps/web and apps/admin. Second, packages/config can centralize environment names, public URLs, and feature flag keys so the apps do not drift. Third, you can later add packages/contracts for API types or Zod schemas shared by a frontend and backend.
The trap is creating packages/everything. Shared code should mean “same meaning from every caller”, not “a convenient place to hide logic”. When asking Claude Code for help, be specific: “extract only the duplicated UI helper; keep billing decisions in the app” is much safer than “commonize this codebase”.
Copy-paste the minimal workspace
Start with pnpm-workspace.yaml. The official pnpm-workspace.yaml page shows that packages includes workspace directories, supports exclusions, and can also hold catalogs.
packages:
- "apps/*"
- "packages/*"
catalog:
typescript: ^5.8.3
The root package.json should delegate work to packages instead of containing app logic.
{
"name": "acme-workspace",
"private": true,
"packageManager": "pnpm@11.5.0",
"scripts": {
"check:web": "pnpm --filter @acme/web build",
"build": "pnpm -r --sort --if-present build",
"test": "pnpm -r --if-present test",
"lint": "pnpm -r --if-present lint",
"changed:test": "pnpm --filter \"...[origin/main]\" --if-present test"
},
"devDependencies": {
"typescript": "catalog:"
}
}
tsconfig.base.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true
}
}
Use .npmrc to remove ambiguity from local package resolution.
link-workspace-packages=false
save-workspace-protocol=rolling
shared-workspace-lockfile=true
strict-peer-dependencies=true
auto-install-peers=false
The important pair is link-workspace-packages=false plus explicit workspace:* dependencies. pnpm documents that the workspace: protocol refuses to resolve to anything other than a local workspace package. That prevents accidentally installing a registry package with the same name as your internal package.
Create a tiny configuration package.
{
"name": "@acme/config",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
}
}
export const appConfig = {
productName: "Acme Workspace",
supportEmail: "support@example.com",
publicSiteUrl: "https://example.com"
} as const;
Now create packages/ui and depend on the config package through the workspace protocol.
{
"name": "@acme/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@acme/config": "workspace:*"
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
}
}
import { appConfig } from "@acme/config";
export function renderPrimaryButton(label: string): string {
return `[${appConfig.productName}] ${label}`;
}
The web app consumes both packages.
{
"name": "@acme/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@acme/config": "workspace:*",
"@acme/ui": "workspace:*"
}
}
apps/web/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}
import { appConfig } from "@acme/config";
import { renderPrimaryButton } from "@acme/ui";
console.log(appConfig.publicSiteUrl);
console.log(renderPrimaryButton("Start trial"));
Run the workspace:
corepack pnpm install
corepack pnpm --filter @acme/web build
corepack pnpm -r --sort --if-present build
Create @acme/admin with the same dependency style and you have a second app sharing UI and configuration without copying files.
Teach Claude Code the package boundaries first
Claude Code has an official guide for monorepos and large codebases. The main lesson applies directly to pnpm workspaces: if you start at the root and let the agent read everything, unrelated packages can fill the context window and make the recommendation less focused.
Keep the root CLAUDE.md limited to repository-wide rules.
# Acme Workspace
This repository is a pnpm workspace.
Packages:
- apps/web: customer-facing TypeScript app
- apps/admin: internal admin app
- packages/ui: shared UI helpers
- packages/config: shared runtime constants
Rules:
- Use pnpm, not npm or yarn.
- Add internal dependencies with workspace:*.
- Run focused commands with pnpm --filter before running full workspace commands.
- Do not move business logic into packages/ui.
Put package-specific conventions closer to the package. The Claude Code memory documentation explains that CLAUDE.md files provide persistent instructions. In a workspace, that means the root file should orient Claude, while apps/web/CLAUDE.md or packages/ui/CLAUDE.md should carry local rules.
# packages/ui
This package contains presentation helpers only.
Allowed:
- Small formatting helpers
- Shared component labels
- Accessibility-focused UI utilities
Not allowed:
- API calls
- Billing logic
- Feature flag decisions
Start with inspection, not editing.
claude -p "
Inspect this pnpm workspace. Do not edit files yet.
List the package graph, scripts, and any dependency direction that looks risky.
Then propose the smallest change needed to make apps/web and apps/admin share UI helpers.
"
That prompt reduces the chance that Claude Code creates a large abstraction before it understands the package graph.
Use filters so daily work and CI stay small
pnpm Filtering restricts commands to a subset of packages. This is one of the most useful workspace features, especially when Claude Code is running checks during a task.
# Build only the web app
pnpm --filter @acme/web build
# Build web and the packages it depends on
pnpm --filter @acme/web... build
# Test ui and the apps that depend on it
pnpm --filter ...@acme/ui test
# Test packages related to changes since main
pnpm --filter "...[origin/main]" --if-present test
The common mistake is the direction of .... @acme/web... selects web and its dependencies. ...@acme/ui selects ui and its dependents. If you change UI code but run only @acme/ui..., you may skip the apps that import it.
A focused GitHub Actions workflow can look like this:
name: workspace-check
on:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: corepack enable
- run: corepack prepare pnpm@11.5.0 --activate
- run: pnpm install --frozen-lockfile
- run: pnpm --filter "...[origin/main]" --if-present lint
- run: pnpm --filter "...[origin/main]" --if-present test
- run: pnpm --filter "...[origin/main]" --if-present build
For a tiny repository, running everything is acceptable. But packages tend to grow once the workspace exists. Naming scripts around filtered checks early keeps CI cost and waiting time under control.
Common failures and concrete fixes
The first failure is using a normal semver range for an internal package.
{
"dependencies": {
"@acme/ui": "^0.1.0"
}
}
Inside the workspace, prefer this:
{
"dependencies": {
"@acme/ui": "workspace:*"
}
}
The second failure is putting app-specific decisions into shared packages. If packages/ui starts making billing decisions or calling admin-only APIs, the package boundary is already damaged. Shared packages should contain code that means the same thing from every caller.
The third failure is asking Claude Code to modify the whole workspace from the root for every task. It can work, but it often reads irrelevant files and produces a bigger change than needed. If the task is only in packages/ui, start there or say exactly which directories are in scope.
The fourth failure is a circular dependency. pnpm warns that scripts cannot be guaranteed to run in topological order when workspace dependency cycles exist. If packages/ui imports from apps/web, the design is backwards. Ask Claude Code to audit this regularly:
claude -p "
Check this workspace for circular dependencies and misplaced imports.
Focus on packages/* importing from apps/*, duplicated config values, and dependencies that should be workspace:*.
Return findings with file paths and suggested minimal fixes.
"
Add Changesets only when release management matters
If every package is private, you do not need release tooling on day one. If packages/ui or packages/config will be published to npm or an internal registry, add version management before the first real release. The pnpm docs note that workspace package versioning is handled by tools such as Changesets or Rush rather than by a built-in pnpm release workflow.
pnpm add -Dw @changesets/cli
pnpm changeset init
pnpm changeset
pnpm changeset version
pnpm -r publish --access public
Before publishing, understand that workspace:* dependencies are converted to normal version ranges during packing or publishing. Keep application packages marked as private: true.
Claude Code can review release PRs:
claude -p "
Review this Changesets release PR.
Check that only publishable packages are versioned,
workspace dependencies are valid,
and apps/* packages remain private.
Do not change files unless you find a blocking issue.
"
Summary: define boundaries before automating
pnpm workspace is not what makes a monorepo complicated. It is a small foundation for treating shared UI, configuration, types, and tests as real dependencies instead of copied files. Claude Code works best when you use it to inspect that foundation repeatedly.
The recommended order is pnpm-workspace.yaml, explicit workspace:* dependencies, small shared packages, layered CLAUDE.md files, and filtered CI. You do not need a heavy build system before those basics are stable.
Next, use CLAUDE.md best practices to improve the instruction layer and Claude Code testing strategies to standardize workspace checks. For team rollout or review rules, the Claude Code training and consultation page is the practical next step.
What I verified while preparing this article
The example was checked against Windows, Node.js 22, Corepack, and pnpm 11.5.0. Removing workspace:* made local package resolution ambiguous, and confusing ...@acme/ui with @acme/ui... made it easy to skip app-level tests after a UI change. In real projects, asking Claude Code to inspect the package graph before editing is a simple habit that catches unnecessary abstractions and circular dependencies early.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.