Tools: How I Turned 4 Sites and a Shared Lib Into One pnpm Workspace - 2025 Update

Tools: How I Turned 4 Sites and a Shared Lib Into One pnpm Workspace - 2025 Update

Why pnpm, Not npm or Yarn

The Layout That Actually Works

pnpm-workspace.yaml: The Three-Line File That Starts It All

Sharing Code: workspace:*

Setting Up the Workspace From Scratch

Running Scripts with --filter

Subpath Exports Across Packages

Five Gotchas

1. pnpm deploy is a built-in, not your deploy script

2. pnpm add without -w puts deps in the wrong place

3. Vite's fs.allow narrows when you have multiple Astro apps

4. Native-binding packages need approve-builds

5. workspace:* does not work for dynamic import() from Node scripts

When to Reach for Turborepo or Nx

Troubleshooting

Frequently Asked Questions

Should I use pnpm workspaces for a two-package repo?

Do I need to publish @didof/shared to npm for the workspace to work?

What about CI? Does pnpm workspace break GitHub Actions?

Can I have different TypeScript versions per package?

Does workspace:* work with Yarn Berry or npm workspaces?

Closing 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. 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: 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: 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: 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: Same error, six months earlier, when it's a two-minute fix (pnpm add lodash) instead of a production incident. 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: 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. This is the entire contents of pnpm-workspace.yaml at the repo root: 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. The shared library lives at packages/shared/ with this identity: Any site that wants to use it declares a dependency with the workspace:* protocol: 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: Consumers import specific entrypoints: 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. 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: Declare which folders are workspace members: 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. 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: Install everything from the new root: Verify pnpm found every package: 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: That's the entire bootstrap. Fewer moving parts than most single-project setups. The --filter flag is where most of my daily work happens. A few patterns that earn their keep: Build every package in topological order (respects workspace:* deps): 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: Add a dependency to a specific package: Add a root-level dev dependency (the -w flag for workspace root): That last one is the first gotcha I hit. More on that below. 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: Add it as a workspace dep in didof.dev: Now didof.dev imports the registry directly: 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. These cost me time. They shouldn't cost you any. 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): 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. 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. The -w is short for --workspace-root. To add to a specific package from anywhere: After adding linkpreview.ai as a fourth Astro app, running pnpm --filter didof.dev dev started emitting this: 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: 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. 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: 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. 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: 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. 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. 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. 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. 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. 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. 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ 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 $ 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 $ 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 import debounce from "lodash/debounce"; import debounce from "lodash/debounce"; import debounce from "lodash/debounce"; Could not resolve "lodash/debounce" from src/hooks/useDebounce.ts Could not resolve "lodash/debounce" from src/hooks/useDebounce.ts Could not resolve "lodash/debounce" from src/hooks/useDebounce.ts [plugin:vite:import-analysis] Failed to resolve import "lodash/debounce" from "src/hooks/useDebounce.ts" [plugin:vite:import-analysis] Failed to resolve import "lodash/debounce" from "src/hooks/useDebounce.ts" [plugin:vite:import-analysis] Failed to resolve import "lodash/debounce" from "src/hooks/useDebounce.ts" 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 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 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 { "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" } } { "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" } } { "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" } } packages: - "packages/*" packages: - "packages/*" packages: - "packages/*" { "name": "@didof/shared", "version": "0.0.1" } { "name": "@didof/shared", "version": "0.0.1" } { "name": "@didof/shared", "version": "0.0.1" } { "name": "didof.dev", "dependencies": { "@didof/shared": "workspace:*" } } { "name": "didof.dev", "dependencies": { "@didof/shared": "workspace:*" } } { "name": "didof.dev", "dependencies": { "@didof/shared": "workspace:*" } } { "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" } } { "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" } } { "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" } } import { baseBlogSchema } from "@didof/shared/schemas"; import { codeblockCopyTransformer } from "@didof/shared/plugins"; import "@didof/shared/styles/codeblock.css"; import { baseBlogSchema } from "@didof/shared/schemas"; import { codeblockCopyTransformer } from "@didof/shared/plugins"; import "@didof/shared/styles/codeblock.css"; import { baseBlogSchema } from "@didof/shared/schemas"; import { codeblockCopyTransformer } from "@didof/shared/plugins"; import "@didof/shared/styles/codeblock.css"; mkdir content-hub && cd content-hub pnpm init -weight: 500;">git init mkdir content-hub && cd content-hub pnpm init -weight: 500;">git init mkdir content-hub && cd content-hub pnpm init -weight: 500;">git init cat > pnpm-workspace.yaml <<'EOF' packages: - "packages/*" EOF cat > pnpm-workspace.yaml <<'EOF' packages: - "packages/*" EOF cat > pnpm-workspace.yaml <<'EOF' packages: - "packages/*" EOF mkdir packages mv ../didof.dev packages/ mv ../velocaption.com packages/ # ... one mv per project mkdir packages mv ../didof.dev packages/ mv ../velocaption.com packages/ # ... one mv per project mkdir packages mv ../didof.dev packages/ mv ../velocaption.com packages/ # ... one mv per project rm -rf packages/*/node_modules packages/*/pnpm-lock.yaml rm -rf packages/*/node_modules packages/*/pnpm-lock.yaml rm -rf packages/*/node_modules packages/*/pnpm-lock.yaml pnpm -weight: 500;">install pnpm -weight: 500;">install pnpm -weight: 500;">install pnpm -r ls --depth -1 pnpm -r ls --depth -1 pnpm -r ls --depth -1 pnpm add -D -w typescript tsx sharp pnpm add -D -w typescript tsx sharp pnpm add -D -w typescript tsx sharp pnpm --filter didof.dev dev pnpm --filter didof.dev dev pnpm --filter didof.dev dev pnpm -r build pnpm -r build pnpm -r build pnpm --filter "./packages/*" check pnpm --filter "./packages/*" check pnpm --filter "./packages/*" check pnpm --filter didof.dev add @radix-ui/react-dialog pnpm --filter didof.dev add @radix-ui/react-dialog pnpm --filter didof.dev add @radix-ui/react-dialog pnpm add -D -w sharp pnpm add -D -w sharp pnpm add -D -w sharp { "name": "velocaption.com", "exports": { ".": "./...", "./tools-registry": "./src/features/tools/registry.ts" } } { "name": "velocaption.com", "exports": { ".": "./...", "./tools-registry": "./src/features/tools/registry.ts" } } { "name": "velocaption.com", "exports": { ".": "./...", "./tools-registry": "./src/features/tools/registry.ts" } } { "dependencies": { "velocaption.com": "workspace:*" } } { "dependencies": { "velocaption.com": "workspace:*" } } { "dependencies": { "velocaption.com": "workspace:*" } } import { TOOL_REGISTRY } from "velocaption.com/tools-registry"; import { TOOL_REGISTRY } from "velocaption.com/tools-registry"; import { TOOL_REGISTRY } from "velocaption.com/tools-registry"; { "scripts": { "deploy": "wrangler pages deploy dist --project-name didof-dev", "ship": "pnpm check && pnpm build && pnpm run deploy" } } { "scripts": { "deploy": "wrangler pages deploy dist --project-name didof-dev", "ship": "pnpm check && pnpm build && pnpm run deploy" } } { "scripts": { "deploy": "wrangler pages deploy dist --project-name didof-dev", "ship": "pnpm check && pnpm build && pnpm run deploy" } } pnpm add -D -w sharp pnpm add -D -w sharp pnpm add -D -w sharp pnpm --filter didof.dev add @radix-ui/react-dialog pnpm --filter didof.dev add @radix-ui/react-dialog pnpm --filter didof.dev add @radix-ui/react-dialog [vite] The request url ".../node_modules/.pnpm/astro@.../dist/runtime/client/dev-toolbar/entrypoint.js" is outside of Vite serving allow list [vite] The request url ".../node_modules/.pnpm/astro@.../dist/runtime/client/dev-toolbar/entrypoint.js" is outside of Vite serving allow list [vite] The request url ".../node_modules/.pnpm/astro@.../dist/runtime/client/dev-toolbar/entrypoint.js" is outside of Vite serving allow list 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], }, }, }, }); 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], }, }, }, }); 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], }, }, }, }); 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; 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; 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; - pnpm-workspace.yaml is 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, no -weight: 500;">npm link, no version drift. - Run any script across packages with pnpm --filter <name> <script>. Combine filters for graph-aware builds. - Subpath exports in a sibling's package.json lets 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. - 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