How we use npm workspaces

Recently I wrote about changing the tooling we use for our JavaScript monorepo and mentioned we are using npm 7’s new workspaces feature. Workspaces have been available to try since version 7 was released and is now considered “generally available.” I have been familiar with yarn workspaces for some time and I was a bit confused at first about what npm workspaces actually did. I’ll try to give a brief overview of our directory structure and how npm’s workspaces work for us.

For us, the new workspaces feature means two things: 1) all dependencies of the root package + sub-packages are installed into a single node_modules folder at the root and 2) sub-packages are symlinked into node_modules during npm install.

Directory structure

We’ve broken our project up into three different types of packages: apps which are preact apps intended to be bundled and deployed somewhere, modules which are plain npm packages for node/browsers and do not bundle their dependencies, and workers which are either Worker or ServiceWorker scripts entirely bundled up with no imports or exports. We don’t have to keep these three types of packages separated, but it helps us navigate around. It looks similar to this:

monorepo
├ …
├ build.js
├ package.json
├ apps
│ ├ …
│ └ download-app
│   ├ …
│   └ package.json
├ modules
│ ├ …
│ └ socket
│   ├ …
│   └ package.json
└ workers
  ├ …
  └ download-service-worker
    ├ …
    └ package.json

Our root package.json adds those three directories’ children as workspaces:

{
  "private": true,
  "name": "@shareup/monorepo",
  "version": "0.1.0",
  "author": "Shareup <hello@shareup.app>",
  "license": "UNLICENSED",
  "type": "module",
  "engines": { "node": "15.x" },
  "workspaces": [
    "apps/*",
    "modules/*",
    "workers/*"
  ]
}

And this gives us the two things I mentioned above:

1) All dependencies are installed into the root node_modules

npm install will check every workspace, accumulate all the unique dependencies, and install them all into the root node_modules directory. For example: since download-app depends on preact, npm will install that version of preact into the node_modules folder at the root.

An important caveat is: any project can require any dependency from the shared node_modules even if it’s not directly included in the specific sub-project’s package.json file. We use this “feature” so our build tools are only listed in the root package.json file and we don’t duplicate those out into every sub-project. However, there are currently no tools to help identify and/or enforce any undeclared dependencies in a sub-project, so right now it’s a human problem or you’ll need to write your own tooling.

Another important thing to remember is: don’t run npm install inside a sub-project. npm isn’t smart enough to figure out it’s inside a workspace and will assume it’s a normal project, create a local node_modules directory inside the sub-project, etc. I hope this changes soon and npm can detect the root package.json and perform the install up at the root.

2) All sub-projects are symlinked into the root node_modules

npm will create a symlink inside the root node_modules directory to each sub-project. If you remember the above directory structure, then this example would create the following symlinks (we prefix all our module names with @shareup):

node_modules
├ …
└ @shareup
  ├ download-app → ../../apps/download-app/
  ├ download-service-worker → ../../workers/download-service-worker/
  └ socket → ../../modules/socket/

So any project can import { createSocket } from '@shareup/socket' and it will find it on disk through the symlink.

Give it a try

We recommend trying out the new npm workspaces and providing feedback to the npm team so they get even better over time.