A monorepo pays off when several apps and packages change together: one pull request can update a shared library and every app that uses it, with one lockfile, one set of lint and TypeScript rules, and one CI pipeline. For JavaScript and TypeScript, pnpm workspaces plus Turborepo is the lowest-friction setup that holds up in production: pnpm gives you strict dependency linking and a single version policy, and Turborepo gives you a cached task graph and affected-only CI. Monorepos go wrong in predictable ways: CI that rebuilds everything, packages that reach into each other's internals, and cache keys that ignore environment variables and serve the wrong build.
Key takeaways
- Choose a monorepo when cross-project changes are common and the teams already work together. Choose separate repositories when lifecycles, languages or access rules differ.
- pnpm workspaces plus Turborepo covers most JavaScript and TypeScript repositories. Nx earns its extra concepts when you need generators and enforced module boundaries across many teams.
- Declare task inputs, outputs and environment variables precisely. A cache that misses an input rebuilds too often; a cache that misses an environment variable ships the wrong build.
- Run CI only on affected packages, against the merge base, with a remote cache that verifies artifact signatures.
- Enforce boundaries in code: packages expose an explicit public API through
exports, and apps never import from other apps.
Monorepo or multiple repositories?
The benefit of a monorepo is not that the code is in one place. It is that a change spanning several projects is one commit, one review and one CI run, so there is never a window where the shared API client is updated and the mobile app has not caught up.
| Signal | Monorepo | Separate repositories |
|---|---|---|
| Changes regularly touch more than one project | Strong fit | Coordination tax on every change |
| Shared UI library, API client or types | Strong fit | Needs publishing and version bumps for every change |
| Same language and toolchain | Strong fit | Either works |
| Mixed languages with separate build systems | Possible, needs Bazel-class tooling | Usually simpler |
| Strict access control (contractors, regulated code) | Hard, Git has no per-directory read permissions | Strong fit |
| Independent release cycles and owners | Works, but you gain little | Strong fit |
If most of your pull requests touch one project, a monorepo mostly adds tooling. If most of your bugs come from version mismatches between your own packages, a monorepo removes that whole class of bug.
Choosing the tooling
| Tool | What it is | Use when |
|---|---|---|
| pnpm workspaces | Package manager with workspace linking, catalogs, strict node_modules | Always, as the base layer for JS and TS |
| Turborepo | Task runner with a dependency-aware graph, local and remote caching | A handful of apps and packages, small platform team |
| Nx | Task runner plus generators, module boundary rules, plugins | Many teams, strict architecture rules, appetite for more concepts |
| Bazel, Buck2, Pants | Hermetic, language-agnostic build systems | Large polyglot repositories with dedicated build engineers |
Boring tech wins here. pnpm plus Turborepo is the default we reach for: it reads the package.json scripts you already have and adds caching on top, so there is little to unlearn if you outgrow it.
Workspace layout
Separate deployable apps from shared packages, and split configuration into its own packages so every project extends the same rules.
acme/
apps/
web/ Next.js app
admin/ internal dashboard
api/ Node service
packages/
ui/ shared React components
api-client/ generated, typed client for apps/api
db/ schema, migrations, query layer
config-typescript/ shared tsconfig bases
config-eslint/ shared lint config
package.json
pnpm-workspace.yaml
turbo.jsonPin the package manager in the root package.json (use the exact version your team runs) so every machine and CI runner uses the same one:
{
"name": "acme",
"private": true,
"packageManager": "pnpm@12.9.1",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"turbo": "^2.0.0"
}
}Workspace settings live in pnpm-workspace.yaml; since pnpm 11, pnpm no longer reads settings from the pnpm field of package.json. That file lists the workspace packages, allows lifecycle scripts only for the dependencies that genuinely need them, and holds catalogs that enforce one version of shared dependencies across the workspace:
packages:
- "apps/*"
- "packages/*"
allowBuilds:
esbuild: true
sharp: true
catalog:
react: ^19.0.0
react-dom: ^19.0.0
typescript: ^6.0.0
vitest: ^5.0.0Since pnpm 10, dependency install scripts do not run unless you allow them, which closes a common supply chain path. pnpm 11 replaced the older onlyBuiltDependencies list with the allowBuilds map, and pnpm approve-builds writes it for you. Our software supply chain guide covers why that default matters.
Packages then declare "react": "catalog:" instead of a version. Upgrading React becomes a one-line change reviewed once, instead of a drift between apps that nobody notices until two copies of React end up in one bundle.
Configuring Turborepo so the cache tells the truth
turbo.json describes how tasks depend on each other and what goes into each cache key. Every mistake here shows up later as either slow CI or a wrong build.
{
"$schema": "https://turborepo.dev/schema.json",
"globalDependencies": [".env.example", "tsconfig.base.json"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "!**/*.md", "!**/*.test.ts"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"env": ["API_URL", "SENTRY_RELEASE"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}What each part does:
dependsOn: ["^build"]builds a package's dependencies before the package itself.inputscontrols which files change the hash.$TURBO_DEFAULT$keeps the default (all tracked files in the package), and the negations stop README edits and test files from invalidating builds.outputsis what gets restored on a cache hit. Exclude.next/cache, which is large and not needed to restore a build.envadds environment variable values to the hash. This is the line people forget. IfAPI_URLis baked into the bundle but not declared, a build made for staging can be restored from cache for production. Turborepo 2 runs tasks in strict environment mode by default, so undeclared variables are also filtered out of the task's environment, which surfaces the mistake early.
Internal packages: just-in-time or compiled
There are two ways to share code inside the repository, and the choice affects build speed and tooling.
| Just-in-time package | Compiled package | |
|---|---|---|
exports points at | TypeScript source | Built dist output |
| Build step | None, the consuming app compiles it | Own build task, cached by Turborepo |
| Best for | UI components and utilities used by bundled apps | Packages used by Node services, or published to a registry |
| Watch out for | Every consuming bundler must compile TS; Next.js does this for workspace packages, except webpack builds of the Pages Router, which need transpilePackages | Stale dist during local dev unless dev runs a watcher |
A just-in-time UI package is the simplest thing that works for frontend code:
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./button": "./src/button.tsx",
"./dialog": "./src/dialog.tsx",
"./styles.css": "./src/styles.css"
},
"peerDependencies": {
"react": "catalog:"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "catalog:"
}
}The exports map is also your public API. Anything not listed cannot be imported from outside the package, which is exactly the boundary you want. Apps consume it with "@acme/ui": "workspace:*" and a normal import. If the package later grows into a real design system, our design systems guide covers packaging it for external consumers.
Internal packages that are never published do not need versions. Keep them at 0.0.0, reference them with workspace:*, and they always resolve to the current code. Use Changesets only for packages that someone installs from a registry.
Enforcing boundaries
Without rules, a monorepo becomes a monolith with extra steps: apps/admin imports a helper from apps/web/src/lib, and now two apps cannot be changed independently. Encode the rules:
- Apps never import from other apps. Shared code moves into a package.
- Packages import each other only through
exports. No deep paths intosrc. - Lower layers never import higher layers.
dbmust not import fromui.
With Nx, @nx/enforce-module-boundaries handles this through project tags. With Turborepo, a dependency-cruiser rule in CI does the job:
module.exports = {
forbidden: [
{
name: "no-app-to-app",
severity: "error",
from: { path: "^apps/([^/]+)/" },
to: { path: "^apps/([^/]+)/", pathNot: "^apps/$1/" },
},
{
name: "no-deep-package-imports",
severity: "error",
from: { path: "^apps/" },
to: { path: "^packages/[^/]+/src/" },
},
],
};Pair it with a CODEOWNERS file so changes to shared packages request review from the people who own them. A shared package without an owner becomes everyone's dumping ground.
CI that only does the work a change requires
The CI goal is simple: a pull request runs tasks only for the packages it changed and everything that depends on them, and pulls everything else from the remote cache.
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_SIGNATURE_KEY }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Affected tasks only
run: |
BASE=${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event.before }}
pnpm turbo run lint typecheck test build --filter="...[$BASE]"Details that matter:
- Compare against the base branch, not
HEAD~1. A filter ofHEAD~1misses earlier commits when a push contains several. Fetch full history so the base ref exists. - The
...prefix includes dependents. Changepackages/uiand every app that uses it is rebuilt and tested, which is the point. - Sign cache artifacts. With
"remoteCache": { "signature": true }inturbo.jsonand a signature key in CI, Turborepo verifies artifacts before restoring them. Without it, anyone who can write to the cache can plant a build output. - Do not give untrusted pull requests a writable cache token. Forked pull requests should read from the cache at most.
Our GitHub Actions CI/CD guide covers the rest of the pipeline: environments, deployment gates and secrets.
Deploying one app: prune, then build
A Docker build that copies the whole repository invalidates its install layer whenever any file changes anywhere. turbo prune produces just the slice of the repository one app needs, split so dependency installation stays cached:
FROM node:24-alpine AS base
RUN npm install -g pnpm turbo
FROM base AS pruner
WORKDIR /repo
COPY . .
RUN turbo prune web --docker
FROM base AS builder
WORKDIR /repo
COPY --from=pruner /repo/out/json/ .
COPY --from=pruner /repo/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm install --frozen-lockfile
COPY --from=pruner /repo/out/full/ .
RUN pnpm turbo run build --filter=web
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /repo/apps/web/.next/standalone ./
COPY --from=builder /repo/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /repo/apps/web/public ./apps/web/public
USER node
CMD ["node", "apps/web/server.js"]The install layer depends only on the pruned package.json files and lockfile, so source edits reuse it. The runner stage assumes Next.js with output: "standalone", which traces exactly the files the server needs. On Vercel or similar platforms, configure each project's ignored build step so a commit that does not affect an app does not deploy it.
What goes wrong in production
- Wrong cache hits. An environment variable that changes the build output is missing from
env, and a staging build is restored in production. Declare every build-time variable, and treat an unexplained cache hit after a config change as a bug. - Cache misses on every run. A generated file, timestamp or absolute path lands in the inputs, so the hash never matches. Use
turbo run build --dry=jsonto see what goes into a task's hash, and--summarizeto compare two runs. - Phantom dependencies. Code imports a package it never declared and works because something else installed it. pnpm's strict linking catches most of these locally; the rest break when a transitive dependency is removed. Run
pnpm installfrom a clean checkout in CI. - TypeScript gets slow. One giant type-check over the whole repository grows with every package. Give each package its own
typechecktask so Turborepo caches it, and keeptsconfigpaths out of the picture in favor of real packageexports. Our TypeScript practices guide covers shared configs. - Lockfile conflicts on every merge. Group dependency updates with Renovate or Dependabot so one pull request updates a dependency everywhere, and regenerate the lockfile rather than hand-merging it.
- Release coupling by accident. Everything is in one repository, so someone decides everything must deploy together. Keep deploys per app, triggered by the affected graph.
- Clone and checkout get slow. At large sizes, use partial clone (
git clone --filter=blob:none) andgit sparse-checkout set apps/web packages/uifor engineers who work in one corner.
Migrating from multiple repositories
Move one repository at a time, and keep history so git blame still works:
git clone https://github.com/acme/web.git web-migrate
cd web-migrate
git filter-repo --to-subdirectory-filter apps/web
cd ../acme
git remote add web-migrate ../web-migrate
git fetch web-migrate
git merge --allow-unrelated-histories web-migrate/mainThen replace the old published dependency on the shared package with workspace:*, delete the duplicated lint and TypeScript configs in favor of the shared ones, and add the app to CI. Start with the two projects that change together most often. That pair proves the value, and the rest follow on evidence instead of a mandate.
Frequently Asked Questions
When should you use a monorepo instead of multiple repositories?
Use a monorepo when several apps and packages change together and are owned by teams that already collaborate, for example a web app, a mobile app and the shared libraries they use. Separate repositories are better when projects have independent lifecycles, different languages and toolchains, or strict access boundaries. The deciding question is how often one change has to touch more than one project.
Is Turborepo or Nx better for a monorepo?
Turborepo is smaller and easier to adopt: it reads your existing package.json scripts and adds a cached task graph. Nx does more, including code generators, module boundary lint rules and a richer plugin ecosystem, at the cost of more concepts. For a JavaScript and TypeScript repo with a handful of apps, Turborepo is usually enough; for many teams and strict architecture rules, Nx earns its weight.
Do monorepos slow down CI?
Only if CI builds and tests everything on every change. With affected-only filtering, a remote cache and correctly declared task inputs, a pull request runs work only for the packages it changed and their dependents. The common cause of slow monorepo CI is cache keys that miss inputs, which forces rebuilds, or that miss environment variables, which causes wrong cache hits.
Should internal packages in a monorepo be versioned?
Usually not. Internal packages that are only consumed inside the repository can stay private, reference each other with the workspace protocol, and always resolve to the current code. Version and publish with a tool like Changesets only the packages that external consumers install from a registry.
How do you deploy a single app from a monorepo?
Build only that app and its dependencies, then ship a pruned artifact. Turborepo's prune command produces a subset of the repository and lockfile for one app, which keeps Docker layers small and cacheable. On platforms like Vercel, skip deployments for apps a commit did not affect.
Need help with your monorepo?
We set up repositories we then work in every day, so we care about the parts that only hurt later: cache correctness, CI time and boundaries that survive a growing team. If you are consolidating repositories, fixing a monorepo whose CI has slowed to a crawl, or planning the structure before the second app arrives, talk to the senior engineer who would do the work.