Build a Full-Stack Monorepo with pnpm
Build a three-package pnpm workspace monorepo with shared Zod validation.
Introduction
30 Second Summary
When multiple apps need the same validation logic, the typical approach is to copy code between projects. One update later, they drift out of sync and bugs slip through.
In this project, you will build a three-package monorepo using pnpm workspaces. A shared validation library feeds both a React frontend and an Express backend, all wired through pnpm's symlink-based dependency architecture that you can inspect and trace.
What You'll Build
You'll submit a task through a React form, watch the same Zod validation schema check it client-side and server-side, see it persist in PostgreSQL across refreshes, and then trace pnpm's symlink chain to understand exactly how the architecture keeps every package isolated.
By the end of this project, you'll have:
- A shared validation library (@monorepo/shared) consumed by both frontend and backend. Edit one schema file and both consumers pick up the change instantly with zero re-publishing.
- A running full-stack task manager where you submit tasks through a React 19 frontend built with Vite, validate them with the same schema on both sides, and see them persist in PostgreSQL across page refreshes.
- A traceable dependency architecture where you follow symlinks from each package's node_modules through pnpm's virtual store to the global content-addressable store, and watch a phantom dependency import fail exactly where it should.
- Secret Mission: Configure hoisting controls to compare semistrict vs fully strict node_modules and observe how phantom dependencies break under each mode.
Do I need any prerequisites?
This project assumes you have Node.js 24, pnpm 11, PostgreSQL, and DBeaver installed locally. You should be comfortable with basic React components and have working experience with Express.
Before We Start
Before any hands-on work, take a moment to commit to what you're building and why.
You will create a three-package pnpm workspace monorepo with a task manager app, but the app code is deliberately minimal. The real deliverable is your understanding of the dependency architecture underneath it.
Set Up the Monorepo Skeleton
By default, pnpm treats every directory as an independent project with its own dependency tree and lockfile. To make multiple packages share a single dependency graph, you need a pnpm-workspace.yaml file at the root that tells pnpm which directories belong together.
In this step, you'll create the workspace root, configure pnpm to recognize three packages, and wire them together so they share a single lockfile.
In this step, get ready to:
- Create the workspace root with a pnpm-workspace.yaml configuration.
- Initialize three packages with their own package.json files and a shared lockfile.
- Wire the shared package as a dependency using the workspace: protocol.
Verify your tools and create the workspace root
This project requires Node.js 24 or higher and pnpm 11 or higher. Let's confirm both are ready.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Check your Node.js version by running this command:
node --version
✔️ I see version 24 or higher
Node.js is ready. Move on to the pnpm check below.
ⓧ I see an older version
pnpm 11 requires Node.js 22 or higher. You'll need to upgrade before continuing.
- Visit nodejs.org and download the latest LTS installer for your operating system.
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run node --version again to confirm you see v24 or higher.
ⓧ Command not found
Node.js is not installed yet. Install the LTS version to continue.
- Visit nodejs.org and download the latest LTS installer for your operating system.
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run node --version again to confirm you see v24 or higher.
- Check your pnpm version by running this command:
pnpm --version
✔️ I see version 11 or higher
pnpm is ready. Move on to creating the workspace root below.
ⓧ I see an older version
You need pnpm 11 for workspace catalogs and the features this project uses. Upgrade by following these instructions.
- Visit the pnpm installation page and follow the instructions for your operating system.
- Close and reopen your terminal, then run pnpm --version again to confirm you see 11 or higher.
Windows users
On Windows, the recommended install method is npx get-pnpm because the standalone install script may be blocked by Windows Defender.
ⓧ Command not found
pnpm is not installed yet. Install it to continue.
- Visit the pnpm installation page and follow the instructions for your operating system.
- Close and reopen your terminal, then run pnpm --version again to confirm you see 11 or higher.
Windows users
On Windows, the recommended install method is npx get-pnpm because the standalone install script may be blocked by Windows Defender.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir fullstack-monorepo
cd fullstack-monorepo
The workspace root needs two files: a package.json that marks this directory as private (so it's never accidentally published) and a pnpm-workspace.yaml that tells pnpm where your packages live.
- Open the fullstack-monorepo folder in VS Code. In the top menu, click File.
- Click Open Folder.
- Select the fullstack-monorepo folder on your Desktop, then click Open.
- Create the root package.json by hovering over the folder name in the Explorer sidebar and clicking the New File icon.
- Name the file package.json and press Enter.
- Paste the following code into package.json:
{
"name": "fullstack-monorepo",
"private": true,
"type": "module"
}
What does this code do?
- "private": true prevents this root package from ever being published to the npm registry. A workspace root is a container, not a distributable package.
- "type": "module" tells Node.js to treat all .js files as ES modules, so you can use import and export syntax instead of require().
- Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).
- Create another new file in the Explorer sidebar and name it pnpm-workspace.yaml.
- Paste the following code into pnpm-workspace.yaml:
# Tell pnpm where workspace packages live
packages:
- 'packages/*'
What does this file do?
The packages/* glob tells pnpm that every immediate subdirectory inside packages/ is a workspace member. pnpm will manage their dependencies together, sharing a single lockfile and content-addressable store.
- Save pnpm-workspace.yaml.
You should see both files listed in the Explorer sidebar: package.json and pnpm-workspace.yaml.
Now create the three package directories that the workspace glob will match.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick, same shortcut on macOS and Windows).
- Create the three package directories by running this command:
mkdir -p packages/shared packages/backend packages/frontend
- Confirm the directories exist by running:
ls packages/
You should see backend, frontend, and shared listed in the output.
Don't see all three directories?
- Make sure your terminal is inside the fullstack-monorepo folder. Run pwd to check your current location.
- If the VS Code integrated terminal opened at a different folder, run cd ~/Desktop/fullstack-monorepo to navigate to the right place, then re-run the mkdir command.
Why aren't my directories showing up?
Initialize each package with its own package.json
Each workspace package needs its own package.json with a unique scoped name. Scoped names like @monorepo/shared prevent naming collisions with public npm packages.
- In the Explorer sidebar, expand the packages/shared folder.
- Right-click the shared folder and select New File.
- Name the file package.json and paste the following code:
{
"name": "@monorepo/shared",
"version": "1.0.0",
"type": "module"
}
What does the scoped name do?
The @monorepo/ prefix is a scope. It groups all your workspace packages under one namespace, which makes cross-package imports readable: import { TaskSchema } from "@monorepo/shared".
- Save packages/shared/package.json.
- Create a package.json file inside packages/backend/ using the same steps (right-click the folder, New File). Paste the following code:
{
"name": "@monorepo/backend",
"version": "1.0.0",
"type": "module"
}
- Save packages/backend/package.json.
Your Explorer sidebar should now show package.json files inside both shared and backend.
- Create a package.json file inside packages/frontend/ the same way. Paste the following code:
{
"name": "@monorepo/frontend",
"version": "1.0.0",
"type": "module"
}
- Save packages/frontend/package.json.
All three packages are defined. Time to let pnpm discover them.
- Run pnpm install from the workspace root by running this command in the integrated terminal:
pnpm install
What does this command do?
When run at the workspace root, pnpm install reads pnpm-workspace.yaml, discovers every package matching the packages/* glob, and resolves all of their dependencies into a single shared lockfile.
- Confirm that a single lockfile was created at the root by running:
ls pnpm-lock.yaml
You should see pnpm-lock.yaml listed. This single file at the root manages dependency resolution for all three packages at once.
Why a single lockfile?
pnpm defaults to a shared workspace lockfile (the sharedWorkspaceLockfile setting is true by default). One lockfile means dependency versions are resolved together across all packages. No per-package lockfiles, no version drift between siblings.
pnpm install failed?
- Check that pnpm-workspace.yaml is at the workspace root (same level as your root package.json), not inside a subdirectory.
- Make sure each package.json inside packages/ has valid JSON. A missing comma or quote will cause a parse error.
Why did pnpm install fail in my workspace?
Wire the shared package as a workspace dependency
Both the backend and frontend will import code from the shared package. To make that work, you need to declare @monorepo/shared as a dependency in each consumer's package.json. The workspace:* protocol tells pnpm to resolve the dependency from the local workspace instead of downloading it from npm.
- Add the shared package as a dependency of the backend by running this command:
pnpm add @monorepo/shared --workspace --filter @monorepo/backend
What does this command do?
- --workspace forces pnpm to link the package from your local workspace rather than searching the npm registry.
- --filter @monorepo/backend scopes the operation so only the backend's package.json gets the new dependency.
- Open packages/backend/package.json in the Explorer sidebar.
You should see a new dependencies section with "@monorepo/shared": "workspace:*". pnpm wrote that for you automatically.
- Add the shared package as a dependency of the frontend by running this command:
pnpm add @monorepo/shared --workspace --filter @monorepo/frontend
- Open packages/frontend/package.json in the Explorer sidebar.
You should see the same "@monorepo/shared": "workspace:*" entry in the frontend's dependencies.
Before you run the final verification, what do you expect pnpm to show for the @monorepo/shared dependency? Will it point to a version number on npm, or to something local?
- Verify the workspace linking worked by running this command:
pnpm ls --filter @monorepo/backend
You should see @monorepo/shared listed with a link: prefix pointing to ../shared. This confirms that pnpm resolved the dependency from the local workspace, not from the npm registry.
Don't see the link: prefix?
- Make sure you included the --workspace flag when running pnpm add. Without it, pnpm looks for the package on the npm registry instead of your local workspace.
- Confirm the name field in packages/shared/package.json is exactly @monorepo/shared. A typo in the name means pnpm can't match it.
Why isn't my workspace dependency linking correctly?
✔️ Awesome, I've got everything!
Double check that all your files are saved.
ⓧ I'd like to double check the full code
Here is each file as it should look at the end of this step. Compare with your code and fix any differences.
{
"name": "fullstack-monorepo",
"private": true,
"type": "module"
}
packages:
- 'packages/*'
{
"name": "@monorepo/shared",
"version": "1.0.0",
"type": "module"
}
{
"name": "@monorepo/backend",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@monorepo/shared": "workspace:*"
}
}
{
"name": "@monorepo/frontend",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@monorepo/shared": "workspace:*"
}
}
Your monorepo skeleton is wired up. Three packages, one lockfile, and the shared package linked locally to both consumers. Next up, you'll fill the shared package with Zod validation schemas and build an Express backend that imports them.
Build the Shared Package and Express Backend with PostgreSQL
Your monorepo skeleton is in place. Three packages share a single lockfile, and the shared package is already wired as a workspace:* dependency of both backend and frontend.
None of those packages contain code yet. In this step, you will build a shared Zod validation library and an Express 5 backend with PostgreSQL persistence that imports from it.
In this step, get ready to:
- Build a shared Zod validation package that both frontend and backend will import.
- Write an Express 5 REST API that validates task data with shared schemas and persists tasks in PostgreSQL.
- Run the backend and confirm that cross-package workspace imports work at runtime.
Install Zod and write the shared validation package
The shared package is the single source of truth for data shapes and configuration that both the frontend and backend will rely on. Every validation rule lives here, so a change in one place updates both consumers instantly.
- Install Zod in the shared package by running this command from your workspace root:
pnpm add zod --filter @monorepo/shared
You should see pnpm resolve and link Zod into the shared package. The root pnpm-lock.yaml updates to include Zod as a dependency of @monorepo/shared.
Seeing a resolution error?
Make sure you are running the command from the workspace root directory (fullstack-monorepo), not from inside a package subdirectory. The --filter flag tells pnpm which package to install the dependency into.
Help me debug a pnpm install error
- Create a file called index.js inside packages/shared/ and paste the following code:
import * as z from "zod";
// Define the shape of a task for validation
export const TaskSchema = z.object({
id: z.string().optional(),
title: z.string().min(1).max(100),
status: z.enum(["todo", "in-progress", "done"]),
priority: z.number().int().min(1).max(5),
});
// Shared API configuration for both frontend and backend
export const API_CONFIG = {
PORT: 3001,
BASE_URL: "http://localhost:3001",
};
// Shared database connection settings
export const DB_CONFIG = {
connectionString: "postgresql://postgres:postgres@localhost:5432/tasks",
};
What does this code do?
TaskSchema defines the exact shape of a task using Zod. Every field has constraints: title must be between 1 and 100 characters, status must be one of three fixed values, and priority must be an integer from 1 to 5.
API_CONFIG and DB_CONFIG centralize configuration so both the backend and frontend reference the same port and connection string. Changing a value here changes it everywhere.
Adjust the connection string to match your setup
The default connection string assumes a PostgreSQL username of postgres and password of postgres. If your local PostgreSQL uses a different password, update the connectionString value before continuing.
- Save packages/shared/index.js.
The shared package now has code, but nothing imports it yet. You will confirm it works when the backend imports from it shortly.
- Open packages/shared/package.json in VS Code.
- Find the "type": "module" line.
- Add the following line directly after it:
"exports": "./index.js",
What does the exports field do?
The exports field tells Node.js which file to load when another package imports @monorepo/shared. Without it, Node.js would fall back to looking for an index.js file automatically. Setting it explicitly is the recommended practice for ESM packages.
- Save packages/shared/package.json.
✔️ Awesome, I've got everything!
Great. Make sure both packages/shared/index.js and packages/shared/package.json are saved.
ⓧ I'd like to double check the full code
Here is the complete packages/shared/index.js:
import * as z from "zod";
export const TaskSchema = z.object({
id: z.string().optional(),
title: z.string().min(1).max(100),
status: z.enum(["todo", "in-progress", "done"]),
priority: z.number().int().min(1).max(5),
});
export const API_CONFIG = {
PORT: 3001,
BASE_URL: "http://localhost:3001",
};
export const DB_CONFIG = {
connectionString: "postgresql://postgres:postgres@localhost:5432/tasks",
};
And the updated packages/shared/package.json (pnpm added the dependencies block when you ran the install command):
{
"name": "@monorepo/shared",
"version": "1.0.0",
"type": "module",
"exports": "./index.js",
"dependencies": {
"zod": "^4.4.3"
}
}
Create the PostgreSQL database and write the backend
The backend needs a PostgreSQL database to persist tasks. You will create the database first, then write the code that connects to it.
- Switch to DBeaver.
- In the Database Navigator panel on the left, right-click your local PostgreSQL connection.
- Select Create, then Database.
- Enter tasks as the database name.
- Click OK to create the database.
You should now see a tasks database listed under your PostgreSQL connection in the Database Navigator.
Don't see your PostgreSQL connection?
Make sure PostgreSQL is running on your machine. If you have not connected DBeaver to your local PostgreSQL before, click New Database Connection in the toolbar, select PostgreSQL, and enter your local credentials.
Help me connect DBeaver to my local PostgreSQL
- Install Express and pg in the backend package by running this command from the workspace root:
pnpm add express pg --filter @monorepo/backend
You should see pnpm resolve both packages and update the lockfile.
- Create a file called db.js inside packages/backend/ and paste the following code:
import { Pool } from "pg";
import { DB_CONFIG } from "@monorepo/shared";
// Create a connection pool using the shared config
const pool = new Pool({
connectionString: DB_CONFIG.connectionString,
});
// Create the tasks table if it does not exist yet
export async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority INTEGER NOT NULL
)
`);
}
export default pool;
What does this code do?
Pool from the pg library manages a pool of reusable PostgreSQL connections. Instead of opening and closing a new connection for every query, the pool hands out existing connections and recycles them.
initDb() runs a CREATE TABLE IF NOT EXISTS query so the table is created automatically on server startup. This means you never need to run a separate migration script.
Notice the import on line 2: DB_CONFIG comes from @monorepo/shared. This is pnpm's workspace linking in action. The backend resolves the import through a symlink to the shared package you just wrote.
- Save packages/backend/db.js.
✔️ Awesome, I've got everything!
Great. Double check you saved the file before moving on.
ⓧ I'd like to double check the full code
import { Pool } from "pg";
import { DB_CONFIG } from "@monorepo/shared";
const pool = new Pool({
connectionString: DB_CONFIG.connectionString,
});
export async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority INTEGER NOT NULL
)
`);
}
export default pool;
Now you will write the Express server that uses this database layer. The file is long enough that you will build it in two parts: a working GET endpoint first, then the POST endpoint.
- Create a file called index.js inside packages/backend/ and paste the following code:
import express from "express";
import { TaskSchema, API_CONFIG } from "@monorepo/shared";
import pool, { initDb } from "./db.js";
const app = express();
// Parse incoming JSON request bodies
app.use(express.json());
// Return all tasks sorted by priority
app.get("/api/tasks", async (req, res) => {
const result = await pool.query("SELECT * FROM tasks ORDER BY priority");
res.json(result.rows);
});
// Catch-all error handler for unhandled errors
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "Internal server error" });
});
// Create the tasks table and start listening
await initDb();
app.listen(API_CONFIG.PORT, () => {
console.log(`Server running on ${API_CONFIG.BASE_URL}`);
});
What does this code do?
This creates an Express 5 app with a single GET endpoint at /api/tasks that queries PostgreSQL and returns all tasks as JSON. The await initDb() call at the top level creates the tasks table before the server starts accepting requests.
Express 5 natively catches rejected promises in async route handlers and forwards them to the error handler. This means you do not need try/catch blocks in each route.
- Save packages/backend/index.js.
Before adding the POST route, let's confirm the server starts and the workspace import resolves. Run the backend by running this command:
node packages/backend/index.js
You should see Server running on http://localhost:3001 in your terminal. This confirms that the backend successfully imported API_CONFIG from @monorepo/shared through pnpm's workspace symlink.
- Press Ctrl+C to stop the server.
Server not starting?
- If you see a connection error mentioning PostgreSQL, make sure your PostgreSQL service is running and that the connectionString in packages/shared/index.js matches your local credentials.
- If you see a module resolution error for @monorepo/shared, confirm that packages/shared/package.json has the "exports": "./index.js" line.
Help me debug my backend startup error
Now add the POST route that validates and creates tasks. This is where the shared Zod schema does its work.
- In packages/backend/index.js, find the error handler line:
// Catch-all error handler for unhandled errors
app.use((err, req, res, next) => {
- Add the following POST route directly above that error handler:
app.post("/api/tasks", async (req, res) => {
// Validate the request body and generate a unique ID
const validation = TaskSchema.safeParse({
...req.body,
id: crypto.randomUUID(),
});
// Return validation errors if the data is invalid
if (!validation.success) {
res.status(400).json({ errors: validation.error.issues });
return;
}
// Insert the validated task into PostgreSQL
const task = validation.data;
await pool.query(
"INSERT INTO tasks (id, title, status, priority) VALUES ($1, $2, $3, $4)",
[task.id, task.title, task.status, task.priority]
);
res.status(201).json(task);
});
What does this route do?
TaskSchema.safeParse() validates the incoming request body against the exact same schema you defined in the shared package. If validation fails, the route returns a 400 status with structured error messages from Zod. No invalid data ever reaches the database.
crypto.randomUUID() is a built-in Node.js global that generates a unique ID for each task. No extra package needed.
The $1, $2, $3, $4 syntax in the SQL query uses parameterized queries, which prevent SQL injection by never interpolating user input directly into the query string.
- Save packages/backend/index.js.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
Your packages/backend/index.js should have these sections in order. Scroll up and confirm each block is present:
- Three import lines at the top: express, TaskSchema and API_CONFIG from @monorepo/shared, and pool with initDb from ./db.js.
- App creation and express.json() middleware.
- GET /api/tasks route that queries PostgreSQL.
- POST /api/tasks route with TaskSchema.safeParse() validation and pool.query() insert.
- Error handler middleware.
- await initDb() and app.listen() at the bottom.
Add the dev script and test the API
The backend code is complete. Now you need a convenient way to run it and then put it through its paces with real requests.
- Open packages/backend/package.json.
- Find the "type": "module" line.
- Add the following scripts block directly after it:
"scripts": {
"dev": "node index.js"
},
- Save packages/backend/package.json.
- Start the backend by running this command from the workspace root:
pnpm --filter @monorepo/backend dev
You should see Server running on http://localhost:3001 in your terminal. The server is now occupying this terminal window.
- Open a second terminal window for running test commands. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) in VS Code, type Terminal: Create New Terminal, and press Enter.
First, confirm the GET endpoint returns an empty task list. Run this command in your new terminal:
curl http://localhost:3001/api/tasks
You should see [] printed. The database has no tasks yet, so the response is an empty array.
Now test Zod's validation. Before you run the next command, do you think sending an empty title will succeed or fail?
- Send a POST request with an empty title by running:
curl -X POST http://localhost:3001/api/tasks -H "Content-Type: application/json" -d '{"title":""}'
You should see a 400 response with Zod validation errors. The request body is missing required fields (status and priority), and title is empty. The shared schema caught all three issues.
- Now send a valid task by running:
curl -X POST http://localhost:3001/api/tasks -H "Content-Type: application/json" -d '{"title":"Build monorepo","status":"in-progress","priority":3}'
You should see a 201 response with the full task object, including a generated id field.
- Confirm the task persisted by running the GET request again:
curl http://localhost:3001/api/tasks
You should see an array containing the task you just created.
- Switch to DBeaver and open the tasks table inside your tasks database. You should see one row with the data you just submitted.
What just happened?
Three packages worked together for the first time. The backend imported TaskSchema from @monorepo/shared through pnpm's workspace symlink. Zod validated the request body at runtime. PostgreSQL persisted the result.
This proves that workspace linking is not just metadata in a lockfile. It delivers real, working cross-package imports at runtime.
Getting a connection error or empty response?
- Make sure the server is still running in your first terminal. If it crashed, check the error message and restart it.
- If curl is not available on your system, paste http://localhost:3001/api/tasks into your browser address bar to test the GET endpoint.
- On Windows, single quotes around the JSON body may not work in PowerShell. Replace the single quotes with escaped double quotes: -d "{\"title\":\"Build monorepo\",\"status\":\"in-progress\",\"priority\":3}".
Help me debug my Express backend
The shared validation package and the backend are both working. Next up, you will trace pnpm's internal linking architecture and discover what happens when a package tries to access a dependency it never declared.
Trace pnpm's Symlink Architecture and Hit the Phantom Dependency Wall
Your Express backend is running. It validates requests using shared Zod schemas and persists tasks in PostgreSQL. The workspace:* linking works at runtime.
But you have not yet looked underneath node_modules to see how pnpm actually wires everything together. This step forces you to trace pnpm's three-layer linking architecture and then trigger a failure that reveals why strict isolation matters.
In this step, get ready to:
- Inspect the backend's node_modules and trace symlinks through the .pnpm virtual store.
- Trigger a phantom dependency failure that would silently succeed under npm.
- Trace hard links from the virtual store to pnpm's global content-addressable store.
Inspect the backend's node_modules
pnpm gives each package a node_modules that contains only symlinks to its explicitly declared dependencies. Transitive dependencies live deeper in the .pnpm virtual store, invisible to packages that did not declare them.
- Switch to the terminal where the backend is running.
- Stop the backend by pressing Ctrl+C.
- List the contents of the backend's node_modules by running:
ls packages/backend/node_modules/
You should see entries for express, pg, and the @monorepo scope folder containing shared. You will NOT see transitive dependencies like pg-protocol, pg-pool, accepts, or content-type.
Where are the transitive dependencies?
Those packages ARE installed on disk, but they live inside the .pnpm virtual store at the workspace root's node_modules/.pnpm. Only packages that explicitly list a dependency in their own package.json get a symlink in their node_modules.
- Follow the pg symlink to see where it actually points by running:
ls -l packages/backend/node_modules/pg
The output shows that pg is a symlink pointing into node_modules/.pnpm/pg@8.23.0/node_modules/pg. This is pg's location inside the virtual store.
- Now look inside pg's virtual store directory to see what pg itself can access by running:
ls node_modules/.pnpm/pg@8.23.0/node_modules/
Inside this nested node_modules, you can see symlinks to pg's declared dependencies like pg-protocol, pg-pool, and pg-types.
pg can access these packages because they are listed in pg's own package.json. Your backend cannot access them because they are not in the backend's package.json.
How does the three-layer structure work?
Layer 1: each package's node_modules contains symlinks only to its declared dependencies. Layer 2: those symlinks point to the .pnpm virtual store, where each package version has its OWN nested node_modules containing symlinks to ITS dependencies.
Layer 3: the actual files in the virtual store are hard links to pnpm's global content-addressable store. You will trace this third layer in the final substep.
Trigger the phantom dependency wall
A phantom dependency is a package your code imports without declaring it in package.json. Under npm's flat layout, this works silently because all transitive dependencies are hoisted to the top-level node_modules.
You just saw that pg-protocol lives inside pg's virtual store directory, not in the backend's node_modules. Let's try importing it and see what pnpm does.
- In VS Code, open packages/backend/index.js.
- Find the import block at the top of the file:
import express from "express";
import { TaskSchema, API_CONFIG } from "@monorepo/shared";
import pool, { initDb } from "./db.js";
- Add this phantom import on a new line directly below the existing imports:
import pgProtocol from "pg-protocol";
What is this import testing?
pg-protocol is a real package that pg depends on. It IS installed on disk inside the virtual store. Under npm's flat hoisted layout, this import would resolve because pg-protocol would be hoisted right next to pg in a single flat node_modules.
Under pnpm's strict layout, only the backend's declared dependencies are accessible. The backend never declared pg-protocol in its package.json.
- Save packages/backend/index.js.
Before you run the backend, predict what will happen. Will Node.js find pg-protocol, or will the import fail?
- Try starting the backend by running:
pnpm --filter @monorepo/backend dev
The command fails with ERR_MODULE_NOT_FOUND. Even though pg-protocol is physically installed on disk (you saw it inside pg's virtual store directory), Node.js cannot resolve it from the backend's node_modules.
Why this error is a feature
If pg-protocol ever gets removed as a dependency of pg in a future version, code relying on the phantom import would break silently. pnpm surfaces this hidden coupling as an error now, before it becomes a production incident.
- In packages/backend/index.js, delete the import pgProtocol from "pg-protocol"; line you just added.
- Save the file.
- Restart the backend by running:
pnpm --filter @monorepo/backend dev
The backend starts successfully again. The phantom import was the only problem.
Backend still failing after removing the import?
- Make sure you deleted the entire import pgProtocol from "pg-protocol"; line, not just part of it.
- Check that you saved packages/backend/index.js after removing the line. Unsaved changes will not take effect.
- Confirm your file starts with the three original imports: express, @monorepo/shared, and ./db.js.
- Help me debug why my backend won't start after removing the phantom import.
Trace the content-addressable store
You have seen the first two layers: symlinks from each package's node_modules into the .pnpm virtual store, and the virtual store's nested node_modules enforcing per-package isolation. The third layer connects the virtual store to pnpm's global content-addressable store.
- Open a new terminal tab in VS Code (the backend is running in the current one).
- Find your global content-addressable store location by running:
pnpm store path
pnpm prints a path to the store directory on your machine. On macOS this is typically under ~/Library/pnpm/store/v11. On Windows it is under AppData\Local\pnpm\store\v11.
How does the content-addressable store save disk space?
The files inside node_modules/.pnpm/pg@8.23.0/node_modules/pg/ are hard links to files in this global store. If you have ten projects using pg@8.23.0, they all share a single copy of each file on disk.
In pnpm 11, the store index is a single SQLite database (index.db inside the store directory). This replaced the millions of JSON index files used in earlier pnpm versions.
Windows-specific linking behavior
On Windows, pnpm uses junctions instead of symlinks when Developer Mode is off.
The content-addressable store must also be on the same disk as your project for hard linking to work. If they are on different disks, pnpm copies files instead of linking, losing the disk space benefit.
You have now traced pnpm's full linking architecture from the project level down to the global store. Every package resolves only what it explicitly declares, every file on disk exists as a single copy in the content-addressable store, and phantom dependencies fail loudly instead of hiding until production.
✔️ Awesome, I've got everything!
Great. Confirm you removed the phantom import line and that your backend is running successfully in the other terminal tab.
ⓧ I'd like to double check the full code
No files changed permanently in this step. Your packages/backend/index.js should look exactly like it did at the end of Step 2. Confirm the import block at the top has only these three lines:
import express from "express";
import { TaskSchema, API_CONFIG } from "@monorepo/shared";
import pool, { initDb } from "./db.js";
There should be no import pgProtocol line anywhere in the file. If there is, delete it and save.
You have seen pnpm's strict isolation in action. Next, you will build the React frontend and prove that the same shared package works in a browser context through Vite's dependency pre-bundling.
Build the React Frontend and Connect to the Backend
In the last step, you traced pnpm's three-layer linking architecture and hit the phantom dependency wall firsthand. The workspace is proven server-side, but you have not yet consumed the shared package from a browser context.
Adding a React frontend demonstrates that the same @monorepo/shared import works identically in a Vite-bundled browser app. By the end of this step, both frontend and backend will run concurrently, sharing validated types from a single source of truth while tasks persist in PostgreSQL.
In this step, get ready to:
- Scaffold a React 19 frontend with Vite 8 and wire it into the workspace.
- Build a task manager UI that validates input using the shared Zod schema before submitting to the backend API.
- Run both packages concurrently and verify full-stack persistence in PostgreSQL.
Scaffold the React frontend with Vite
Vite 8 ships a scaffold command that generates a ready-to-run React project. You will scaffold into the existing packages/frontend/ directory, then update the generated files to match the workspace conventions.
- Stop the running backend by pressing Ctrl+C in the terminal.
- Navigate into the packages/ directory by running this command:
cd packages/
- Scaffold the React frontend by running:
pnpm create vite frontend --template react
What about the existing directory?
The packages/frontend/ directory already has a package.json from Step 1. Vite will detect the non-empty directory and ask how to proceed. Choose the option to overwrite existing files so the scaffold can generate its full set of starter files.
- After the scaffold finishes, navigate back to the workspace root by running:
cd ..
The scaffold generated a package.json with default names and direct version numbers. You need to update it to match the workspace conventions: the scoped name, the workspace: protocol for the shared package, and catalog: references for React.
- Open packages/frontend/package.json in VS Code and replace its entire contents with:
{
"name": "@monorepo/frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@monorepo/shared": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.0",
"vite": "^8.2.1"
}
}
What changed from the scaffold version?
- The name is set to @monorepo/frontend so pnpm recognizes it as a workspace package.
- The @monorepo/shared dependency uses workspace:* to link to the local shared package instead of pulling from a registry.
- React and react-dom use catalog: so their versions are governed by the workspace-level catalog in pnpm-workspace.yaml.
- Save packages/frontend/package.json.
- Install all dependencies from the workspace root by running:
pnpm install
- Confirm the shared package is linked by running:
pnpm ls --filter @monorepo/frontend
You should see @monorepo/shared listed with a link: prefix pointing to ../shared. This confirms pnpm is symlinking the local package rather than downloading it from a registry.
Not seeing the link prefix?
- Make sure you ran pnpm install from the workspace root (the fullstack-monorepo directory), not from inside packages/frontend/.
- Check that the name field in packages/shared/package.json is exactly @monorepo/shared. A mismatch means pnpm cannot resolve the workspace reference.
- Ask NextWork for help debugging your workspace linking.
Configure the Vite proxy and build the task manager UI
The scaffold generated default config and starter components. You need a development proxy to forward /api requests to the Express backend, and a task manager component that imports the shared Zod schema.
- Open packages/frontend/vite.config.js and replace its contents with the following proxy configuration:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Forward /api requests to the Express backend during development
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
});
Why a Vite proxy?
The React dev server runs on port 5173 while the Express backend runs on port 3001. Without a proxy, browser requests from the frontend to /api/tasks would hit localhost:5173 and fail. The proxy transparently forwards any request starting with /api to the backend, so the frontend code can use relative URLs like fetch("/api/tasks").
- Save vite.config.js.
- Open packages/frontend/index.html and replace its contents with:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Task Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
What changed from the scaffold?
The scaffold version includes a Vite favicon link and the title Vite + React. This replacement strips the branding and sets the page title to Task Manager.
- Save index.html.
- Verify the scaffold works by starting the frontend dev server:
pnpm --filter @monorepo/frontend dev
Open http://localhost:5173 in your browser. You should see the default Vite + React starter page with the browser tab titled Task Manager.
- Stop the frontend dev server by pressing Ctrl+C.
Now you will replace the scaffold starter code with the actual task manager component. The component imports TaskSchema from @monorepo/shared to validate form data on the client before sending it to the backend.
- Open packages/frontend/src/main.jsx and replace its contents with:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);
What changed from the scaffold?
The scaffold version imports index.css. This replacement removes that import because the task manager uses inline styles instead.
- Save main.jsx.
- Open packages/frontend/src/App.jsx and delete all of the existing scaffold code.
- Paste the following code as the first section of App.jsx (the imports, state, and data fetching):
import { useState, useEffect } from "react";
import { TaskSchema } from "@monorepo/shared";
function App() {
// State for the task list and form fields
const [tasks, setTasks] = useState([]);
const [title, setTitle] = useState("");
const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState(1);
const [error, setError] = useState(null);
// Fetch existing tasks from the backend when the component mounts
useEffect(() => {
fetch("/api/tasks")
.then((res) => res.json())
.then(setTasks);
}, []);
What does this code do?
- The import of TaskSchema from @monorepo/shared is the same import the backend uses. Vite resolves it through the workspace symlink and bundles it into the browser code.
- Five useState hooks track the task list, each form field, and a validation error message.
- The useEffect fetches /api/tasks on mount. The Vite proxy forwards this to http://localhost:3001/api/tasks on the Express backend.
- Directly below the useEffect block, paste the form submission handler:
// Validate client-side, then POST to the backend
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
const taskData = { title, status, priority: Number(priority) };
const validation = TaskSchema.omit({ id: true }).safeParse(taskData);
if (!validation.success) {
setError(validation.error.issues.map((i) => i.message).join(", "));
return;
}
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(taskData),
});
if (res.ok) {
const newTask = await res.json();
setTasks((prev) => [...prev, newTask]);
setTitle("");
setPriority(1);
}
};
What does this code do?
- TaskSchema.omit({ id: true }).safeParse(taskData) validates the form data using the exact same Zod schema the backend uses. The omit call removes the id field because the backend generates it.
- If validation fails, the error message is displayed to the user and no network request fires. This is the key benefit of sharing schemas across frontend and backend.
- On a successful POST, the new task is appended to the local state and the form fields reset.
- Directly below the handleSubmit function, paste the component's return statement with the form UI:
// Render the task form
return (
<div style={{ maxWidth: 600, margin: "2rem auto", fontFamily: "system-ui" }}>
<h1>Task Manager</h1>
<form onSubmit={handleSubmit} style={{ marginBottom: "2rem" }}>
<div style={{ marginBottom: "0.5rem" }}>
<input
type="text"
placeholder="Task title"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ padding: "0.5rem", width: "100%" }}
/>
</div>
<div style={{ marginBottom: "0.5rem", display: "flex", gap: "0.5rem" }}>
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="todo">To Do</option>
<option value="in-progress">In Progress</option>
<option value="done">Done</option>
</select>
What does this code do?
- The form element calls handleSubmit on submit, which triggers client-side Zod validation before any API request.
- The title input, status dropdown, and priority picker each bind to their corresponding useState values so React keeps the form in sync.
- Directly below the <select> closing tag, paste the rest of the form controls, the task list, and the closing of the component:
<input
type="number"
min="1"
max="5"
value={priority}
onChange={(e) => setPriority(e.target.value)}
style={{ width: 60, padding: "0.5rem" }}
/>
<button type="submit" style={{ padding: "0.5rem 1rem" }}>
Add Task
</button>
</div>
{error && <p style={{ color: "red" }}>{error}</p>}
</form>
<ul style={{ listStyle: "none", padding: 0 }}>
{tasks.map((task) => (
<li
key={task.id}
style={{
padding: "0.75rem",
marginBottom: "0.5rem",
border: "1px solid #ddd",
borderRadius: 4,
display: "flex",
justifyContent: "space-between",
}}
>
<span>{task.title}</span>
<span>
{task.status} | P{task.priority}
</span>
</li>
))}
</ul>
</div>
);
}
export default App;
What does this code do?
- The priority input is a number field clamped between 1 and 5, matching the Zod schema's z.number().int().min(1).max(5) constraint.
- The {error && ...} line conditionally renders the Zod validation error message in red if the client-side check fails.
- The <ul> maps over the tasks array, rendering each task's title, status, and priority as a styled list item.
- Save App.jsx.
✔️ Awesome, I've got everything!
Great. Make sure you have saved App.jsx, main.jsx, index.html, and vite.config.js.
ⓧ I'd like to double check the full code
The full App.jsx file is built from the four code blocks above. Scroll up and confirm each section is present in order:
- Block 1: imports, state declarations, and the useEffect data fetch.
- Block 2: the handleSubmit function with client-side Zod validation and the POST request.
- Block 3: the return ( statement opening with the container div, heading, form opening, title input, status select.
- Block 4: the priority input, submit button, error display, closing form tag, task list <ul>, and export default App.
For vite.config.js, confirm it has the react() plugin and a server.proxy section forwarding /api to http://localhost:3001.
For main.jsx, confirm it does NOT import any CSS file and renders <App /> inside <StrictMode>.
Seeing import errors in your editor?
- If VS Code underlines @monorepo/shared in red, confirm you ran pnpm install from the workspace root after updating package.json.
- Check that packages/shared/package.json has "exports": "./index.js". Without this field, the workspace link resolves but the import cannot find the entry point.
- Ask NextWork for help resolving the import error.
Wire up the root dev script and test the full stack
Running pnpm --filter @monorepo/backend dev in one terminal and pnpm --filter @monorepo/frontend dev in another works, but a root-level script makes it a single command.
- Open the root package.json (in the fullstack-monorepo directory, not inside packages/).
- Find this line:
"type": "module"
- Add a scripts section directly after it, so the file now looks like this:
{
"name": "fullstack-monorepo",
"private": true,
"type": "module",
"scripts": {
"dev": "pnpm --filter @monorepo/backend dev & pnpm --filter @monorepo/frontend dev"
}
}
What does this script do?
The & operator runs both filter commands as background processes so the backend and frontend start concurrently from a single pnpm dev command.
On Windows, & works in PowerShell but not in cmd.exe. If you are using cmd.exe, open two separate terminals and run each filter command individually.
- Save the root package.json.
- Start the full stack by running:
pnpm dev
You should see both the backend and frontend start up. The backend logs Server running on http://localhost:3001 and Vite prints a local URL at http://localhost:5173.
Only one server starts?
- If you are on Windows using cmd.exe, the & operator is not supported. Open a second terminal and run pnpm --filter @monorepo/frontend dev separately.
- If either server crashes on start, check that no other process is already using port 3001 or 5173.
- Ask NextWork for help getting both servers running.
Time to test the full stack. Before you do, think about this: when you submit a task with an empty title, which layer do you expect to catch the error? The frontend, the backend, or the database?
- Open http://localhost:5173 in your browser.
- You should see the Task Manager heading with a form containing a title input, a status dropdown, a priority number input, and an Add Task button.
- Type a title like Set up CI pipeline, leave the status as To Do, set the priority to 2, and click Add Task.
- The task should appear in the list below the form.
- Refresh the page.
- The task should still be there because it is persisted in PostgreSQL, not just in React state.
- Now test validation: clear the title field so it is empty and click Add Task.
A red error message appears below the form. Open your browser's Network tab in DevTools. There is no outgoing request to /api/tasks. The client-side Zod validation caught the empty title before any network request fired. Both frontend and backend use the exact same TaskSchema import from @monorepo/shared.
Why validate on both sides?
Client-side validation gives instant feedback without a network round trip. Server-side validation protects the database from requests that bypass the browser (like curl or a malicious client). Using the same schema for both means the rules stay in sync automatically.
Tasks not persisting after refresh?
- Check that your PostgreSQL server is running. The backend connects to the tasks database on startup via the connection string in @monorepo/shared.
- Open DBeaver and verify the tasks table has rows. If it is empty, the POST request may have failed silently. Check the backend terminal for error messages.
- If the form submits but the list stays empty after refresh, open the browser console and check for errors on the fetch("/api/tasks") call. A proxy misconfiguration in vite.config.js could cause it.
- Ask NextWork for help debugging persistence.
The monorepo is fully wired. Frontend, backend, and shared package all consume the same dependency graph, linked through pnpm's symlink architecture. Next up, you will centralize the dependency versions so a single catalog governs every package's version ranges.
Centralize Dependency Versions with Catalogs
Your full-stack task manager is running with shared validation, a React frontend, and an Express backend backed by PostgreSQL. Every consumer imports from the same @monorepo/shared package.
There is one architectural gap left to close. Your package.json files reference dependency versions using the catalog: protocol, but the catalog itself has not been defined yet. Without it, there is no single source of truth for version ranges, and nothing prevents version drift across the workspace.
In this step, get ready to:
- Define a centralized catalog section in pnpm-workspace.yaml that governs all shared dependency versions.
- Understand how pnpm resolves peer dependencies from the workspace root.
- Verify that every workspace package resolves each dependency to a single version.
Define the dependency catalog
When a package.json lists a dependency as "zod": "catalog:", pnpm looks up the actual version range from a catalog section in pnpm-workspace.yaml. This means upgrading a dependency across the entire workspace is a single-line change in one file.
- Open pnpm-workspace.yaml in VS Code.
- Add the following catalog section below the existing packages configuration:
catalog:
zod: ^4.4.3
react: ^19.2.8
react-dom: ^19.2.8
express: ^5.2.1
pg: ^8.23.0
What does this code do?
Each line maps a package name to a version range. When any package.json in the workspace lists "catalog:" for that dependency, pnpm substitutes the range from this section.
When you publish or pack a package, pnpm replaces catalog: with the actual version range automatically. Consumers of your published package see a normal version string, not catalog:.
- Save pnpm-workspace.yaml.
- Resolve the catalog entries by running this command from the workspace root:
pnpm install
You should see pnpm resolve dependencies and update pnpm-lock.yaml. Every catalog: reference now points to the version range you just defined.
Seeing a catalog resolution error?
- Make sure the catalog key is at the top level of pnpm-workspace.yaml, not nested under packages. YAML indentation matters.
- Confirm each package name in the catalog exactly matches the name used in your package.json files. For example, react-dom is hyphenated, not camelCase.
- Check that you saved the file before running pnpm install.
Why is my pnpm catalog not resolving?
✔️ Awesome, I've got everything!
Great. Make sure you have saved pnpm-workspace.yaml.
ⓧ I'd like to double check the full code
Here is the complete pnpm-workspace.yaml file. Make sure yours matches exactly.
packages:
- 'packages/*'
catalog:
zod: ^4.4.3
react: ^19.2.8
react-dom: ^19.2.8
express: ^5.2.1
pg: ^8.23.0
Explore peer dependency resolution
You might have noticed that react and react-dom are only direct dependencies of the frontend package. Libraries like @vitejs/plugin-react declare React as a peer dependency, yet pnpm never warned you about unmet peers. That is not an accident.
What is resolvePeersFromWorkspaceRoot?
In pnpm 11, the resolvePeersFromWorkspaceRoot setting defaults to true. This means peer dependencies declared by any workspace package are resolved from the root project's dependencies.
Because your workspace shares a single pnpm-lock.yaml and a single root node_modules, pnpm can see every dependency across all packages. When a library peer-depends on React, pnpm finds it in the frontend's dependency tree and considers it satisfied. No manual hoisting, no duplicate installations.
Verify singleton versions across the workspace
The catalog and the shared lockfile work together to ensure each dependency resolves to exactly one version workspace-wide. The pnpm-lock.yaml at the workspace root consolidates all resolution into a single file. Every workspace:* reference in the lockfile shows how pnpm tracks cross-package relationships.
Before you run the next command, think: how many packages in your workspace directly depend on Zod? What version do you expect to see?
- Verify that every workspace consumer resolves the same Zod version by running this command:
pnpm ls zod --recursive --depth 0
You should see @monorepo/shared listing Zod at the version from your catalog entry. Only the shared package depends on Zod directly. The backend and frontend consume Zod indirectly through @monorepo/shared, so they do not appear in this listing. The single catalog entry governs the version for every consumer.
Why does a single lockfile matter?
Without a shared lockfile, each package would resolve dependencies independently. Two packages could pick different patch versions of the same library, leading to duplicate copies on disk and subtle runtime mismatches.
With sharedWorkspaceLockfile set to true (the pnpm 11 default), every workspace package's dependencies are resolved together in one pass. The result is a single version of each dependency in the virtual store, shared across all packages via hard links.
Seeing multiple versions of the same dependency?
- Check that every package.json that references the dependency uses "catalog:" instead of a hardcoded version range. A hardcoded range bypasses the catalog and can resolve to a different version.
- Run pnpm install again from the workspace root to re-resolve all dependencies after any package.json changes.
Why am I seeing duplicate dependency versions in my pnpm workspace?
Your dependency versions are now governed by a single catalog, and every workspace package resolves to the same version of each shared library. Next up, you will dig into pnpm's hoisting controls and see what happens when you tighten isolation even further.
Secret mission
Configure Hoisting Controls for Strict Isolation
pnpm's default semistrict mode lets dependencies quietly access packages they never declared. You will disable hoisting entirely to expose these phantom dependencies, then configure selective hoisting that keeps the project running while maintaining strict isolation where it matters.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a PostgreSQL database, so there are no ongoing cloud costs.
Resources you used:
- The fullstack-monorepo project directory on your Desktop, containing the workspace root, shared package, backend, and frontend.
- A PostgreSQL database named tasks with a tasks table storing persisted task rows.
- Packages cached in pnpm's global content-addressable store.
Keep everything running
No action needed. Choose this if you are still actively building on the monorepo or plan to extend it soon.
- The project directory stays on your Desktop. You can restart the dev servers any time with pnpm dev from the workspace root.
- The PostgreSQL tasks database retains all your persisted task data.
- Packages in pnpm's content-addressable store remain cached for fast installs in future projects.
Pause - I'll come back to this later
Stop the running dev servers to free up terminal sessions and memory. Everything else stays in place and picks up where you left off.
- Press Ctrl+C in any terminal running the dev servers to stop them.
- The PostgreSQL tasks database retains all data. Your tasks survive between sessions.
- When you are ready to resume, navigate to the fullstack-monorepo directory and run pnpm dev to start both servers again.
Delete - I don't want to use this again
Remove all project files, the database, and orphaned store packages. This is a clean slate.
- Stop any running dev servers by pressing Ctrl+C in the terminal.
- Delete the project directory by running this command on macOS/Linux:
rm -rf ~/Desktop/fullstack-monorepo
On Windows, run this in PowerShell instead:
Remove-Item -Recurse -Force $env:USERPROFILE\Desktop\fullstack-monorepo
- Drop the tasks database in PostgreSQL. Open a terminal and run:
psql -U postgres -c "DROP DATABASE IF EXISTS tasks;"
You can also drop the database using DBeaver by right-clicking the tasks database in the sidebar and selecting Delete.
- Clean up orphaned packages from pnpm's content-addressable store by running:
pnpm store prune
This removes any cached packages that are no longer referenced by any project on your machine.
Nice Work!
Nice Work!
You just built a full-stack monorepo from scratch and traced every layer of the dependency architecture underneath it. From workspace linking to the content-addressable store, you now understand what pnpm is actually doing when it installs your packages.
You've learned how to:
- Build a three-package pnpm workspace monorepo with a shared Zod validation library consumed by both a React 19 frontend and an Express 5 backend backed by PostgreSQL.
- Trace pnpm's three-layer linking architecture and trigger a phantom dependency failure using pg-protocol, understanding why pnpm's strict isolation catches bugs that npm's flat hoisting silently hides.
- Centralize dependency versions with the catalog: protocol in pnpm-workspace.yaml, ensuring a single source of truth for shared dependency versions across the workspace.
- Secret Mission: Configure hoisting controls to compare semistrict vs fully strict node_modules and selectively unblock tooling that relies on phantom dependency access.
Ready to quiz yourself?