The Right Way to Structure a Node.js Monorepo in 2026
You split your backend into separate repos. Now you have twelve repos, nine package.json files with slightly different dependency versions, four copies of your validation utils, and six CI pipelines to coordinate for one feature. Here's the monorepo setup that actually works.
You split your backend into separate repos because "microservices." Three months later you have twelve repos, nine package.json files with slightly different versions of the same dependencies, four copies of your validation utils, and a deployment process that requires coordinating six separate CI pipelines to ship one feature.
That's the problem monorepos solve. But a badly structured monorepo trades those problems for new ones — circular dependencies, broken hoisting, CI pipelines that rebuild everything on every commit, and TypeScript path aliases that work on your machine but break in production.
What a monorepo is not
A monorepo is not just "all your code in one repo" — that's a monolith. A monorepo is multiple distinct packages in a single repository, each with their own package.json, their own build, their own tests — but sharing a single install, a single lockfile, and a single CI pipeline.
In 2026 the standard tool for this in Node.js is npm workspaces. No Lerna, no Nx required to get started — npm ships everything you need. Add those tools later if you need advanced orchestration. Start with npm.
The folder structure that scales
Stop guessing at folder names. This is the structure that survives three engineers becoming thirty:
my-monorepo/
├── package.json ← workspace root
├── package-lock.json ← single lockfile for everything
├── tsconfig.base.json ← shared compiler options
├── .eslintrc.base.js ← shared lint rules
├── .env.example
│
├── apps/ ← deployable applications
│ ├── api/ ← Express/Fastify backend
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── src/
│ ├── worker/ ← background job processor
│ │ ├── package.json
│ │ └── src/
│ └── admin/ ← internal dashboard
│ ├── package.json
│ └── src/
│
├── packages/ ← shared internal libraries
│ ├── types/ ← shared TypeScript types
│ ├── utils/ ← shared pure utility functions
│ ├── db/ ← database client + models
│ └── config/ ← shared config validation (Zod)
│
└── tooling/ ← shared dev tooling configs
├── eslint/
└── tsconfig/Two rules that make this work long-term: apps/ contains things you deploy — they can import from packages/ but never from each other. packages/ contains things you share — they can import from other packages/ but never from apps/. Circular dependencies between apps will silently corrupt your builds.
npm workspaces — setup and the gotchas
The root package.json declares every workspace location:
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*",
"tooling/*"
],
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
},
"scripts": {
"build": "npm run build --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
"lint": "npm run lint --workspaces --if-present",
"typecheck":"npm run typecheck --workspaces --if-present"
}
}"private": true is not optional — it prevents accidentally publishing the workspace root to npm. --if-present skips packages that don't have that script defined; without it, a missing test script in one package kills the entire run.
Gotcha 1 — install from root, always. Run npm install at the root. Never inside individual packages. If you run npm install inside apps/api/, you get a second node_modules that shadows the workspace-hoisted one and breaks your internal package references.
Gotcha 2 — add dependencies to the right package:
# Add a dependency to a specific workspace
npm install express --workspace=apps/api
# Add a dev dependency to the root (affects all workspaces)
npm install typescript --save-dev
# Add an internal package as a dependency
npm install @myrepo/utils --workspace=apps/apiThe shared tsconfig — get the inheritance right
Most monorepos get this wrong. They either copy tsconfig.json into every package (changes drift) or create one root tsconfig.json referencing all packages (incremental builds break). The right pattern is inheritance.
tsconfig.base.json at the root — compiler options only, no include:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}Each package extends this and adds only what's local:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}module: NodeNext enforces explicit file extensions in imports (.js not .ts — yes, you import .js even in TypeScript files when targeting Node). This eliminates an entire class of runtime resolution errors that bite people at deploy time.
Internal packages — how to share code without pain
This is where most monorepos go wrong. The naive approach requires building every internal package before running any app. The better approach uses the exports field with TypeScript source directly in development — no build step in the hot path.
{
"name": "@myrepo/utils",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc"
},
"devDependencies": {
"typescript": "*"
}
}Apps reference internal packages with "*" as the version — npm workspaces resolves it to the local package automatically:
{
"name": "@myrepo/api",
"dependencies": {
"@myrepo/utils": "*",
"@myrepo/types": "*",
"@myrepo/db": "*",
"@myrepo/config": "*"
}
}Inside the app — clean imports, no relative path chains:
// Clean imports — no relative ../../ chains
import { slugify, formatCurrency } from '@myrepo/utils';
import { UserSchema, OrderSchema } from '@myrepo/types';
import { db } from '@myrepo/db';
import { getConfig } from '@myrepo/config';
const config = getConfig(); // fully typed, validated with Zod
const user = UserSchema.parse(req.body);Running scripts across workspaces
npm workspaces has no built-in topological ordering. For ordered execution, use npm-run-all2:
npm install npm-run-all2 --save-dev{
"scripts": {
"build:packages": "npm run build --workspace=packages/types --workspace=packages/utils --workspace=packages/db --workspace=packages/config",
"build:apps": "npm run build --workspaces --if-present",
"build": "run-s build:packages build:apps",
"dev": "run-p dev:api dev:worker",
"dev:api": "npm run dev --workspace=apps/api",
"dev:worker": "npm run dev --workspace=apps/worker"
}
}run-s runs scripts sequentially. run-p runs them in parallel. For local development, parallel is fine. For CI builds, always sequential with packages before apps.
# Run tests only in the api app
npm run test --workspace=apps/api
# Run build only in the utils package
npm run build --workspace=packages/utils
# Run a script inside a workspace context
npm exec --workspace=apps/api -- node src/scripts/seed.jsCI/CD — where monorepos break pipelines
The biggest monorepo mistake in CI: rebuilding and testing everything on every commit. The fix is affected package detection — only run CI for packages that changed, plus their dependents.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
worker: ${{ steps.filter.outputs.worker }}
utils: ${{ steps.filter.outputs.utils }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'apps/api/**'
- 'packages/**'
- 'tsconfig.base.json'
worker:
- 'apps/worker/**'
- 'packages/**'
- 'tsconfig.base.json'
utils:
- 'packages/utils/**'
test-api:
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run build --workspace=packages/utils --workspace=packages/types --workspace=packages/db --workspace=packages/config
- run: npm run test --workspace=apps/apiThe packages/** path in every app filter means any shared package change triggers all app tests. The cache: 'npm' in setup-node caches based on package-lock.json. Since you have one lockfile at the root, the cache is shared across all jobs automatically.
Dependency management — the hoisting trap
npm workspaces hoists dependencies to the root node_modules by default. This creates a subtle trap: a package can accidentally import a dependency it never declared because it was hoisted from a sibling.
// apps/api depends on: express, @myrepo/utils
// packages/utils depends on: lodash
// apps/api/src/index.ts:
import _ from 'lodash'; // works locally (hoisted), crashes in production DockerCatch this with eslint-plugin-import and the no-extraneous-dependencies rule:
// apps/api/.eslintrc.js
module.exports = {
rules: {
'import/no-extraneous-dependencies': ['error', {
packageDir: ['.', '../../'], // check both local and root package.json
}],
},
};Bump shared dependency versions across all workspaces at once:
# Bump typescript everywhere at once
npm install typescript@5.8 --save-dev --workspacesCommon mistakes
- Importing with relative paths across package boundaries — import { something } from '../../packages/utils/src' bypasses workspace resolution entirely and breaks when you move files. Always import via the package name: from '@myrepo/utils'
- Running npm install inside a workspace instead of the root — creates a nested node_modules that causes duplicate installs and breaks workspace symlinks silently
- Not setting "private": true on internal packages — someone will eventually run npm publish from the wrong directory
- Sharing environment variable values between apps via the root .env — each app should own its own env file. A packages/config package with Zod validation is the right place for the shared parsing logic, not the values
- Letting apps import from other apps — anything shared between apps belongs in packages/, not in a cross-app import that defeats the structure entirely
- Not declaring a Node.js engine range at the root — workspaces inherit root engine constraints. Without it, npm won't warn when someone installs on the wrong Node version
The takeaway
A monorepo is not a project structure decision — it's a dependency management decision. The structure exists to make the dependency graph explicit: apps/ depends on packages/, packages/ depends on other packages/, nothing flows the other direction. npm workspaces, a shared tsconfig.base.json, and source-linked internal packages give you that graph with minimal tooling. The CI pipeline with path-based change detection is what makes it fast enough to be usable. Get those four things right and the monorepo pays you back in eliminated duplicate code, consistent types across services, and a single npm install that sets up everything.
Related Articles
You might also enjoy these
Array.fromAsync() and the End of Promise.all Map Patterns
Every JavaScript developer has written await Promise.all(items.map(async item =>...)). It works — until you hit a rate-limited API, a paginated async generator, or a ReadableStream. Array.fromAsync() is the purpose-built replacement you didn't know you needed.
Angular Signals Forms — Replace ReactiveFormsModule in New Projects
Reactive forms were the right solution for 2018. Angular 21 ships Signal-based Forms — no valueChanges, no async pipe, no subscription management. Here's how to replace ReactiveFormsModule in every new component you write.
Stay in the loop
Get articles on technology, health, and lifestyle delivered to your inbox.
No spam — unsubscribe anytime.