DEV Community

Cover image for How I Turned 4 Sites and a Shared Lib Into One pnpm Workspace
Francesco Di Donato
Francesco Di Donato

Posted on • Originally published at didof.dev

How I Turned 4 Sites and a Shared Lib Into One pnpm Workspace

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

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
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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/*"
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Any site that wants to use it declares a dependency with the workspace:* protocol:

{
  "name": "didof.dev",
  "dependencies": {
    "@didof/shared": "workspace:*"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Consumers import specific entrypoints:

import { baseBlogSchema } from "@didof/shared/schemas";
import { codeblockCopyTransformer } from "@didof/shared/plugins";
import "@didof/shared/styles/codeblock.css";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Declare which folders are workspace members:

cat > pnpm-workspace.yaml <<'EOF'
packages:
  - "packages/*"
EOF
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install everything from the new root:

pnpm install
Enter fullscreen mode Exit fullscreen mode

Verify pnpm found every package:

pnpm -r ls --depth -1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Build every package in topological order (respects workspace:* deps):

pnpm -r build
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add a dependency to a specific package:

pnpm --filter didof.dev add @radix-ui/react-dialog
Enter fullscreen mode Exit fullscreen mode

Add a root-level dev dependency (the -w flag for workspace root):

pnpm add -D -w sharp
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Add it as a workspace dep in didof.dev:

{
  "dependencies": {
    "velocaption.com": "workspace:*"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now didof.dev imports the registry directly:

import { TOOL_REGISTRY } from "velocaption.com/tools-registry";
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The -w is short for --workspace-root. To add to a specific package from anywhere:

pnpm --filter didof.dev add @radix-ui/react-dialog
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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],
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)