Setting up npm Workspaces for a Monorepo
Workspaces are a powerful npm feature that enables working with multiple independent projects in the same repository. I'll show you how to set them up and use them with Vite for a fluid development experience.
Once we're done, we'll end up with the following folder structure:
monorepo/ <-- the root of our workspace
├── node_modules/
├── project-a/ <-- the root of project A
│ ├── package.json
│ └── vite.config.ts
├── project-b/ <-- the root of project B
│ ├── package.json
│ └── vite.config.ts
└── package.json
And this is our set of requirements:
- Each project's dependencies should be independent of one another; if we modify a package in project A, it must not modify the package in project B, and vice-versa
- We should be able to run builds and start development servers from our workspace root
- We should be able to execute builds and development servers concurrently
Setting up the workspaces
Workspaces have been supported since npm version 7.24.2, but that version of npm is already considered to be legacy. I'll be using npm version 10.7.0 for this, so anything newer than that should work. To check your current npm version, run:
npm -v
If you need to update it, run:
npm install -g npm@latest
First up, if you're following along, create the monorepo directory above, and open it in a command prompt. Next, we'll setup Vite for both projects. To setup project-a run:
npm create vite@latest project-a
Select Vue as the framework and TypeScript as the variant. Now setup project-b using:
npm create vite@latest project-b
This time, select Svelte as the framework and TypeScript as the variant. Finally, create monorepo/package.json and add the following to it:
{
"private": true,
"name": "monorepo",
"version": "1.0.0",
"workspaces": [
"project-a",
"project-b"
],
}
We add "private": true to prevent our monorepo from being published as a package. But the important part here is the workspaces array. As you can see, we've defined two workspaces: one for project-a and a second for project-b. Let's now see how things tie together so we can understand how to work with workspaces.
Open up the project-a/package.json and you'll see the name in that file is this:
{
"name": "project-a"
}
That name is what npm expects us to pass as an argument to commands when we're referring to a workspace – npm does not use the name of the folder. To make this clear, change the name to:
{
"name": "our-first-project"
}
Next, run:
npm install axios --workspace=our-first-project
If you take a look at the package.json again, you should now see axios as a dependency:
{
"name": "our-first-project",
"dependencies": {
"axios": "^1.9.0"
}
}
If you have a look at project-b/package.json though, you won't see axios there. This means we've met the first of our three requirements: dependency isolation. Let's work on the other two.
At this point, run npm install from monorepo/ to ensure all packages are installed. Now try running:
npm run build --workspaces
This time, we've used the --workspaces argument (note the plural) to run the build command against all workspaces. But there are a couple of problems here. First of all, it runs the builds sequentially. For a production setup, that's going to take far too long. And second, what happens if a workspace doesn't define a build command in its package.json? If you delete the "build": "vite build", line from project-b/package.json and run the build command again, you'll see this:
npm error Lifecycle script `build` failed with error:
npm error Error: Missing script: "build"
To see a list of scripts, run:
npm run
npm error in workspace: project-b@0.0.0
npm error at location: D:\monorepo\project-b
Depending on what you're doing, you might prefer that the command fails like this. But, if not, you can supply the --if-present argument to only issue the command to workspaces that have it defined:
npm run build --workspaces --if-present
But how do we solve the concurrency problem? Like anything in the JavaScript world, there is a package for that. From monorepo/ run:
npm install concurrently --save-dev
Open up monorepo/package.json and add the "scripts" section:
{
"private": true,
"name": "monorepo",
"version": "1.0.0",
"workspaces": [
"project-a",
"project-b"
],
"devDependencies": {
"concurrently": "^9.1.2"
},
"scripts": {
"build": "concurrently \"npm run --workspace=project-a build\" \"npm run --workspace=project-b build\""
}
}
concurrently expects commands to be enclosed in "s and separated by a space, like so:
concurrently "npm run --workspace=project-a build" "npm run --workspace=project-b build"
^ note the space
As we're using this in a json file though, we need to escape the quotes with \, which is why the scripts section looks the way it does. If you now run:
npm run build
You'll see both projects build. That said, it won't necessarily be clear that this is now happening in parallel. If you run this several times though, you should see some interleaved logging output from both projects indicating the builds are taking place simultaneously.
So we have parallel builds, but what about running servers? In particular, how do we run servers for both projects at the same time? To deal with that, open project-a/vite.config.ts and change it to the following:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
port: 5500,
open: true
}
})
Open project-b/vite.config.ts and change it to this:
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
server: {
port: 5501, // note the different port number
open: true
}
})
Finally, back in monorepo/package.json update the scripts section to be:
"scripts": {
"build": "concurrently \"npm run --workspace=project-a build\" \"npm run --workspace=project-b build\"",
"dev": "concurrently \"npm run --workspace=project-a dev\" \"npm run --workspace=project-b dev\""
}
And run:
npm run dev
Two tabs should've opened in your browser: one should show the Vite and Vue logos, the other should show the Vite and Svelte logos. And that's it! You can now work on these projects together, or separately, within the same repository. I love how easy this is to setup, especially adding TypeScript into the mix, compared to how long this would've taken to do several years ago.
Making things a bit easier
Have you got tired of typing --workspace=workspace_name yet? Good, now you're motivated to do something about it. Let's say we have a workspace called blog. In our monorepo/package.json, we could add this to our script section:
"scripts": {
"blog": "npm run --workspace=blog"
}
This now enables us to use npm run blog <command> instead (e.g. npm run blog dev).
Happy workspacing (whatever that means).