
Monorepo Strategy: Managing Multiple Projects with Turborepo
Managing frontend, backend, and shared libraries in separate repos was sync hell. Setting up a monorepo with Turborepo changed everything.

Managing frontend, backend, and shared libraries in separate repos was sync hell. Setting up a monorepo with Turborepo changed everything.
Why is the CPU fast but the computer slow? I explore the revolutionary idea of the 80-year-old Von Neumann architecture and the fatal bottleneck it left behind.

How to deploy without shutting down servers. Differences between Rolling, Canary, and Blue-Green. Deep dive into Database Rollback strategies, Online Schema Changes, AWS CodeDeploy integration, and Feature Toggles.

ChatGPT answers questions. AI Agents plan, use tools, and complete tasks autonomously. Understanding this difference changes how you build with AI.

Solving server waste at dawn and crashes at lunch. Understanding Auto Scaling vs Serverless through 'Taxi Dispatch' and 'Pizza Delivery' analogies. Plus, cost-saving tips using Spot Instances.

There was a time when I managed projects across three separate repositories: frontend-app, backend-api, and shared-utils. On the surface, it seemed like a clean structure. Frontend with frontend, backend with backend, shared code isolated. It even felt aligned with the principle of separation of concerns.
The problems started when actual work began. Changing an API spec meant touching three different places. First, modify type definitions in shared-utils. Publish to npm. Install the new version in backend-api. Then update in frontend-app again. A single logical change exploded into four commits and three pull requests.
Version management became another nightmare. I started leaving notes in README files: "This frontend version requires shared-utils 2.3.1 or higher." But who reads those on time? Things worked locally but broke in production, repeatedly. The culprit was always version mismatch.
That's when it clicked. This wasn't a structural problem—it was a coordination cost problem. Like trying to play a single song with three instruments in separate rooms. They needed to be in the same space. That space was a monorepo.
Initially, I understood monorepo simply as "dumping multiple projects into one repo." I was wrong. The essence lies in atomic changes.
In a polyrepo setup (multiple independent repositories), changes propagate with delays. You modify A, publish it, then wait for B to update. During that gap, an inconsistent state exists. This inconsistency was the breeding ground for bugs.
In a monorepo, all changes land in a single commit. When you change an API type, the backend implementation and frontend calling code get updated together in the same PR. One merge brings the entire system to a consistent state. Like a database transaction.
This concept truly resonated after I actually did the migration. I opened a PR changing type definitions, and TypeScript immediately flagged every affected file. Three files in frontend, two in backend. Fixed them all at once, merged once. Done.
Monorepos aren't silver bullets. There are clear tradeoffs.
Polyrepo advantages:My case was clear-cut. Small team (basically solo), tightly coupled projects, lots of shared code. Monorepo was the answer.
When choosing monorepo tools, I compared Lerna, Nx, and Turborepo.
Lerna was the pioneer of early monorepo tooling, but maintenance stopped and then revived. It's primarily specialized for npm package publishing. Not quite what I needed.
Nx is powerful but heavy. Plugin system, code generators, dependency graph visualization—tons of features. But steep learning curve and complex configuration. Coming from the Angular ecosystem, it felt slightly alien to my React/Next.js-centric stack.
Turborepo was simple. A tool focused on "fast builds." After Vercel acquired it, Next.js integration improved. Just one config file (turbo.json), intuitive concepts. I chose it.
I followed Turborepo's standard convention:
my-monorepo/
├── apps/
│ ├── web/ # Next.js web app
│ ├── admin/ # Admin dashboard
│ └── api/ # Express.js API
├── packages/
│ ├── ui/ # Shared UI components
│ ├── utils/ # Utility functions
│ ├── types/ # TypeScript type definitions
│ └── config/ # Shared configs (ESLint, TypeScript)
├── turbo.json
├── package.json
└── pnpm-workspace.yaml
apps/ contains actual deployable applications. packages/ contains internal libraries. This clear distinction means even new team members can understand the structure immediately.
Each app and package has its own package.json. For example, apps/web/package.json:
{
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.0.0",
"react": "^18.2.0",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*"
}
}
The key part: "@repo/ui": "workspace:*". This references internal packages using pnpm's workspace protocol. No need to publish to npm registry. They're linked locally.
Root pnpm-workspace.yaml:
packages:
- 'apps/*'
- 'packages/*'
That's it. pnpm recognizes all directories matching these patterns as workspaces.
I chose pnpm for three reasons:
One pnpm install at the root installs dependencies for all apps and packages. Felt like magic.
The heart of Turborepo is turbo.json, where you define build pipelines:
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"lint": {
"cache": false
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
}
}
}
Core concepts:
Dependency graph: "dependsOn": ["^build"] means "before I build, packages I depend on must build first." The ^ symbol indicates dependency direction.
Caching: Turborepo hashes task inputs (files, environment variables) to create cache keys. If inputs haven't changed, outputs get reused. The "outputs" array specifies what results to cache.
Incremental builds: Only rebuild changed packages and their dependents. Even with 100 packages, if only 1 changes, only 1 (plus its dependents) rebuilds.
Running pnpm turbo build actually looks like:
• Packages in scope: web, admin, api, @repo/ui, @repo/utils, @repo/types
• Running build in 6 packages
• Remote caching enabled
@repo/types:build: cache hit, replaying output [1.2s]
@repo/utils:build: cache hit, replaying output [0.8s]
@repo/ui:build: cache miss, executing [12.3s]
web:build: cache miss, executing [34.2s]
admin:build: cache miss, executing [28.7s]
api:build: cache hit, replaying output [2.1s]
Tasks: 6 successful, 6 total
Cached: 4, Remote: 2
Time: 36.4s >>> FULL TURBO
The satisfaction of seeing "FULL TURBO" is real. Thanks to caching, build time dropped from 5 minutes to 30 seconds.
Local cache only exists on my machine. CI environments do clean builds every time. Remote caching solves this.
Turborepo provides Vercel's cloud cache. Setup was simple:
pnpm turbo login
pnpm turbo link
Now in CI:
Building the same commit multiple times (like PR reruns) finishes almost instantly from the second run onward. CI costs dropped noticeably.
If security is a concern, self-hosting is possible with S3 or GCS backends.
The biggest value of monorepos is code reuse. I created three types of internal packages.
1. @repo/ui - Shared UI ComponentsBasic components like buttons, inputs, modals. Built on Tailwind CSS and shadcn/ui.
// packages/ui/src/button.tsx
export function Button({ children, variant = 'primary', ...props }) {
return (
<button
className={cn(
'px-4 py-2 rounded font-medium',
variant === 'primary' && 'bg-blue-500 text-white',
variant === 'secondary' && 'bg-gray-200 text-gray-800'
)}
{...props}
>
{children}
</button>
)
}
Web app and admin dashboard share the same design system. Changing button styles instantly reflects across all apps.
2. @repo/types - TypeScript Type DefinitionsAPI specs, database schemas, business entity types.
// packages/types/src/user.ts
export interface User {
id: string
email: string
name: string
role: 'admin' | 'user'
createdAt: Date
}
export type CreateUserInput = Omit<User, 'id' | 'createdAt'>
export type UpdateUserInput = Partial<CreateUserInput>
Frontend and backend use the same types. When API response types change, TypeScript errors immediately appear in frontend code. Catching mismatches at compile time.
3. @repo/utils - Utility FunctionsPure functions for date formatting, validation, constants, etc.
// packages/utils/src/date.ts
export function formatDate(date: Date, format: 'short' | 'long' = 'short') {
if (format === 'short') {
return date.toISOString().split('T')[0] // YYYY-MM-DD
}
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
Duplicate code vanished. Previously, I copy-pasted the same functions across repos.
Complex build orchestration gets handled declaratively.
For example, if web app uses @repo/ui, and @repo/ui uses @repo/utils:
@repo/utils → @repo/ui → web
Running pnpm turbo build makes Turborepo automatically:
@repo/utils@repo/ui (after utils completes)web (after ui completes)No need to manually specify order. The dependency graph determines execution sequence. Like Make or Bazel, but much simpler.
Parallel execution is automatic too. If admin and api don't depend on each other, they build simultaneously. Maximizing CPU core usage.
Core realizations from building a monorepo with Turborepo:
Monorepo isn't "one repo," it's "one source of truth." All code exists in the same timeline. Version mismatches become structurally impossible.
Sharing is an asset, not a cost. In polyrepos, code sharing was overhead (publishing, version management). In monorepos, it's just one import line.
Build speed is solved by tooling. There's a myth that monorepos are slow, but modern tools like Turborepo can actually be faster with caching and incremental builds.
Boundaries don't disappear. Monorepo doesn't mean everything gets mixed together. With apps/ and packages/, plus clear API boundaries, modularity remains intact.
After running this in production for six months, development velocity improved noticeably. When adding new features, the "which repo do I start with?" dilemma vanished. Just write code where it's needed. No worrying about refactoring scope either. TypeScript flagged every affected location.
Monorepos aren't about scale. They're not luxury reserved for massive organizations like Google. Small teams, even solo developers handling multiple related projects, can benefit from monorepos. The key is minimizing coordination costs between projects.
Turborepo made that journey remarkably easy. You can start with one config file. If you're exhausted from juggling multiple repos and version synchronization, it's worth trying.