Workspaces & Monorepos
npm workspaces link internal packages in one repo so shared code ships without publishing to a registry for every change.
Recipe
Quick-reference recipe card - copy-paste ready.
{
"name": "@acme/platform",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "npm run build -w @acme/shared && npm run build -w @acme/api"
}
}// apps/api/package.json
{
"name": "@acme/api",
"dependencies": {
"@acme/shared": "workspace:*"
}
}When to reach for this:
- Multiple deployable services share TypeScript types or utilities.
- You want one lockfile and one
npm cifor the whole org slice. - Internal packages change in the same PR as their consumers.
Working Example
platform/
package.json # workspaces root
package-lock.json
apps/
api/
package.json # @acme/api
src/server.ts
packages/
shared/
package.json # @acme/shared
src/index.ts
// packages/shared/package.json
{
"name": "@acme/shared",
"version": "0.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
}
}// apps/api/src/server.ts
import { createLogger } from "@acme/shared";
const log = createLogger("api");
log.info("listening");npm install # links workspaces
npm run build -w @acme/shared
npm run dev -w @acme/apiWhat this demonstrates:
workspace:*tells npm to symlink the local@acme/sharedpackage.-wtargets a single workspace withoutcd.- Shared package builds before the API imports compiled output.
Deep Dive
How It Works
- Root
workspacesglobs discoverpackage.jsonfiles underapps/*andpackages/*. npm installhoists shared dependencies and symlinks internal packages into consumernode_modules.workspace:*resolves to the local version at install time; publishing replaces it with the concrete semver.- One
package-lock.jsonat the root captures the entire graph.
workspace: Protocol
| Specifier | Meaning |
|---|---|
workspace:* | Any local version (most common) |
workspace:^ | Match local major-compatible |
workspace:1.2.3 | Pin to exact local version |
TypeScript Notes
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
}
}- Enable
compositefor project references across packages. - Consumers should import from package names (
@acme/shared), not relative paths intopackages/shared/src.
Gotchas
- Importing source paths across packages - Bypasses package boundaries and breaks publish. Fix: export through
package.jsonmain/exports. - Forgetting to build shared libs - API runs against stale
dist/. Fix: orderbuildscripts or use Turborepo pipeline deps. - Phantom dependencies - Hoisting lets you import undeclared packages. Fix: consider pnpm or ESLint
import-x/no-extraneous-dependencies. - Version 0.0.0 internal packages - Fine for private monorepos; publishable libs need real semver before
npm publish. Fix: runnpm versionin the package before external release. - Circular workspace deps -
@acme/adepends on@acme/band vice versa. Fix: extract shared kernel to@acme/core.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| npm pack + file: | Quick spike without workspaces | Long-term monorepo maintenance |
| Private Verdaccio / npm org | Teams need versioned internal releases | Every change is atomic in one PR |
| Turborepo / Nx on top | Cached builds across 5+ packages | Two-package repo (overkill) |
FAQs
What does workspace:* mean at publish time?
npm replaces workspace:* with the concrete version from the workspace package's package.json when you npm publish from that package.
How do I run a script in one workspace?
npm run test -w @acme/api
npm run build --workspaces --if-present-w targets one package; --workspaces runs across all.
Can workspaces mix apps and libraries?
Yes. Common layout: apps/* for deployables, packages/* for shared libs. Keep deployables clearly separated in Docker/CI.
Do I need separate lockfiles per package?
No. One root lockfile is the npm workspaces default and preferred for reproducible CI.
How do I add a dependency to one workspace?
npm install zod -w @acme/apiThe root lockfile updates; the dependency lands in that workspace's package.json.
Should internal packages be private?
Mark "private": true on packages never published externally. Remove only when publishing to npm with provenance.
How does TypeScript find workspace types?
Built .d.ts in dist/ plus types field in package.json. Build shared packages before typechecking dependents.
Can I use Express and Fastify in different workspaces?
Yes. Each app workspace owns its framework dependency; shared code stays framework-agnostic.
What if two workspaces need different versions of the same lib?
npm may install multiple versions nested in the lockfile. Prefer aligning versions to reduce bundle size and audit surface.
When should I publish internal packages to npm?
When another repo consumes them or you need semver boundaries between teams. Until then, workspace:* is enough.
Related
- Package Managers Basics - pnpm vs npm for monorepos
- Turborepo for Node - cached workspace builds
- Multi-Service Monorepo - service boundaries
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.