Monorepos with npm Workspaces

· 5 min read · Updated April 20, 2026 · intermediate
javascript npm monorepo node packages

Overview

npm workspaces is a feature built into npm 7 and later that lets you manage multiple packages within a single project. Instead of maintaining separate repositories for each library or service, you keep them in one monorepo and npm handles the dependency tree, hoisting shared modules to a single node_modules folder.

The practical benefit is that all your packages share one node_modules directory, installing and updating dependencies is faster, and packages within the monorepo can reference each other by name without publishing to a registry first.

Setting Up Workspaces

The root package.json

Define your workspaces in the root package.json:

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}
```bash

The `packages/*` glob matches all subdirectories. You can also list specific paths:

```json
"workspaces": [
  "packages/core",
  "packages/ui",
  "apps/web"
]
```bash

Setting `private: true` prevents the root package from being accidentally published to npm.

### Package structure

With `packages/*`, your directory looks like this:

```bash
my-monorepo/
  package.json       ← root package with workspaces field
  packages/
    core/
      package.json
    ui/
      package.json
    utils/
      package.json
  node_modules/       ← shared, hoisted dependencies
```bash

Each workspace has its own `package.json`:

```json
{
  "name": "@my-monorepo/core",
  "version": "1.0.0",
  "main": "dist/index.js"
}
```bash

## Installing and Running

### Installing from root

Run `npm install` from the root — it installs all workspace dependencies in one pass:

```bash
npm install lodash        # installs lodash at root (hoisted)
npm install --workspaces   # install all workspaces
npm install -w @my-monorepo/core  # install to specific workspace
```bash

Dependencies shared across workspaces are hoisted to the root `node_modules`. If `core` and `ui` both depend on `lodash`, npm installs it once at the root.

### Running scripts

Use `-w` to run a script in a specific workspace:

```bash
npm -w @my-monorepo/core run build    # build the core package
npm -w @my-monorepo/ui run dev       # start the UI dev server
npm run build --workspaces            # build all workspaces
```bash

Run a command in every workspace:

```bash
npm run test --workspaces
```bash

## Referencing Workspace Packages

Inside the monorepo, workspace packages reference each other by name just like any other npm package:

```json
{
  "name": "@my-monorepo/ui",
  "dependencies": {
    "@my-monorepo/core": "*"
  }
}
```bash

The `*` version means "use whatever is in the monorepo". npm resolves this from the local workspace rather than fetching from the registry. No `npm link` required.

### Depending on the right package

Be explicit about which package you depend on. If you have `@my-monorepo/core` and `@my-monorepo/utils`, make sure each consumer imports from the right one:

```javascript
// In the UI package
import { createStore } from '@my-monorepo/core';   // correct
import { formatDate } from '@my-monorepo/core';     // probably wrong — that's utils
```bash

## Publishing Workspace Packages

When you are ready to publish, use `npm publish` from the root. It automatically publishes packages that have changed:

```bash
npm publish --workspaces --access public
```bash

This publishes each workspace that has a `version` bump. You can also publish from a specific workspace:

```bash
npm -w @my-monorepo/core publish
```json

For private packages, set `"private": false` in the workspace's `package.json` and ensure you have access to publish to your scope on npm.

## Development Workflow

### Watching for changes

When developing locally, you want workspace packages to rebuild when their source changes. With most bundlers you need a watch mode or a tool like `npm run -w` in a loop:

```bash
# In one terminal, watch and rebuild core on changes
npm -w @my-monorepo/core run build -- --watch

# In another terminal, run the UI which depends on core
npm -w @my-monorepo/ui run dev
```bash

More complex setups use Turborepo or changesets to orchestrate builds, cache results, and decide which packages need rebuilding based on what changed.

## Common Use Cases

### Shared utility library

```json
// packages/utils/package.json
{
  "name": "@my-monorepo/utils",
  "main": "src/index.js"
}
```bash

```javascript
// packages/utils/src/index.js
export function formatCurrency(amount) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(amount);
}
```bash

```json
// packages/api/package.json
{
  "name": "@my-monorepo/api",
  "dependencies": {
    "@my-monorepo/utils": "*"
  }
}
```bash

The API package imports from utils without needing a published version.

### Frontend and backend in one repo

```bash
my-app/
  packages/
    shared/          # types, constants shared between frontend and backend
    server/          # Express/Fastify app
    client/          # React/Vue app
  package.json
```bash

Both `server` and `client` depend on `shared`. You version bump `shared`, then `server` and `client` update their dependency on it.

## Gotchas

**Circular dependencies.** If package A depends on B and B depends on A, npm will error. Structure your packages in layers — shared utilities at the bottom, applications at the top.

**Version conflicts.** If two workspaces depend on different major versions of the same package, npm cannot hoist them. You get two copies in `node_modules`. This is a symptom of package boundaries being wrong — split or merge packages to reduce dependency overlap.

**The root `node_modules` is shared.** Do not add production dependencies to the root `package.json` unless every workspace needs them. Add dev-only tools there (test runners, linters).

**npm 7+ required.** Workspaces are only available in npm 7 and later. Check your npm version with `npm --version` and upgrade with `npm install -g npm`.

**Node resolution.** When a workspace package imports another by name, Node looks it up in `node_modules` starting from the importer's directory. With workspaces, it finds the hoisted version at the root. This works as expected, but can be confusing when debugging resolution issues.

## See Also

- [/guides/javascript-modules-esm/](/guides/javascript-modules-esm/) — JavaScript module system that npm workspaces builds on
- [/guides/javascript-node-best-practices/](/guides/javascript-node-best-practices/) — Node.js project structure and patterns