Monorepos in JavaScript & TypeScript: A Practical Guide
How to structure, build, and maintain JavaScript/TypeScript monorepos
If we maintain more than two or three packages across separate repositories, we've probably hit the same wall: version mismatches, duplicated configs, copy-pasted CI pipelines. Monorepos solve most of that, but the tooling matters.
What a Monorepo Is
A monorepo is a single repository that contains multiple distinct projects or packages. It is not a monolith — packages can still be independently deployable, independently versioned, and have clear boundaries.
A typical structure:
my-monorepo/
├── packages/
│ ├── ui/ # shared component library
│ ├── utils/ # shared utilities
│ └── config/ # shared tsconfig, eslint, etc.
├── apps/
│ ├── web/ # Next.js frontend
│ ├── api/ # Express/Fastify backend
│ └── admin/ # admin dashboard
├── package.json
├── pnpm-workspace.yaml
└── turbo.jsonThe apps/ folder holds deployable applications. The packages/ folder holds shared libraries consumed by those apps. This convention is not enforced by any tool — it is what most teams settle on.
Benefits
- Atomic changes across packages. Refactor a shared utility and update every consumer in one PR.
- Single set of configs. One ESLint config, one TypeScript base config, one Prettier setup.
- Simplified dependency management. One lockfile, one place to audit, one
node_modulestree. - Easier code sharing. No publishing to npm just to share a helper between two internal projects.
Choosing a Package Manager
All three major package managers support workspaces, but they differ in important ways.
npm workspaces — built in since npm 7. Works for small setups. Symlinks packages and hoists dependencies. Slower and less strict than the alternatives.
// package.json (root)
{
"name": "my-monorepo",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}Yarn (Berry/v4) — Plug'n'Play is interesting but introduces friction with tools that expect node_modules. Worth considering if our team is already on Yarn and comfortable with PnP.
pnpm — this is what I'd recommend for most new monorepos. It is fast, strict about dependencies (no phantom deps), and has excellent workspace support. The content-addressable store keeps disk usage reasonable.
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'Running commands with pnpm:
# install a dep in a specific package
pnpm add zod --filter @myorg/api
# run a script in a specific package
pnpm --filter @myorg/web dev
# run a script in all packages that have it
pnpm -r run buildThe --filter flag supports package names, directory paths, and git-diff-based filtering (--filter ...[HEAD~1] to target only changed packages).
Shared TypeScript Config
Instead of copy-pasting tsconfig.json across packages, create a shared base:
// packages/config/tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}Each package extends it:
// apps/web/tsconfig.json
{
"extends": "@myorg/config/tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}The same pattern works for ESLint, Prettier, and any other config.
Internal Packages Without Publishing
Shared packages that are only consumed internally do not need a build step. We can point the package's exports directly at the TypeScript source:
// packages/utils/package.json
{
"name": "@myorg/utils",
"version": "0.0.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}This works because our apps (Next.js, Vite, etc.) will transpile the imported source through their own bundler. No separate build step during development — changes to a shared util are picked up immediately.
If we need to publish a package to npm, we will need a proper build step with tsc or tsup. For internal packages, skip it.
Task Orchestration: Turborepo vs Nx
Once we have more than a handful of packages, running builds and tests in the right order and caching results becomes important.
Turborepo is the simpler option. Configuration is minimal:
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"test": {
"dependsOn": ["build"]
}
}
}"dependsOn": ["^build"] means "build my dependencies before building me." Turborepo figures out the graph and parallelizes what it can. The cache is content-hash-based — if inputs have not changed, it replays the output.
# run build across all packages, respecting dependency order
turbo build
# run dev servers for web and api in parallel
turbo dev --filter=@myorg/web --filter=@myorg/apiNx is more powerful but more opinionated. It has its own plugin system, generators, affected-based testing, and a cloud cache. Worth considering for large setups (50+ packages). For most teams, I'd start with Turborepo — it hits the sweet spot. Migrating to Nx later is straightforward; going the other direction is harder.
Dependency Management Between Packages
Reference internal packages through the dependencies field with the workspace protocol:
// apps/web/package.json
{
"name": "@myorg/web",
"dependencies": {
"@myorg/utils": "workspace:*",
"@myorg/ui": "workspace:*",
"react": "^19.0.0"
}
}workspace:* (pnpm/Yarn) tells the package manager to resolve from the local workspace. npm workspaces use "*" — the symlink happens automatically.
Watch out for version conflicts on shared externals. If @myorg/web uses React 19 but @myorg/ui specifies React 18 as a peer dependency, we will get runtime bugs. Keep peer dependency ranges aligned, or use overrides/resolutions to force a single version.
A Real-World Folder Layout
monorepo/
├── apps/
│ ├── web/
│ │ ├── src/
│ │ ├── package.json # depends on @myorg/ui, @myorg/utils
│ │ └── tsconfig.json # extends shared config
│ └── api/
│ ├── src/
│ │ ├── routes/
│ │ └── index.ts
│ ├── package.json # depends on @myorg/utils, @myorg/db
│ └── tsconfig.json
├── packages/
│ ├── ui/
│ │ ├── src/
│ │ │ ├── Button.tsx
│ │ │ └── index.ts
│ │ └── package.json
│ ├── utils/
│ │ ├── src/
│ │ │ ├── format.ts
│ │ │ └── index.ts
│ │ └── package.json
│ ├── db/
│ │ ├── src/
│ │ │ ├── client.ts
│ │ │ └── schema.ts
│ │ └── package.json
│ └── config/
│ ├── tsconfig.base.json
│ ├── eslint.config.mjs
│ └── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
└── .github/
└── workflows/
└── ci.ymlCI: Only Build What Changed
Instead of running every test suite on every commit, filter to affected packages:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for change detection
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build lint test --filter=...[origin/main]--filter=...[origin/main] tells Turborepo to only run tasks for packages that changed since origin/main. Combined with caching, this keeps CI fast as the repo grows.
Things to Keep in Mind
- Remote caching (Turborepo, Nx) — in my experience, the cache hit rate tends to be lower than expected. Local caching provides most of the benefit.
- Code generators — Nx has scaffolding generators, but the generated code still needs maintenance. A template directory and a shell script often work just as well.
- Strict module boundary enforcement — tools like Nx's
enforce-module-boundariescan prevent bad imports, but teams often end up fighting the rules before the repo is large enough to need them. Add it when boundary violations actually become a problem.
Common Pitfalls
Circular dependencies. Package A depends on B, which depends on A. TypeScript may let this slide, but builds will break in confusing ways. Extract the shared code into a third package.
The catch-all utils package. It is tempting to put all shared code into one @myorg/utils package. Over time it becomes a dumping ground. Split by domain early: @myorg/date-utils, @myorg/validation, etc.
Incorrect dependency graph. If build tasks do not declare their dependencies correctly in turbo.json, we get nondeterministic failures. Set up dependsOn properly.
When a Monorepo Is Not Worth It
- A single project with no shared code — a monorepo adds overhead for zero benefit.
- Projects with completely different toolchains (e.g. a Go backend and a React frontend with nothing shared) — separate repos are fine.
- A team without bandwidth to maintain the tooling — a poorly maintained monorepo is worse than well-maintained polyrepos.
Getting Started
The simplest path:
mkdir my-monorepo && cd my-monorepo
pnpm init
mkdir -p packages/shared apps/webSet up the workspace, add a shared package, wire it into an app with workspace:*, and see how it feels. We can layer on Turborepo, shared configs, and CI integration as needs arise.
Start simple, add complexity only when the pain justifies it.