JavaScript monorepos with npm workspaces: practical guide
Overview
npm workspaces is a feature built into npm 7 and later that lets you manage multiple packages within a single project — a JavaScript monorepo. 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. Compared with running multiple npm install commands in separate folders, workspaces give you one source of truth, deterministic versions across packages, and a single package-lock.json that captures the whole graph.
This guide walks through a minimal but realistic monorepo: a shared utility package, a web app that consumes it, and a CLI tool. We cover the root package.json, the per-package layout, running commands across workspaces, sharing TypeScript settings, and the rough edges you should know about before committing to the pattern. For more context on Node tooling, see the Vite guide.
Setting up workspaces
The root package.json
Define your workspaces in the root package.json:
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*"
]
}
The packages/* glob matches all subdirectories. When your layout is more complex, you can list specific paths instead. This gives you finer control over which directories are treated as workspaces and avoids accidentally picking up unrelated folders:
"workspaces": [
"packages/core",
"packages/ui",
"apps/web"
]
Setting private: true prevents the root package from being accidentally published to npm. With the workspace list defined, npm treats matching directories as packages and hoists their dependencies together. The resulting directory layout is predictable and easy to navigate.
Package structure
With packages/*, your directory looks like this:
my-monorepo/
package.json ← root package with workspaces field
packages/
core/
package.json
ui/
package.json
utils/
package.json
node_modules/ ← shared, hoisted dependencies
Each workspace has its own package.json that declares the package name, version, and entry point. The name is how other workspaces reference it; the main field tells Node which file to load when the package is imported.
{
"name": "@my-monorepo/core",
"version": "1.0.0",
"main": "dist/index.js"
}
Installing and running
Installing from root
Run npm install from the root; it installs all workspace dependencies in one pass:
npm install lodash # installs lodash at root (hoisted)
npm install --workspaces # install all workspaces
npm install -w @my-monorepo/core # install to specific workspace
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. The same commands work for running scripts in specific workspaces, using the -w flag to target one package at a time.
Running scripts
Use -w to run a script in a specific workspace:
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
Run a command in every workspace — this fans out to all packages that have a matching script defined. Workspaces without that script are silently skipped, so you do not need every package to declare every script name:
npm run test --workspaces
Referencing workspace packages
Inside the monorepo, workspace packages reference each other by name just like any other npm package:
{
"name": "@my-monorepo/ui",
"dependencies": {
"@my-monorepo/core": "*"
}
}
The * version means “use whatever is in the monorepo”. npm resolves this from the local workspace rather than fetching from the registry, so no npm link is required. When importing, be precise about which package provides which function — mixing them up leads to confusing runtime errors.
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:
// In the UI package
import { createStore } from '@my-monorepo/core'; // correct
import { formatDate } from '@my-monorepo/core'; // probably wrong, that belongs in utils
Publishing workspace packages
When you are ready to publish, npm publish from the root publishes all workspaces that have changed. npm compares each package’s version against the registry and only publishes those with a higher version number:
npm publish --workspaces --access public
This publishes each workspace that has a version bump. For targeted releases, run publish from a single workspace to avoid updating packages that have not changed:
npm -w @my-monorepo/core publish
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:
# 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
More complex setups use Turborepo or changesets to orchestrate builds, cache results, and decide which packages need rebuilding based on what changed. Knowing what to build next is important, but equally important is deciding what shape your packages should take. The next section covers patterns that emerge in real monorepos.
Common use cases
Shared utility library
A shared package declares its name and entry point. Other workspaces can then depend on it with the * version specifier:
// packages/utils/package.json
{
"name": "@my-monorepo/utils",
"main": "src/index.js"
}
The package implementation lives in the file referenced by main. A utility like formatCurrency is self-contained and stateless, making it safe to share across the entire monorepo without worrying about side effects:
// packages/utils/src/index.js
export function formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
}
Any workspace that needs this utility adds the package to its dependencies. The * version picks up whatever is in the local workspace, so you never accidentally pull a stale published version during development:
The API package imports from utils without needing a published version.
Frontend and backend in one repo
my-app/
packages/
shared/ # types, constants shared between frontend and backend
server/ # Express/Fastify app
client/ # React/Vue app
package.json
Both server and client depend on shared. You version bump shared, then server and client update their dependency on it. Workspaces make these patterns straightforward, but the approach comes with trade-offs you should understand before committing to 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.
Pick package boundaries carefully
Workspaces work best when each package has one clear job. If a package starts to cover unrelated concerns, consumers end up depending on too much at once and the dependency graph becomes harder to reason about. Keep shared utilities small, keep apps focused, and move code between packages only when the split improves ownership or reuse. A clean boundary also helps with release planning, because you can see which package changed and which downstream packages may need a follow-up bump.
Keep workspace scripts small
Script names are easiest to remember when they stay short and task-based. build, test, and dev are easier to reuse across packages than a long list of one-off commands. If two packages need the same workflow, align the script names so root-level commands can fan out without special casing. That consistency makes the monorepo easier for new contributors, because they can guess the next command from the previous one instead of learning each package from scratch.
Treat the root as infrastructure
The root package should support the workspaces, not compete with them. Use it for shared tooling, lockfile management, and repo-level scripts. Avoid placing feature code there unless the root truly behaves like a package that other workspaces consume. This habit keeps the top level tidy and reduces the risk of shipping accidental dependencies. It also makes it easier to understand what belongs to the repo itself and what belongs to a product package underneath it.
Know when to split out
Not every project needs a monorepo forever. If a workspace grows into a separate release cycle or a different permission model, it may be better on its own. Workspaces make sense when the packages share tooling, release cadence, or source code. Once those links weaken, the overhead can start to outweigh the benefit. Revisit the structure as the project changes, and do not be afraid to simplify if the monorepo stops serving the team well.
Revisit the layout
A workspace layout should stay tied to the shape of the project, not to the shape it had on day one. If a package becomes too broad, split it. If two packages always change together, consider whether they belong next to each other or should be merged. That periodic review keeps the monorepo from freezing into a structure that no longer fits. The best workspace setup is the one that still makes sense after a few rounds of product growth.
See also
- /guides/javascript-modules-esm/ for the JavaScript module system that npm workspaces builds on
- /guides/javascript-node-best-practices/ for Node.js project structure and patterns