Where Turborepo Fits in a NestJS Monorepo and When to Skip It
How Turborepo, pnpm workspaces and NestJS divide the work in a TypeScript monorepo: shared packages, task graphs, caching, coupling risks and when to skip it.
This article is published in English.
Sources: Turborepo + NestJS: The Monorepo Architecture That Makes Large TypeScript Systems Manageable
A NestJS backend usually starts as a single API with one package.json, one node_modules folder and one CI pipeline. As the product grows, authentication, orders, payments, inventory and notifications split into separate services, and the operational question shifts from "how do we scale this service?" to "how do we manage seven TypeScript applications without copying the same configuration, types and pipelines seven times?". This article explains what a monorepo, pnpm and Turborepo each contribute, how the task graph and cache save work, what is safe to share, and when the setup costs more than it returns.
Keep one boundary in mind throughout. Turborepo does not create microservices, does not replace NestJS and does not make an architecture scalable by itself. It solves a narrower problem: coordinating build-style work across many packages in one repository.
From one service to many repositories
A growing platform's repository might end up holding a folder per service:
auth-service/
order-service/
payment-service/
inventory-service/
notification-service/
shipment-service/
api-gateway/
Splitting services helps with scaling and ownership, but each service now brings its own dependencies, config, build, tests, lint rules and pipeline.
The coordination cost of a polyrepo
Picture a typical e-commerce system in which a gateway fronts the auth, order and product services, the order service calls payment and inventory, and payment triggers notifications:
API Gateway
|
┌─────────────────┼─────────────────┐
↓ ↓ ↓
Auth Order Product
|
┌────────┴────────┐
↓ ↓
Payment Inventory
|
↓
Notification
A common first move is to give each service its own repository, the polyrepo approach:
github.com/company/auth-service
github.com/company/order-service
github.com/company/product-service
github.com/company/payment-service
github.com/company/inventory-service
github.com/company/notification-service
This is a legitimate architecture. The friction appears when you look inside each repository and find the same scaffolding repeated everywhere:
package.json
tsconfig.json
eslint.config.js
Dockerfile
CI configuration
README.md
node_modules
and, worse, the same kinds of code needed by several services at once:
DTOs
TypeScript types
logging utilities
configuration
authentication helpers
database utilities
Kafka/RabbitMQ abstractions
testing utilities
Now every change to shared material becomes a synchronization exercise. When a shared type changes, which repositories must be updated? When a dependency is bumped, which services need the upgrade? When a lint rule or CI workflow changes, how many copies must be edited and kept in step? The number of services is not the real problem. Coordination is.
A monorepo is a strategy; Turborepo is a tool
The alternative is to keep all applications and shared code in one repository, with applications under apps/, internal libraries under packages/, and workspace and task configuration at the root:
ecommerce-platform/
│
├── apps/
│ ├── api-gateway/
│ ├── auth-service/
│ ├── order-service/
│ ├── product-service/
│ ├── payment-service/
│ ├── inventory-service/
│ └── notification-service/
│
├── packages/
│ ├── types/
│ ├── logger/
│ ├── config/
│ ├── database/
│ └── messaging/
│
├── package.json
├── pnpm-workspace.yaml
└── turbo.json
That layout is a monorepo: a decision about where code lives. Turborepo is a tool that coordinates the work inside it. You can have one without the other.
What Turborepo actually does
Turborepo is a build system for JavaScript and TypeScript monorepos. It orchestrates tasks that exist in many packages at once, such as:
build
test
lint
type-check
dev
Without it, building several services means visiting each one by hand. First the auth service:
cd apps/auth-service
pnpm build
then the others in turn (the source snippet runs two commands onto one line; each cd and pnpm build belongs on its own line):
cd ../order-service
pnpm buildcd ../payment-service
pnpm build
With Turborepo, you declare tasks once and run a single command from the root:
turbo build
It then works out which tasks are needed, which can run concurrently, and which results can be restored from cache instead of recomputed.
Layering NestJS apps and shared packages
A production-leaning version of the repository gives each Nest application its own src/ and package.json, so every app is a proper workspace package:
ecommerce-platform/
│
├── apps/
│ │
│ ├── api-gateway/
│ │ ├── src/
│ │ └── package.json
│ │
│ ├── auth-service/
│ │ ├── src/
│ │ └── package.json
│ │
│ ├── order-service/
│ │ ├── src/
│ │ └── package.json
│ │
│ └── payment-service/
│ ├── src/
│ └── package.json
│
├── packages/
│ │
│ ├── types/
│ ├── logger/
│ ├── config/
│ ├── database/
│ └── messaging/
│
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
Conceptually there are two layers: applications, which are NestJS services carrying business capabilities, and packages, which hold reusable code.
Monorepo
|
┌─────────┴─────────┐
↓ ↓
Apps Packages
| |
NestJS Shared Code
Apps implement features, packages provide shared building blocks, and Turborepo coordinates tasks across both.
Internal packages as a single source of truth
The strongest everyday benefit of a monorepo is internal packages. A types package, for example, can hold the models several services agree on:
packages/
└── types/
├── src/
│ ├── user.ts
│ ├── order.ts
│ ├── payment.ts
│ └── pagination.ts
└── package.json
Any Nest service can then import from it by package name, resolved through the workspace rather than a registry:
import { OrderStatus } from '@repo/types';
instead of pasting the same enum into every service and hoping the copies never diverge:
enum OrderStatus {
PENDING = 'pending',
PAID = 'paid',
CANCELLED = 'cancelled',
}
When the enum gains a value, the compiler flags every affected place in the same change.
Sharing contracts is not the same as sharing ownership
Sharing is easy in a monorepo, which is exactly why it needs discipline. The order service's domain logic belongs to the order service:
order-service
↓
order-domain
It should not quietly migrate into a package:
packages/
└── order-domain/
that other services then import:
payment-service
inventory-service
notification-service
Once payment, inventory and notification code depend on the order domain's internals, a change to order rules can break three unrelated deployments. The rule of thumb: share infrastructure and contracts deliberately, and never share business ownership by default. Reasonable candidates for shared packages look like this:
@repo/types
@repo/logger
@repo/config
@repo/testing
@repo/messaging
Business logic should normally stay in the service that owns it. For a deeper treatment of that trade-off, see duplication versus coupling: deciding when shared code should exist.
The dependency graph decides what runs
Turborepo reads the dependency relationships declared in each workspace's package.json. In this example both services depend on the types package, and the order service also depends on a database package:
@repo/types
/ \
/ \
↓ ↓
auth-service order-service
|
↓
@repo/database
Because Turborepo knows this graph, if you edit a file inside the order service:
apps/order-service/src/orders/order.service.ts
there is no reason to rebuild an unrelated service such as:
notification-service
With filtering (for example turbo build --filter=...), you can scope a run to changed packages and their dependents.
Caching: skipping work you have already done
The first time you run a build:
turbo build
every required task actually executes:
auth-service → BUILD
order-service → BUILD
payment-service → BUILD
inventory-service → BUILD
Turborepo fingerprints each task's inputs (source files, dependencies, configuration, relevant environment variables) and stores the outputs and logs. Run the same command again with nothing relevant changed:
turbo build
and the result looks like this:
auth-service → CACHE HIT
order-service → CACHE HIT
payment-service → CACHE HIT
inventory-service → CACHE HIT
The outputs were restored from cache. For this to work, Turborepo must know which files a task produces, which is what the outputs setting in turbo.json declares (for a Nest build, typically dist/**). A task with undeclared outputs can report a cache hit while leaving you without the build artifacts.
Why CI benefits most
A naive pipeline repeats all work on every push:
Git Push
↓
Install dependencies
↓
Build everything
↓
Test everything
↓
Lint everything
↓
Deploy
With Turborepo in the pipeline, each task is first checked against the graph and the cache, and only tasks whose inputs changed are executed:
Git Push
↓
CI
↓
Turborepo
↓
┌─────────┴─────────┐
↓ ↓
Dependency Task Graph
Analysis ↓
Cache Check
↓
┌─────────┴─────────┐
↓ ↓
Cached Execute
Tasks Tasks
In large repositories this removes much redundant CI work.
Local cache versus remote cache
There are two levels of caching:
- Local cache. Your own machine reuses results of tasks it has already run, which speeds up everyday development.
- Remote cache. A shared cache lets developers and CI machines reuse each other's results.
The remote setup looks like this:
Developer A
│
↓
Remote Cache
↑
│
Developer B
↑
│
CI
If a developer has already built a service and CI later meets the same task inputs, CI downloads the result instead of rebuilding. Treat the remote cache as part of your supply chain: control who can write to it, because a poisoned artifact would be served to everyone.
Running independent work in parallel
Some services do not depend on each other's build output:
auth-service
order-service
product-service
Run sequentially, each waits for the previous one:
auth → finish
↓
order → finish
↓
product
Turborepo can instead run them concurrently, bounded by available CPU:
┌── auth ────┐
│ │
turbo ──┼── order ───┼──→ Done
│ │
└── product ─┘
Describing relationships between tasks
The same orchestration applies to every task type, not only builds:
turbo build
turbo test
turbo lint
turbo typecheck
Relationships are declared in turbo.json. In the configuration below, "^build" means "build this package's dependencies first", so shared packages compile before the services that import them. The test task depends on build without a caret, which refers to the same package's build. lint and typecheck have no dependencies and can run immediately in parallel.
{
"tasks": {
"build": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {},
"typecheck": {}
}
}
You describe how tasks depend on each other, and Turborepo derives an execution plan. In a real project you would also add outputs to cacheable tasks, as described above, and make sure each task name matches a script in the workspace packages' package.json files.
pnpm and Turborepo do different jobs
The pairing appears so often that it can look like one tool:
Turborepo + pnpm
They are separate layers. pnpm manages packages and workspaces: installing dependencies, linking internal packages and enforcing a strict node_modules layout. Turborepo orchestrates tasks and caches their results on top of that workspace.
pnpm
│
Workspace Management
│
↓
┌─────────────┐
│ Monorepo │
└──────┬──────┘
│
↓
Turborepo
│
┌───────┼────────┐
↓ ↓ ↓
Build Test Lint
For a closer look at pnpm itself, see npm vs pnpm: storage, speed and real-world trade-offs.
Why not just use Nest's built-in monorepo mode?
The Nest CLI has its own monorepo mode with applications and libraries, so adding Turborepo needs a reason. The difference is scope. Nest's workspace understands Nest projects. Turborepo works across any JavaScript or TypeScript packages in the workspace. As soon as the repository holds more than Nest services, for example frontends and plain Node workers:
apps/
├── web/ # Next.js
├── admin/ # Next.js
├── api-gateway/ # NestJS
├── order-service/ # NestJS
└── worker/ # Node.js
plus shared UI, types and config packages:
packages/
├── ui/
├── types/
├── config/
└── eslint-config/
you are no longer managing a Nest project. You are managing a TypeScript platform, which is where Turborepo becomes attractive.
Turborepo, Nx or the Nest CLI
There is no universally right choice. As general orientation: Nest's monorepo mode is the simplest option when everything is Nest. Turborepo is a comparatively thin layer of task running and caching on top of your package manager's workspaces. Nx also provides a project graph and caching, and adds a larger set of plugins, code generators and conventions, which some teams value and others find heavy. Choose based on your actual requirements, not on which tool is getting the most attention on social media.
When Turborepo is overkill
Suppose the whole system is one Nest API backed by PostgreSQL and Redis:
NestJS API
↓
PostgreSQL
↓
Redis
with a small team and a single deployable:
1 application
5 developers
1 deployment
Turborepo is probably unnecessary. A plain Nest repository is simpler, and introducing all of this:
Turborepo
pnpm workspace
multiple packages
complex task graph
adds cognitive load without paying for itself. Tools earn their place when the problem justifies them.
When it starts to pay off
The picture changes when several of these conditions hold at once:
Multiple applications
+
Shared packages
+
Shared tooling
+
Repeated CI tasks
+
Large TypeScript codebase
A platform with Next.js frontends, Nest APIs and background workers that all draw on shared packages is the typical case:
Platform
│
┌──────────────┼──────────────┐
↓ ↓ ↓
Next.js NestJS Workers
Web APIs Jobs
│ │ │
└──────────────┼──────────────┘
↓
Shared Packages
A mature layout with clear responsibilities
A grown-up Nest platform might add a separate area for infrastructure alongside apps and packages:
platform/
│
├── apps/
│ ├── api-gateway/
│ ├── auth-service/
│ ├── user-service/
│ ├── order-service/
│ ├── inventory-service/
│ ├── payment-service/
│ └── notification-service/
│
├── packages/
│ ├── config/
│ ├── logger/
│ ├── types/
│ ├── database/
│ ├── messaging/
│ └── testing/
│
├── infrastructure/
│ ├── docker/
│ └── deployment/
│
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
Each tool has one job. NestJS is the application framework:
NestJS
↓
Application framework
and the remaining layers divide up as follows (in the source snippet several entries have been run together; read it as pnpm for dependencies and workspaces, Turborepo for task orchestration and caching, Docker for containerization, Kafka or RabbitMQ for asynchronous messaging, and PostgreSQL or Redis for data and caching):
pnpm
↓
Dependency & workspace managementTurborepo
↓
Task orchestration + cachingDocker
↓
ContainerizationKafka / RabbitMQ
↓
Asynchronous communicationPostgreSQL / Redis
↓
Data & caching
Turborepo is not your infrastructure, your message broker or your deployment platform. It is the build and task layer that surrounds the monorepo.
One repository does not mean one deployment
A frequent worry is that putting everything in one repository sacrifices the independence microservices were supposed to provide. It does not. Where code lives and how it is deployed are separate decisions. A single repository can hold many services:
One Git repository
│
├── auth-service
├── order-service
├── payment-service
└── notification-service
while each still ships as its own container:
auth-service → Container A
order-service → Container B
payment-service → Container C
notification → Container D
and scales on its own terms:
order-service × 10
payment-service × 5
notification × 3
A monorepo is not a monolith, and a polyrepo does not automatically give you microservices. They are independent dimensions. What you do need is CI that deploys only the services whose code or dependencies changed, which the dependency graph makes possible.
The hidden risk: a common package that absorbs everything
Easy sharing cuts both ways. It often starts innocently with one package:
packages/common
which gradually collects helpers for every domain:
packages/common
├── AuthUtils
├── OrderUtils
├── PaymentUtils
├── InventoryUtils
├── NotificationUtils
├── DatabaseUtils
└── EverythingUtils
until every service depends on it. At that point you have built a distributed monolith inside a monorepo: a change to one helper forces a rebuild, retest and redeploy of everything. The top-level folders still look tidy:
apps/
packages/
but the architecture underneath is tightly coupled. Keep the rule strict: share infrastructure, contracts and genuinely generic utilities; keep domain logic inside the service that owns it.
A layered mental model
It helps to picture the system as layers, with business domains on top, shared contracts and infrastructure below, and monorepo tooling at the base:
Business Domains
│
┌───────────────┼───────────────┐
↓ ↓ ↓
Order Payment Inventory
│ │ │
└───────────────┼───────────────┘
↓
Shared Contracts
│
↓
Shared Infrastructure
│
↓
Monorepo Tooling
│
↓
Turborepo
Turborepo sits near the bottom. It should support your architecture, not shape it.
Deciding whether to adopt it
Work through four questions in order.
Do you have more than one application?
If not, the answer is straightforward:
Keep it simple.
If you do, move on.
Do the applications share substantial tooling or code?
If they do not:
Polyrepo may be perfectly reasonable.
If they do:
Monorepo becomes attractive.
Are build, test and lint times becoming expensive?
If yes:
Turborepo's orchestration and caching become valuable.
Do you run Next.js, NestJS, workers and shared TypeScript packages together?
If yes:
Turborepo becomes a strong candidate.
Predictability is the real benefit
Speed is a side effect. The deeper value is that a large repository becomes predictable. As applications multiply, someone has to answer, for every change:
What changed?
and a set of follow-up questions (the source runs them onto one line): what must be rebuilt, what must be retested, what can run in parallel, what can come from cache, and which packages depend on the change:
What needs to rebuild?What needs to be tested?What can run in parallel?What can come from cache?Which packages depend on this change?
Without tooling, engineers answer these by hand; with a monorepo build system, most of it is derived from the graph.
Key takeaways
Turborepo will not fix poor service boundaries, weak database design, tight coupling, bad API contracts, unclear domain ownership or badly designed microservices. What it does is keep a growing TypeScript repository manageable. The whole stack fits in one picture:
Business Architecture
│
↓
NestJS Apps
│
↓
Shared Packages
│
↓
pnpm
│
↓
Turborepo
│
┌──────────┼──────────┐
↓ ↓ ↓
Build Test Lint
│ │ │
└──────────┼──────────┘
↓
CI/CD
- A monorepo decides where code lives; pnpm manages the workspace; Turborepo orchestrates and caches tasks; NestJS builds the services. Keep those roles separate.
- Share types, config, logging and messaging abstractions; keep domain logic with its owning service to avoid a distributed monolith.
- Declare task relationships with
dependsOnand outputs inturbo.jsonso caching is both effective and correct. - Repository layout and deployment are independent: each service can still be deployed and scaled on its own.
- Adopt Turborepo when coordinating multiple TypeScript applications costs more than introducing the tool. For a single Nest API, it usually does not.