Monorepos in JavaScript & TypeScript: A Practical Guide
How to structure, build, and maintain JavaScript/TypeScript monorepos without losing your mind
At some point, every team that maintains more than two or three packages hits the same wall: keeping things in sync across multiple repositories is painful. Version mismatches, duplicated configs, copy-pasted CI pipelines — it adds up fast. Monorepos solve most of that. But they introduce their own complexity, and the tooling matters a lot more than people think.
Here's what I've learned from running JS/TS monorepos in production.
What a Monorepo Actually Is
A monorepo is a single repository that contains multiple distinct projects or packages. That's it. It's not a monolith — your packages can still be independently deployable, independently versioned, and have clear boundaries.
The typical structure looks like this:
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 isn't enforced by any tool — it's just what most teams settle on because it works.
Why You'd Want One
The real benefits are concrete:
- Atomic changes across packages. Refactor a shared utility and update every consumer in one PR. No coordinating releases across repos.
- Single set of configs. One ESLint config, one TypeScript base config, one Prettier setup. Extend from a shared
packages/configand you're done. - Simplified dependency management. One lockfile. One place to audit. One
node_modulestree (with hoisting). - Easier code sharing. No publishing to npm just to share a helper between two internal projects.
These aren't theoretical. If you've ever spent an afternoon bumping a shared library version across five repos and fixing the breakage in each one, you know.
Choosing Your Package Manager
This is the first real decision. All three major package managers support workspaces, but they differ in ways that matter.
npm workspaces — built in since npm 7. Works fine for small setups. Symlinks packages and hoists dependencies. The main downside: it's 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. If your team is already on Yarn and comfortable with PnP, great. Otherwise, I wouldn't switch to it just for monorepo support.
pnpm — this is what I'd recommend for most new monorepos. It's fast, strict about dependencies (no phantom deps), and has excellent workspace support. The content-addressable store means disk usage stays reasonable even with many packages.
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'With pnpm, running a command in a specific workspace:
# 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 is your best friend. It supports package names, directory paths, and even git-diff-based filtering (--filter ...[HEAD~1] to target only changed packages).
Setting Up Shared TypeScript Config
This is one of the first wins you get. Instead of copy-pasting tsconfig.json across packages:
// 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
}
}Then each package just extends it:
// apps/web/tsconfig.json
{
"extends": "@myorg/config/tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}Same pattern works for ESLint, Prettier, and any other config. One source of truth, minimal per-package overrides.
Internal Packages Without Publishing
Here's a pattern that saves a lot of time. Your shared packages don't need a build step if they're only consumed internally. You can point the package's main (or 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 your apps (Next.js, Vite, etc.) will transpile the imported source through their own bundler. No separate build step for shared packages during development. It's fast and the DX is excellent — change a shared util, and it's picked up immediately.
The caveat: if you need to publish a package to npm, you'll need a proper build step with tsc or tsup. But for internal packages, skip it.
Task Orchestration: Turborepo vs Nx
Once you have more than a handful of packages, running builds and tests in the right order (respecting the dependency graph) and caching results becomes important.
Turborepo is the simpler option. It does task orchestration and caching, and does it well. 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"]
}
}
}The "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 haven't changed, it replays the output. This makes CI dramatically faster.
# 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. If you're building something large (think 50+ packages) or want tight IDE integration and code generation, Nx might be worth the extra complexity. For most teams, Turborepo hits the sweet spot.
My honest take: start with Turborepo. If you outgrow it, you'll know — and migrating to Nx is doable. Going the other direction is harder.
Dependency Management Between Packages
Referencing internal packages is done 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"
}
}The workspace:* protocol (pnpm/Yarn) tells the package manager to resolve this from the local workspace, not from the registry. npm workspaces use "*" instead — the symlink happens automatically.
One thing that trips people up: version conflicts on shared externals. If @myorg/web uses React 19 but @myorg/ui specifies React 18 as a peer dependency, you'll get weird runtime bugs. Keep peer dependency ranges aligned, or use your package manager's overrides/resolutions field to force a single version.
A Real-World Folder Layout
Here's what a more fleshed-out monorepo looks like with some actual substance:
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
This is where monorepos really pay off. Instead of running every test suite on every commit, you 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]The --filter=...[origin/main] tells Turborepo to only run tasks for packages that have changed since origin/main. Combined with caching, this keeps CI fast even as the repo grows.
What's Overrated
A few things that get more hype than they deserve:
Remote caching — Turborepo and Nx both offer it. It sounds amazing (share build cache across the team!), but in practice the cache hit rate is lower than you'd expect. Local caching gives you most of the benefit. Try it, but don't assume it'll cut your CI time in half.
Code generators — Nx has scaffolding generators. They're convenient at first, but the generated code still needs to be maintained. A simple template directory and a shell script often work just as well.
Enforcing strict module boundaries — Tools like Nx's
enforce-module-boundarieslint rule can prevent packages from importing things they shouldn't. In theory, great. In practice, teams end up fighting the rules more than benefiting from them until the repo is large enough to genuinely need them. Start without it and add it when you actually have boundary violations causing problems.
Common Pitfalls
Circular dependencies. Package A depends on B, which depends on A. TypeScript will sometimes let this slide, but your build will eventually break in confusing ways. If you notice circular imports, extract the shared code into a third package.
The "everything depends on everything" package. It's tempting to throw all shared code into one @myorg/utils package. Over time, it becomes a dumping ground and every app depends on it. Split by domain early: @myorg/date-utils, @myorg/validation, etc.
Ignoring the dependency graph. If your build tasks don't declare their dependencies correctly in turbo.json (or equivalent), you'll get nondeterministic failures. Take the time to set up dependsOn properly.
When a Monorepo Isn't Worth It
Not every situation calls for one:
- If you have a single project with no shared code, a monorepo adds overhead for zero benefit.
- If different projects have wildly different toolchains (say, a Go backend and a React frontend with nothing shared), separate repos are fine.
- If your team doesn't have the bandwidth to maintain the tooling. A poorly maintained monorepo is worse than well-maintained polyrepos.
Getting Started — The Minimum Viable Setup
If you want to try this out without committing to a full toolchain, here's 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 your app with workspace:*, and see how it feels. You can layer on Turborepo, shared configs, and CI integration as needs arise.
The best monorepo setup is the one your team actually understands and maintains. Start simple, add complexity only when the pain justifies it.