Your Highlights
Monorepos with PNPM Workspaces

PNPM is not just a modern package manager but also a great tool for managing lean monorepos. Learn how to set up and use PNPM workspaces from scratch including TypeScript Project References for building and typechecking incrementally.
In this article, we will explore how to set up and use PNPM workspaces to manage simple monorepos. PNPM is a fast, disk space-efficient package manager that supports monorepo structures out of the box. It has its limitations, but it is a great starting point and can be used as a foundation for more complex setups.
So, if you are looking for a simple way to manage your monorepo, PNPM workspaces are a great choice. They allow you to group multiple packages together, share dependencies, and run scripts across all packages in the workspace. And even if you already know that you will need a more capable monorepo tool like Nx, PNPM Workspaces can still be a valuable part of your toolchain and you can use Nx on top of PNPM Workspaces.
PNPM as a Package Manager
Leaving the monorepos aside for a moment, PNPM is a package manager that is known for its speed and disk space efficiency. It uses a unique approach to store packages in a global store and creates symlinks to them in the project directories. This means that multiple projects can share the same package version without duplicating it on disk, which saves space and speeds up installations. Similarly to Yarn, PNPM has parallelized installations, which makes it faster than npm in most cases anyway.
Installation
To install PNPM, you can use npm or Yarn:
npm install --global corepack@latest
corepack enable pnpm
corepack use pnpm@latest-10
Note: Corepack is a tool that comes with Node.js and allows you to manage package managers like PNPM, Yarn, and others. It is recommended to use Corepack to ensure that you are using the correct version of PNPM. The specific version of PNPM used in this book is 10.x, but you can use any version that is compatible with your project.
Basic Usage
On the surface level, PNPM commands look very similar to npm commands, so if you are already familiar with npm, you will feel right at home. Here are some common PNPM commands and their npm equivalents:
Task | pnpm Command | npm Command |
---|---|---|
Install dependencies | pnpm install | npm install |
Install a package | pnpm add <pkg> | npm install <pkg> |
Install dev dependency | pnpm add -D <pkg> | npm install -D <pkg> |
Remove a package | pnpm remove <pkg> | npm uninstall <pkg> |
Update dependencies | pnpm update | npm update |
Run a script defined in pkg | pnpm run <script> | npm run <script> |
The real difference comes when you start exploring how PNPM works under the hood, especially in the context of symlinks and the global store.
Symlinks and Global Store
In order to achieve the disk space efficiency, PNPM uses a global store to keep all downloaded packages. When you install a package, PNPM creates a symlink in your project directory that points to the package in the global store. This means that if you have multiple projects that depend on the exact same package version, they will all point to the same location on disk, saving space.
The actual folders in the projects node_modules directory are symlinks to the global store. You can also imagine a symlink as a desktop shortcut that points to a file or folder in another location. This way, PNPM can avoid installing the same package multiple times, no matter which project it is used in. So, when you have many projects on your local machine, PNPM can be help you save a lot of disk space compared to npm or Yarn. But it is not just useful when you have many projects on your local machine, it is also useful when you have many projects in a monorepo as each project has a separate node_modules directory, but they can all share the same packages from the global store and when you add a dependecy to one of your projects inside the monorepo, PNPM will symlink it as well.
How PNPM constructs the node_modules directory
When you install a package with PNPM, it creates a node_modules
directory in your project. The first thing it does is to create a .pnpm
directory inside the node_modules
directory where it creates a nested structure for all peer dependencies and dependencies of the packages you install. Each package is stored in its own directory inside .pnpm
, and the package name and version are used to create the directory name. For example, if you install foo@1.0.0
which depends on bar@1.0.0
, PNPM will create the following structure:
Notice, how the bar
package is symlinked from the foo
package. This is how PNPM resolves dependencies and ensures that packages can share their dependencies without duplicating them on disk. However, each dependency has a self reference to its global store location, which is why you see the <store>
placeholder in the example above. This is the actual symlink which is the hard linke to the content addressable store.
Next, direct dependencies are handled. foo is going to be symlinked into the root node_modules folder because foo is a dependency of the project:
In order to find the global store path, you can run the following command:
pnpm store path
This will output the path to the global store, which is usually located in your home directory under .pnpm-store
. But you will not find meaningful package names there, as the packages are stored in a content-addressable format which is a hash value based on the content of the packages. The actual package names are stored in the .pnpm
directory inside the node_modules
directory of your project.
PNPM Workspaces
Workspace is a feature of PNPM that allows you to manage multiple packages in a single repository, giving you the ability to create a monorepo with PNPM. Workspaces are defined in the pnpm-workspace.yaml
file, which is located at the root of your repository. This file specifies which packages are part of the workspace and allows you to run commands across all packages in the workspace. Whenever you add a new dependency to a package in the workspace, PNPM will automatically link it to the global store and create the necessary symlinks in the node_modules
directory of that package.
Setting Up a PNPM Workspace
First, let’s create a new directory and initialize a PNPM workspace:
mkdir my-pnpm-workspace
cd my-pnpm-workspace
pnpm init
If you want to follow along, you can also check out the complete example repository that we’ll be building throughout this article:
Next, create a pnpm-workspace.yaml
file in the root of your workspace:
packages:
- 'packages/*'
Note: The workspace file is yaml formatted, so make sure to use the correct indentation and syntax. You can specify multiple patterns to include different directories in your workspace. Right now, we are only including packages in the
packages
directory, but you can add more directories as needed.
Now, create a packages
directory and add some packages to it:
mkdir packages
cd packages
mkdir package-a package-b
cd package-a
pnpm init
cd ../package-b
pnpm init
Now you have a basic PNPM workspace with two packages: package-a
and package-b
. You can add dependencies to these packages just like you would in a regular PNPM project. For example, to add a dependency to package-a
, you can run:
cd packages/package-a
pnpm add -D typescript
This will add the typescript
package to package-a
and create the necessary symlinks in the node_modules
directory. You can also add dependencies to package-b
in the same way.
cd packages/package-b
pnpm add -D typescript
Notice how each package has its own
node_modules
directory andpackage.json
file, but they can all share the same packages from the global store as PNPM uses symlinks and hard links to avoid duplicating packages on disk. This is one of the key benefits of using PNPM workspaces.
Let’s have a look at the package.json
file of package-a
:
{
"name": "package-a",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"devDependencies": {
"typescript": "^5.8.3"
}
}
You can see that package-a
has a dev dependency on typescript
, which is installed in the global store and symlinked in the node_modules
directory of package-a
. Also notice the version number, which is automatically set to 1.0.0
when you run pnpm init -y
. You can change it later if you want to publish the package to a registry, but if you are using the package only within the workspace, you can leave it as is and do not have to worry about semantic versioning.
At this moment, the PNPM workspace is pretty much empty and does not have any code in it. This is the current structure of the workspace:
In order to change this, we are going to add TypeScript to our workspace and create two simple TypeScript libraries to our packages.
Adding TypeScript to the Workspace
Let’s start by adding TypeScript to the workspace in the root directory:
pnpm add -D typescript --workspace-root
Notice how you always have to use the
--workspace-root
flag when you want to add a dependency to the root of the workspace. This is because PNPM workspaces allow you to have a separatenode_modules
directory for each package, but you can also have a sharednode_modules
directory in the root of the workspace.
Next, we will add a base configuration file which will later be extended by each package such that all our packages have a consistent TypeScript configuration. Create a tsconfig.base.json
file in the root of the workspace:
{
"compileOnSave": false,
"compilerOptions": {
// Output
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false,
// Module resolution
"module": "NodeNext",
"target": "ES2022",
"moduleResolution": "NodeNext",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
// Strictness
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
// Source mapping
"sourceMap": true,
"inlineSources": true,
// Project references
"composite": true,
"skipLibCheck": true,
},
"exclude": ["node_modules", "dist", "build"]
}
Then, we also need to add a tsconfig.json
file to be able to build all the projects in the workspace at once. This file will extend the base configuration file and include all packages in the workspace:
{
"extends": "./tsconfig.base.json",
"files": [],
"references": [
{ "path": "./packages/package-a" },
{ "path": "./packages/package-b" }
]
}
This configuration file tells TypeScript to use the base configuration file and includes all packages in the workspace as project references. This way, you can build all packages in the workspace at once by running pnpm tsc --build
in the root directory.
But what even is a TS Project Reference? For this you first need to understand that tsc
is the TypeScript compiler, which can compile TypeScript code to JavaScript. On the one hand it has the functionality of a classic compiler as it is able to lex, parse an AST, transform and emit code. But on the other hand it also is not just a compiler, but also a build orchestration tool. In other words, it is able to understand the dependencies between different TypeScript files and packages, and can build them in the correct order. This is what project references are all about. They allow you to define dependencies between different TypeScript projects (in our case, packages) and let the TypeScript compiler know how to build them.
A reference can be defined in any tsconfig.json
file, and it looks like this:
{
"compilerOptions": {
"composite": true
},
"references": [
{ "path": "../package-b" }
]
}
This tells the TypeScript compiler that the current project depends on package-b
, and it should be built before the current project. The composite
option is required for project references to work, as it enables the TypeScript compiler to generate the necessary metadata for the project.
Project references are both powerful as they allow you to make typechecking incremental, but they are also necessary for intellisense in your IDE, as it allows the TypeScript compiler to understand the dependencies between different projects and provide accurate type information. If you import something from another internal package in your workspace, the project reference is needed to resolve a Go to Definition or Find All References in your IDE or editor correctly.
Alright, now that we know what project references are, let’s initialize the two packages in our workspace with TypeScript.
Initializing TypeScript in the Packages
First, let’s initialize package-a
with TypeScript:
cd packages/package-a
pnpm add -D typescript
Instead of initializing TypeScript with tsc --init
, we will create a tsconfig.json
file manually, which will extend the base configuration file we created earlier. Create a tsconfig.json
file in the package-a
directory:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
},
"include": ["src/**/*"],
"references": []
}
Now, let’s create a simple TypeScript file in the src
directory of package-a
. Create a src
directory and add an index.ts
file:
mkdir src
touch src/index.ts
This is a very special TypeScript file, as it will be the entry point of our package and is often referred to as a barrel file. It will only have Export Declarations, which means it will only export identifiers from other files contained in the same package. This is a common pattern in TypeScript projects, as it allows you to keep some of your code private while still explicitly exporting the public API of your package.
Add the following code to src/index.ts
:
export { foo } from './lib/hello-world.js';
// Note: The `.js` extension is necessary here because we are using ESNext modules
// and the TypeScript compiler will emit the `.js` files in the `dist` directory.
But foo
is not defined yet, so we need to create the lib
directory and add the hello-world.ts
file:
mkdir lib
touch lib/hello-world.ts
Now, add the following code to lib/hello-world.ts
:
export const foo = () => {
console.log('Hello, World!');
};
Now we have a super simple TypeScript package that exports a single function foo
which logs “Hello, World!” to the console.
Let’s now repeat the same steps for package-b
. First, navigate to the package-b
directory and initialize TypeScript:
cd ../package-b
pnpm add -D typescript
Then create a tsconfig.json
file in the package-b
directory:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
},
"include": ["src/**/*"],
"references": [
{ "path": "../package-a" }
]
}
Notice how we added a reference to package-a
in the references
array. This tells TypeScript that package-b
depends on package-a
, and it will be built after package-a
.
But we also have to add the dependency in the package.json
file of package-b
. Open the package.json
file in the package-b
directory and add the following dependency:
{
"name": "package-b",
"type": "module",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"package-a": "workspace:*"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}
This tells PNPM that package-b
depends on package-a
, and it will use the version of package-a
that is defined in the workspace. The workspace:*
syntax is a special PNPM Workspace feature that allows you to reference packages in the same workspace without specifying a version number.
This way you don’t have to worry about semantic versioning, but be wary that this only works when package-a
is not published to a registry.
Now, let’s create a simple TypeScript file in the src
directory of package-b
. Create a src
directory and add an index.ts
file:
mkdir src
touch src/index.ts
Add the following code to src/index.ts
:
import { foo } from 'package-a';
foo();
Okay, now we are done with the setup and can try to build our packages. Before we can build our packages, we need to make sure that all the node_modules for each package are installed correctly. T
his is also important even when you only have internal depedencies, as PNPM will create the necessary symlinks in the node_modules
directory of each package.
For this, go back to the root of the workspace and run pnpm install
. Then, run the TypeScript compiler with the --build
flag, such that it will orchestrate the build of all packages in the right order:
pnpm install
pnpm tsc --build
This will first build package-a
and then package-b
in the right order. Note that, the --build
flag requires you to use project references, but project references can have other benefits as well.
If you are facing slow intellisense in a large monorepo, you could use project references to reduce the number of files the TS language server has to check for each file, based on the project references.
Conclusion & Outlook
PNPM is not just a powerful package manager, but also great for monorepos. We have learned how to use TS Project References to enable tsc --build
and therefore have set up a fully functional TypeScript monorepo with PNPM workspaces.
On the one hand, PNPM Workspaces are lean and easy to use, but on the other hand, they lack some of the advanced features that more complex monorepo tools like Nx provide. For example, Nx offers advanced caching, task scheduling, and dependency graph analysis, which can significantly speed up your development workflow in larger projects. It is also able to automatically update your TS project references when you add or remove packages, which is a huge convenience factor. The great thing is that you don’t have to choose one or the other, as you can use PNPM Workspaces as a foundation and then build on top of it with Nx if you need more advanced features later on. In fact, opting into Nx nowadays is easier than ever and should be no more than a single command:
pnpm nx init

Stefan Haas
Senior Software Engineer at Microsoft working on Power BI. Passionate about developer experience, monorepos, and scalable frontend architectures.
Comments
Join the discussion and share your thoughts!