Before the monorepo, my local ~/Workspace/didof/ looked like a cork board of unrelated projects: four Astro sites with their own node_modules folders, their own lockfiles, their own @didof/shared-something-that-was-always-out-of-sync, and their own build commands. Pushing a fix to the shared library meant four npm link dances, four CI builds, and a lingering anxiety that one of the sites was running a three-week-old version of whatever I just edited.
Two weeks ago I rolled all of that into a single pnpm workspace. Four Astro apps (didof.dev, velocaption.com, speechstudio.ai, linkpreview.ai) plus one shared library (@didof/shared) now live under one packages/ folder, share one lockfile, and can cross-reference each other's source at build time. I'm shipping faster, and the cross-version bugs are gone.
This post is the walkthrough, with every gotcha I actually hit.
Key Takeaways
pnpm-workspace.yamlis a three-line file that declares which folders are part of the workspace. That's the whole mechanism.workspace:*in a dependency spec points at a sibling package. No publishing, nonpm link, no version drift.- Run any script across packages with
pnpm --filter <name> <script>. Combine filters for graph-aware builds.- Subpath
exportsin a sibling'spackage.jsonlets another package import just one module (e.g. a registry file) without pulling in the whole site.- The five gotchas at the end of this post cost me roughly four hours combined. All of them are cheap to fix once you know about them.
Why pnpm, Not npm or Yarn
npm workspaces work. Yarn workspaces work. I use pnpm because of how it handles node_modules.
Every package in a pnpm workspace gets its own node_modules folder, but the contents are symlinks into a single content-addressable store at the monorepo root. One copy of React on disk, referenced by every package that depends on React. With four Astro sites each pulling in the same ~800 MB of Astro + Vite + React + Radix + Tailwind, the savings are dramatic:
$ du -sh node_modules # content store at root
1.2G
$ du -sh packages/*/node_modules # per-package symlink farms
4.8M packages/didof.dev/node_modules
3.2M packages/velocaption.com/node_modules
3.1M packages/speechstudio.ai/node_modules
2.9M packages/linkpreview.ai/node_modules
If I had four separate node_modules folders with copies of the same stuff, that's closer to 5 GB. pnpm reduces it to 1.2 GB. On a laptop with 500 GB of storage and a tendency to hoard Docker images, that matters.
The other reason is strict dependency resolution. npm and Yarn will let your code import any package that happens to be in node_modules, including transitive dependencies your project never declared. Everything works locally, everything works in CI, and then one day a parent dependency drops that transitive dep in a minor version bump and your build breaks in production with no diff to blame.
Here's a concrete case I lived with Yarn on the old didof.dev repo. A Vite plugin I used depended on lodash internally. Somewhere in my source I wrote:
import debounce from "lodash/debounce";
I never added lodash to my own package.json. It worked for months. Then the Vite plugin upgraded to lodash-es, dropped the old dep, and my build broke:
Could not resolve "lodash/debounce" from src/hooks/useDebounce.ts
With pnpm, this mistake surfaces the moment you write the import. pnpm's node_modules layout only exposes packages you directly declare — transitive ones are hidden inside .pnpm/. So the first run of pnpm dev after adding that import fails immediately:
[plugin:vite:import-analysis] Failed to resolve import "lodash/debounce" from "src/hooks/useDebounce.ts"
Same error, six months earlier, when it's a two-minute fix (pnpm add lodash) instead of a production incident.
The Layout That Actually Works
content-hub/
├── pnpm-workspace.yaml
├── package.json # root; devDeps shared across packages
├── pnpm-lock.yaml # single lockfile for everything
├── scripts/ # monorepo-wide scripts
├── packages/
│ ├── shared/ # @didof/shared: plugins, schemas, utils
│ ├── didof.dev/
│ ├── velocaption.com/
│ ├── speechstudio.ai/
│ └── linkpreview.ai/
└── .gitignore
Every package has its own package.json, astro.config.mjs, tsconfig.json, src/, and dist/. The root package.json is a thin shell that holds monorepo-wide dev tooling (tsx, typescript, sharp, glob) and a handful of scripts that delegate into packages:
{
"scripts": {
"dev:didof": "pnpm --filter didof.dev dev",
"dev:velocaption": "pnpm --filter velocaption.com dev",
"build:all": "pnpm -r build",
"sync": "pnpm tsx scripts/content-sync.ts",
"ship:didof": "pnpm --filter didof.dev ship",
"ship:velocaption": "pnpm --filter velocaption.com ship"
}
}
The pattern repeats for every workspace member: one dev:<name> and one ship:<name> per site. Cross-site commands (build:all, sync) stay unqualified since they operate on the graph.
No clever turbo orchestration. Just pnpm filters. For four sites that mostly build independently, Turborepo would be over-engineering.
pnpm-workspace.yaml: The Three-Line File That Starts It All
This is the entire contents of pnpm-workspace.yaml at the repo root:
packages:
- "packages/*"
That's the whole mechanism. Anything matching that glob is a workspace member. When I cloned linkpreview.ai into packages/linkpreview.ai/ and ran pnpm install, pnpm discovered it, linked its declared deps, and exposed it under its declared package name. No further registration needed.
The packages key accepts multiple globs if you want more specific grouping (apps/*, libs/*, tooling/*). For a flat layout one glob is enough.
Sharing Code: workspace:*
The shared library lives at packages/shared/ with this identity:
{
"name": "@didof/shared",
"version": "0.0.1"
}
Any site that wants to use it declares a dependency with the workspace:* protocol:
{
"name": "didof.dev",
"dependencies": {
"@didof/shared": "workspace:*"
}
}
The * means "whatever version is in the workspace right now." On pnpm install, pnpm creates a symlink from packages/didof.dev/node_modules/@didof/shared pointing at packages/shared/. Edits in shared show up instantly in every consumer: no rebuild, no publish, no npm link of the day.
When you actually publish the workspace (say, releasing @didof/shared to npm for public consumption), pnpm rewrites workspace:* to the real version number at pack time. The published package.json looks normal to any consumer outside the workspace. Inside the workspace, development stays symlinked.
Here's what packages/shared/package.json's exports map looks like in practice:
{
"name": "@didof/shared",
"exports": {
".": "./index.ts",
"./plugins": "./plugins/index.ts",
"./schemas": "./schemas/index.ts",
"./astro-config-base": "./astro-config-base.ts",
"./components/CodeBlockScript.astro": "./components/CodeBlockScript.astro",
"./styles/codeblock.css": "./styles/codeblock.css"
}
}
Consumers import specific entrypoints:
import { baseBlogSchema } from "@didof/shared/schemas";
import { codeblockCopyTransformer } from "@didof/shared/plugins";
import "@didof/shared/styles/codeblock.css";
Each subpath maps to a specific file. You keep the barrel-import ergonomics without pulling the whole library into a bundle when only one piece is needed.
Setting Up the Workspace From Scratch
If you're starting from zero (or consolidating existing repos the way I did), the bootstrap is maybe a dozen commands. Here's the full sequence.
Create the root and init a package:
mkdir content-hub && cd content-hub
pnpm init
git init
Declare which folders are workspace members:
cat > pnpm-workspace.yaml <<'EOF'
packages:
- "packages/*"
EOF
Move each existing project into packages/<name>. For a greenfield package, mkdir packages/<name> and pnpm init inside it. Either way pnpm discovers them on the next install.
mkdir packages
mv ../didof.dev packages/
mv ../velocaption.com packages/
# ... one mv per project
Every moved project keeps its own package.json, but its individual pnpm-lock.yaml and node_modules are obsolete. Clean them so the monorepo-root lockfile takes over:
rm -rf packages/*/node_modules packages/*/pnpm-lock.yaml
Install everything from the new root:
pnpm install
Verify pnpm found every package:
pnpm -r ls --depth -1
That prints each workspace member with its declared dependencies. If a package is missing, check that its package.json sits directly in packages/<name>/, not nested inside another folder.
From here, workspace:* references resolve automatically, --filter scripts work, and shared dev tooling goes at the root:
pnpm add -D -w typescript tsx sharp
That's the entire bootstrap. Fewer moving parts than most single-project setups.
Running Scripts with --filter
The --filter flag is where most of my daily work happens. A few patterns that earn their keep:
Run dev on one site:
pnpm --filter didof.dev dev
Build every package in topological order (respects workspace:* deps):
pnpm -r build
pnpm sees that didof.dev depends on @didof/shared and builds shared first. If you swap pnpm for npm workspaces you'd need to orchestrate this yourself or rely on a runner like Turborepo.
Run a command in every package that matches a pattern:
pnpm --filter "./packages/*" check
Add a dependency to a specific package:
pnpm --filter didof.dev add @radix-ui/react-dialog
Add a root-level dev dependency (the -w flag for workspace root):
pnpm add -D -w sharp
That last one is the first gotcha I hit. More on that below.
Subpath Exports Across Packages
Here's where monorepos start earning their keep for real.
packages/velocaption.com/ has a tool registry at src/features/tools/registry.ts that exports a TOOL_REGISTRY object with 16 entries. My didof.dev landing page wants to show a subset of those tools as a "Free Tools by Velocaption" section.
Before the monorepo, the only way to do this was to fetch https://velocaption.com/tools/rss.xml at build time, parse the XML, and hope the deployed site was up. Now I add a subpath export to velocaption's package.json:
{
"name": "velocaption.com",
"exports": {
".": "./...",
"./tools-registry": "./src/features/tools/registry.ts"
}
}
Add it as a workspace dep in didof.dev:
{
"dependencies": {
"velocaption.com": "workspace:*"
}
}
Now didof.dev imports the registry directly:
import { TOOL_REGISTRY } from "velocaption.com/tools-registry";
Astro/Vite resolves the TypeScript at build time. No compilation step, no JSON generation, no HTTP request to production. The landing page renders from the same source of truth the velocaption site uses for its own tool pages.
For content that's a little more shape-y (like blog posts with frontmatter), I wrote a small build-time aggregator that globs sibling src/content/blog/*/index.mdx, parses frontmatter, copies cover images into didof.dev's public folder, and emits JSON. The pattern is the same: filesystem reads, not HTTP. Sibling packages are just code and data on disk.
Five Gotchas
These cost me time. They shouldn't cost you any.
1. pnpm deploy is a built-in, not your deploy script
I wrote a ship npm script that chained pnpm check && pnpm build && pnpm deploy. The pnpm deploy step failed every time with ERR_PNPM_NOTHING_TO_DEPLOY. Took me ten minutes to find out: pnpm deploy is a pnpm subcommand that copies a package's production dependencies somewhere. It doesn't run my deploy npm script.
The fix is pnpm run deploy (explicit script invocation):
{
"scripts": {
"deploy": "wrangler pages deploy dist --project-name didof-dev",
"ship": "pnpm check && pnpm build && pnpm run deploy"
}
}
Any script name that collides with a pnpm built-in needs pnpm run <name> to disambiguate. The built-in commands to watch out for: deploy, install, add, remove, update, link, pack, publish, start, test. Most of those are obvious (don't override install), but deploy and start are common enough to conflict.
2. pnpm add without -w puts deps in the wrong place
Running pnpm add sharp from the monorepo root looks like it should add sharp to the root's package.json. It does not. pnpm will refuse, or worse, add it to a random package depending on which directory you're in.
To add to the root:
pnpm add -D -w sharp
The -w is short for --workspace-root. To add to a specific package from anywhere:
pnpm --filter didof.dev add @radix-ui/react-dialog
3. Vite's fs.allow narrows when you have multiple Astro apps
After adding linkpreview.ai as a fourth Astro app, running pnpm --filter didof.dev dev started emitting this:
[vite] The request url ".../node_modules/.pnpm/astro@.../dist/runtime/client/dev-toolbar/entrypoint.js" is outside of Vite serving allow list
Vite's dev server has an allowlist of paths it's willing to serve files from. With one Astro app in a repo, it correctly detects the project root and includes the hoisted node_modules/.pnpm/ store two levels up. With multiple sibling Astro apps, the auto-detection gets narrower and excludes the store, which breaks Astro's own dev toolbar.
Fix: explicitly tell Vite the whole monorepo is fair game, in each Astro app's config:
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const MONOREPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
export default defineConfig({
vite: {
server: {
fs: {
allow: [MONOREPO_ROOT],
},
},
},
});
4. Native-binding packages need approve-builds
Packages like sharp, esbuild, and @mediapipe/hands ship native binaries that install via postinstall scripts. pnpm 10+ blocks those scripts by default for security. The first time you try to use sharp, it errors with "postinstall was skipped."
Fix: run pnpm approve-builds once and pick the packages you trust. pnpm records them in your .npmrc or package.json so future installs run their postinstalls automatically.
5. workspace:* does not work for dynamic import() from Node scripts
My build scripts do await import("velocaption.com/tools-registry") at build time. With Vite handling the import, the workspace resolution works. With a plain Node tsx script running outside Vite's resolution, it does not: Node's module resolver has no idea what velocaption.com is.
Fix for build scripts that need to reach into sibling packages: resolve absolute paths with node:path and use pathToFileURL:
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
const registryPath = resolve(
__dirname,
"../../velocaption.com/src/features/tools/registry.ts",
);
const mod = await import(pathToFileURL(registryPath).href);
const registry = mod.TOOL_REGISTRY;
This gives up the package-name aliasing but works from any Node context. For scripts that only run through Vite (e.g. inside Astro pages), stick with workspace:* imports.
When to Reach for Turborepo or Nx
I don't use either, and I've been happy with plain pnpm workspaces for four apps. But there's a threshold.
Turborepo earns its keep when:
- You have so many packages that topological ordering alone isn't enough and you need smart caching of build outputs across runs
- CI needs to skip rebuilds of packages whose source files haven't changed
- You want a built-in dependency graph visualizer
Nx earns its keep when you're in a strongly-typed polyglot monorepo (TypeScript plus Python plus Go) or when your team wants generators to scaffold new packages with the same shape.
For "four websites plus a shared lib, all in TypeScript, built independently," pnpm workspaces is the correct tool. Don't let FOMO push you onto a bigger runner before you've felt the specific pain it solves.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
ERR_PNPM_NOTHING_TO_DEPLOY from your ship script |
pnpm deploy is a built-in |
Use pnpm run deploy
|
pnpm add behaves oddly from the repo root |
Missing -w flag |
pnpm add -D -w <pkg> to add to root |
Vite dev server warns about fs.allow
|
Multiple Astro apps narrow the allowlist | Add server.fs.allow: [monorepoRoot] to each Astro's Vite config |
pnpm install logs postinstall was skipped for sharp
|
pnpm 10+ security default |
pnpm approve-builds once |
| Sibling workspace import fails in a plain Node script | Node module resolver can't see workspace: links |
pathToFileURL(resolve(__dirname, "...")) for dynamic import |
| Cross-package type imports not resolving | Missing exports map on the sibling's package.json |
Add subpath exports, reimport |
Frequently Asked Questions
Should I use pnpm workspaces for a two-package repo?
Yes, even for two. The overhead is a three-line YAML file. The payoff is a single lockfile, cross-package references via workspace:*, and the ability to grow without restructuring. Two becomes four becomes seven, and you don't want to be migrating your dep model at that point.
Do I need to publish @didof/shared to npm for the workspace to work?
No. Workspace dependencies resolve through local symlinks at install time. Publishing is only needed when consumers outside the workspace want to pull your shared library. All the code in this post runs entirely locally with no registry round-trip.
What about CI? Does pnpm workspace break GitHub Actions?
Not at all. The pnpm/action-setup GitHub Action handles workspaces out of the box. One pnpm install at the root resolves the entire graph. Your CI steps become pnpm --filter <package> build or pnpm -r test.
Can I have different TypeScript versions per package?
Technically yes, but I wouldn't. Put TypeScript at the root as a devDependency, let every package share the same version, and use per-package tsconfig.json files with extends to differentiate compile options. Version drift across packages creates confusing type errors that are hard to debug.
Does workspace:* work with Yarn Berry or npm workspaces?
The workspace:* protocol originated in pnpm and is now supported by Yarn Berry and npm 7+. The filter commands differ: npm uses --workspace=<name>, Yarn uses yarn workspace <name> <cmd>, pnpm uses --filter <name>. The underlying workspace resolution is similar across all three.
Closing
The switch cost me one afternoon of moving files and two additional hours fighting the five gotchas above. It's paid for itself every week since. Shared library edits propagate instantly. Cross-site data flows through direct filesystem reads. Installing dependencies takes seconds because everything's already in the store. Lockfile drift between sites is gone.
If you have more than one related project sharing a codebase, there's probably a pnpm workspace in your future. Start with three lines of YAML.
Top comments (0)