Monorepos in JavaScript & TypeScript: A Practical Guide
How I structure, build, and maintain JavaScript/TypeScript monorepos.
In this post I will say a word on how I am structuring and maintaining monorepos. A monorepo is mostly a tooling decision, so this note is mostly about the tooling. It solves version mismatches, duplicated configs, and CI pipelines which you copy-paste from one repo to the next, but the package manager and the task runner are the things which actually do that work.
So a monorepo is one repository which holds several separate projects or packages. A monolith is one deployable unit, and a monorepo is not that. The packages inside it still deploy and version on their own, and the boundaries between them stay clear.
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. And packages/ folder holds the shared libraries which those apps use. No tool enforces this split. It is just what most of teams end up doing.
What you actually get out of it
Atomic changes, mostly. You can refactor a shared utility and update every caller in a single PR, with no release to cut and no version bumps fanned out across four repos. The rest follows from having one tree. One ESLint config and one TypeScript base instead of a copy per project. A single lockfile and one node_modules to audit. And no publishing to npm just to share a helper between two internal apps.
Pick the package manager first
The three major package managers all support workspaces, but the differences between them matter.
npm workspaces are built in since npm 7. Fine for small setups, slower and less strict than the rest.
// package.json (root)
{
"name": "my-monorepo",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}Yarn (Berry/v4) has Plug'n'Play. Clever idea, but it breaks any tool which expects a real node_modules. Use it if your team already runs Yarn and is comfortable with PnP. Otherwise it adds trouble which you don't need.
pnpm is the one to reach for. It is fast, and it won't let you import a dependency which you never declared (no phantom deps). Its workspace support is the best of the three. The content-addressable store also keeps disk usage low, which you notice once you have a dozen packages that each pull in React.
# 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 takes package names, directory paths, or a git diff. For example, --filter ...[HEAD~1] targets only the packages which changed since the last commit.
One tsconfig to extend
Instead of copy-pasting tsconfig.json across packages, I suggest to 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
}
}And 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 anything else which you would otherwise copy around.
Internal packages don't need a build
Shared packages which are used only inside the repo don't need a build step at all. Point the package's exports straight 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 the apps (Next.js, Vite, and similar tools) transpile the imported source through their own bundler anyway. So there is no separate build step in development, and a change to a shared util shows up right away in any app which imports it.
This changes the moment you publish a package to npm. Then you need a real build with tsc or tsup. For purely internal packages, skip it.
Running tasks: Turborepo or Nx
Past a few packages, you start to care about running builds and tests in the right order, and about not redoing work which hasn't changed.
Turborepo is the simpler option and needs little configuration:
// 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 runs in parallel what it can. The cache uses a content hash as its key, so if the inputs haven't changed it just replays the previous 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 and more opinionated: a plugin system, code generators, affected-based testing, a cloud cache. It pays off on large setups, somewhere north of fifty packages. Below that, I would start with Turborepo. Moving from Turborepo to Nx later is not hard. Going the other way is the painful one.
Wiring packages to each other
Reference internal packages in 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 and Yarn) tells the package manager to resolve from the local workspace. npm workspaces use plain "*" and the symlink happens automatically.
Version conflicts on shared external dependencies are where this gets painful. If @myorg/web uses React 19 but @myorg/ui lists React 18 as a peer dependency, you can end up with two copies of React loaded at once, and the resulting runtime bug takes a long time to track down. So keep peer dependency ranges aligned, or pin a single version with overrides or resolutions.
What this looks like in a real repo
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 down to the packages which actually changed:
# .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 run tasks only for the packages which changed since origin/main. Together with caching, this is the thing which keeps CI fast as the repo grows.
Features you can skip for now
Few words about features which get a lot of marketing and matter less than the marketing suggests. Remote caching (both Turborepo and Nx offer it) sounds great, but the hit rate is usually lower than you would hope, and local caching already gives you most of the profit. Nx's code generators are handy for scaffolding, except you still maintain the code which they produce by hand, and a template directory plus a short shell script often does the same job. Strict module-boundary enforcement, like Nx's enforce-module-boundaries, is something which teams turn on too early and then spend time fighting. Add it the day when someone actually imports something they shouldn't.
Where it tends to go wrong
Circular dependencies. Package A depends on B, which depends back on A. TypeScript will sometimes allow this, and then the build breaks with an error which doesn't point to the real cause. Pull the shared code out into a third package.
The catch-all utils package. Everyone is tempted to dump shared code into one @myorg/utils, and it always turns into a mess. So split by domain from the start: @myorg/date-utils, @myorg/validation, and so on.
A dependency graph which is wrong. If your build tasks don't declare their dependencies correctly in turbo.json, you get failures which come and go between runs. So get dependsOn right.
When to skip the monorepo
Monorepo has a cost, so it is not always worth paying. A single project with no shared code gets nothing from one. Projects on completely different toolchains with nothing in common, say a Go backend and a React frontend, are better off apart. And if nobody on the team has time to look after the tooling, well-kept separate repos beat a monorepo which nobody maintains. A neglected monorepo is worse than no monorepo.
If you do want one, the simplest path:
mkdir my-monorepo && cd my-monorepo
pnpm init
mkdir -p packages/shared apps/webSo set up the workspace, add a shared package, connect it to an app with workspace:*, and watch a change in the shared package show up in the app. Turborepo, shared configs, and CI filtering come later.
As a conclusion, I would strongly recommend to start with pnpm and Turborepo, keep packages split by domain, and add the heavier features only when you actually need them.