TL;DR: A monorepo is one Git repository that holds multiple related projects — frontend, backend, shared code — in separate folders. Instead of three toolboxes scattered across the garage, you have one organized toolbox with labeled drawers. Tools like Turborepo and pnpm workspaces make the pieces work together. AI suggests it when your projects share code. You don't always need one — but when you do, it solves real pain.
The Toolbox Analogy: What a Monorepo Actually Is
Picture this: you're a contractor, and you've got three active jobs. You've got a toolbox in your truck, a toolbox at the first job site, and a third set of tools at your shop. Every time you need a specific wrench, you have to remember which site you left it at. You end up buying duplicates. You make changes to a process at one site and forget to do the same at the other two. Things get out of sync.
Now imagine you bring everything back to one organized toolbox — one big box, clearly labeled drawers for each job's specific stuff, but all the shared tools in the main compartment everyone reaches into. That's a monorepo.
A monorepo is a single Git repository that contains multiple projects. Instead of having a separate repository for your frontend, another for your backend, and a third for your shared utilities, they all live in one place — organized into folders, but managed together.
Here's what a typical monorepo folder structure looks like:
my-app/ ← one Git repository
├── apps/
│ ├── web/ ← Next.js frontend
│ │ ├── package.json
│ │ └── src/
│ └── api/ ← Express backend
│ ├── package.json
│ └── src/
├── packages/
│ └── shared/ ← types & utilities both apps use
│ ├── package.json
│ └── src/
│ ├── types.ts
│ └── utils.ts
├── package.json ← root workspace config
└── turbo.json ← Turborepo build config
Both apps/web and apps/api can import from packages/shared as if it were a regular npm package. When you update a type in shared/types.ts, the change is instantly available to both apps. No publishing to npm. No copy-pasting. One change, everywhere.
That's the core value proposition. Everything else — caching, pipelines, tooling — is built on top of this basic idea.
Separate Repos vs. Monorepo: The Real Comparison
Before monorepos make sense, you need to understand what problem they're solving. Let's look at what happens without one.
Say you're building a project management app. You've got:
- A Next.js frontend that shows tasks and projects
- An Express API that handles the database and auth
- A
Usertype that both sides need to agree on
With separate repositories, that User type lives in two places. When the API team (which might just be you wearing a different hat) decides to add a role field to User, you update the API's type definition. Then you have to remember to open the frontend repo, find the same type, and update it there too. Forget to do that — which happens — and your frontend is now out of sync with your API. You get runtime bugs that are confusing to trace back to source.
The traditional fix for this was to publish shared code as its own npm package. You'd create a third repo called something like my-app-shared-types, publish it to npm, then install it in both the frontend and backend. Every time you changed a type, you'd bump the version, publish, then update the dependency in both other repos.
That works at scale — it's how large organizations like Google and Meta operate — but it's a lot of ceremony for a solo builder or small team. You're managing three repositories, running three separate CI pipelines, and doing a package publish dance every time you change a type definition.
The monorepo collapses that ceremony. One repo. One pipeline. Shared code just works.
When Separate Repos Actually Win
To be fair, separate repositories aren't wrong — they're just a different tradeoff:
- Truly independent projects — if your marketing site and your SaaS app share nothing, keeping them separate is cleaner
- Different teams, different deployment schedules — separate repos give teams complete autonomy and reduce the risk of one team's bad commit blocking another's deploy
- Security or access control requirements — sometimes you genuinely can't give everyone access to every codebase
- Very large codebases — past a certain size, even optimized monorepos get slow to clone and work with
The honest answer is: both approaches work. The question is which tradeoff fits your current situation.
When Your AI Suggests a Monorepo (And Why)
If you've been building full-stack apps with AI assistance, you've probably seen something like this:
🧑💻 You asked Claude:
"Set up a project with a Next.js frontend and an Express API that share the same TypeScript types."
And Claude (or whatever tool you're using) came back with a proposal to initialize a Turborepo monorepo, create an apps/ folder for your frontend and backend, and a packages/shared folder for your types.
This is a reasonable suggestion because shared TypeScript types are exactly the use case monorepos excel at. The AI knows that if you put your frontend and backend in separate repos, you'll hit the type-sync problem within a week. A monorepo solves it cleanly from day one.
AI tools reach for monorepos in these scenarios:
- Full-stack TypeScript apps — frontend and backend sharing type definitions is the classic case
- Apps with a mobile component — React Native + web frontend + API all sharing business logic
- Design system + app combos — a shared component library used by multiple frontend apps
- Multi-tenant SaaS — a customer-facing app and an admin dashboard that share the same API and data models
- Microservices that share utilities — multiple backend services that all use the same auth, logging, or database utilities
The pattern in all these cases is the same: multiple deployable things that share code. That's the trigger. When the AI sees that, it reaches for the monorepo playbook.
Real Example: Next.js + Express + Shared Types
Let's make this concrete. Here's how a monorepo actually works for a full-stack TypeScript app — the kind AI will scaffold for you.
The Shared Package
Start with the shared types package. This is where the magic lives:
// packages/shared/src/types.ts
export interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'member';
createdAt: Date;
}
export interface Project {
id: string;
name: string;
ownerId: string;
members: User[];
}
export interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
}
// packages/shared/package.json
{
"name": "@my-app/shared",
"version": "0.1.0",
"main": "./src/index.ts",
"types": "./src/index.ts"
}
The Express API
The backend imports directly from the shared package — no npm publishing required:
// apps/api/src/routes/users.ts
import { User, ApiResponse } from '@my-app/shared';
import express from 'express';
const router = express.Router();
router.get('/:id', async (req, res) => {
const user: User = await db.users.findById(req.params.id);
const response: ApiResponse<User> = {
data: user,
success: true,
};
res.json(response);
});
export default router;
// apps/api/package.json
{
"name": "@my-app/api",
"dependencies": {
"@my-app/shared": "*",
"express": "^4.18.0"
}
}
The Next.js Frontend
The frontend imports the exact same types — guaranteed to match the API:
// apps/web/src/app/dashboard/page.tsx
import { User, ApiResponse } from '@my-app/shared';
async function fetchCurrentUser(): Promise<User> {
const res = await fetch('/api/users/me');
const json: ApiResponse<User> = await res.json();
return json.data;
}
export default async function DashboardPage() {
const user = await fetchCurrentUser();
return (
<div>
<h1>Welcome, {user.name}</h1>
<p>Role: {user.role}</p>
</div>
);
}
The Workspace Config
The root package.json tells npm (or pnpm) that this is a workspace:
// package.json (root)
{
"name": "my-app",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint"
}
}
Now when you run npm install from the root, it installs dependencies for all packages and links @my-app/shared locally so your apps can find it. Change a type in shared/types.ts and both your frontend and backend pick it up immediately on the next save. No publishing. No version bumps. No drift.
The Tools: Turborepo, Nx, and pnpm Workspaces
Once you have a monorepo, you need tooling to manage it. There are three layers here, and it helps to understand what each one does.
Layer 1: Package Manager Workspaces
This is the foundation. npm, pnpm, and yarn all support "workspaces" — a feature that lets them recognize a monorepo structure and link local packages together automatically.
With pnpm workspaces (the most popular choice for new monorepos), you add a pnpm-workspace.yaml file:
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
That's enough to make local package imports work. When apps/web declares @my-app/shared as a dependency, pnpm knows to link it to the local packages/shared folder instead of fetching it from npm.
pnpm is often preferred over npm for monorepos because it handles disk space more efficiently — it uses a single content-addressable store rather than duplicating packages across every workspace.
Turborepo
Workspaces handle package linking. Turborepo handles the build pipeline. It adds two major things:
Task orchestration — Turborepo understands which packages depend on which other packages. When you run turbo build, it builds your packages/shared first (because both apps depend on it), then builds your apps in parallel. Without this, you'd have to manually figure out and specify the right build order.
Caching — This is the big one. Turborepo remembers the output of every build task. If you run turbo build and nothing in packages/shared has changed since the last build, Turborepo skips building it and replays the cached output in milliseconds. On a large project, this can take a multi-minute build down to seconds.
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
The "dependsOn": ["^build"] line is the key part. The ^ prefix means "build all the packages this package depends on first." So when you run turbo build in apps/web, Turborepo automatically builds packages/shared first without you having to tell it to.
Nx
Nx is a more opinionated alternative to Turborepo. It also does build caching and task orchestration, but adds more on top:
- Code generators to scaffold new apps and libraries with the right structure
- A visual dependency graph you can view in the browser (
nx graph) - Integrated testing, linting, and CI configuration
- Plugin ecosystem for specific frameworks (Next.js, React Native, NestJS)
Nx is more powerful but also more complex. Turborepo is simpler to set up and understand. For most solo builders and small teams, Turborepo is the right starting point. Reach for Nx when you're managing many packages and teams and need the extra guardrails and generators.
Quick Comparison
| Tool | What it does | Best for |
|---|---|---|
| pnpm workspaces | Links local packages together | Any monorepo as the base layer |
| Turborepo | Build caching + task pipelines | Solo builders, small teams |
| Nx | Caching + generators + plugins | Larger teams, complex setups |
Setting Up a Monorepo (The Quick Way)
The fastest way to get a Turborepo monorepo running is with the official create command. This is exactly what your AI will run when it scaffolds one for you:
# Create a new Turborepo project
npx create-turbo@latest my-app
# Or with pnpm
pnpm dlx create-turbo@latest my-app
That gives you a starter with two apps and a shared UI package already wired together. From there, you can add your own apps and packages following the same pattern.
If you're adding a monorepo structure to an existing project, the process is more manual. You'd reorganize your existing code into the apps/ folder, create a packages/shared folder for shared code, set up the workspace config, and install Turborepo. Your AI is actually quite good at helping with this migration — just describe your existing structure and ask it to generate the new layout.
Adding a New Package
Once you have a monorepo, adding a new shared package follows a pattern:
# Create the package folder
mkdir -p packages/utils/src
# Add a minimal package.json
cat > packages/utils/package.json << 'EOF'
{
"name": "@my-app/utils",
"version": "0.1.0",
"main": "./src/index.ts"
}
EOF
# Export from the package
cat > packages/utils/src/index.ts << 'EOF'
export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
EOF
Then in any app that needs it, add it as a dependency:
// apps/web/package.json
{
"dependencies": {
"@my-app/utils": "*"
}
}
Run pnpm install from the root, and the new package is linked. Import it anywhere in apps/web:
import { formatDate } from '@my-app/utils';
const display = formatDate(new Date()); // "March 23, 2026"
The Honest Tradeoffs: Complexity vs. Consistency
Here's the part most tutorials skip. A monorepo solves real problems — but it introduces real costs too. You need to know both sides before letting your AI wire one up for you.
What You Gain
- Shared code without publishing overhead — the main reason to do this at all
- Atomic commits across projects — a single commit can update the shared type AND both apps that use it, guaranteeing they're always in sync
- Unified tooling — one ESLint config, one TypeScript config, one CI pipeline to maintain instead of three
- Easier refactoring — rename a function in your shared package and your IDE can find all usages across all apps in one search
- Single git history — the entire project's history is in one place, making it easy to understand what changed and why
What You Give Up
- Simpler mental model — "one repo, one project" is easier to reason about than "one repo, multiple interconnected projects"
- Faster initial clones — as the repo grows, cloning and initial setup takes longer
- Independent deployment triggers — in a monorepo you need to be careful about CI configuration to avoid deploying the frontend every time you change the backend
- Learning curve on tooling — Turborepo and workspaces add concepts you need to understand before you can debug them when they go wrong
- Harder to open-source selectively — if you want to open-source just your shared utilities, it's more complicated when everything lives in one private repo
The tradeoff usually tips in favor of a monorepo when you have genuine shared code between two or more deployable projects. It tips against when the projects are independent or when you're not ready to take on the tooling complexity.
When You Don't Need a Monorepo
This is important enough to get its own section, because AI tools will sometimes suggest a monorepo when it's genuinely not the right call. Here's when to push back.
You're building your first project. If you're new to coding and still figuring out how JavaScript modules work, adding monorepo tooling on top of that is the wrong order of operations. Build the app first. Learn the fundamentals. You can always restructure later when the shared code problem actually appears.
Your projects don't share code. If your marketing site and your SaaS app don't share any logic, types, or utilities, there's no reason for them to live together. The overhead of a monorepo is not justified by organization alone.
You're building a simple CRUD app. A single-page React frontend + a single API + one database? That's one app. Keep it in one repository, structured conventionally. You don't need a packages/ folder when there's nothing to share.
Your team has very different deployment cadences. If your frontend ships multiple times a day and your backend ships once a week through a careful release process, keeping them in separate repos with separate CI pipelines might be the right call — it gives each team full control without coupling their workflows.
The phrase "Turborepo config" makes you anxious. That's valid information. If you're at the point in a project where understanding one more tool feels like it'll break you, don't set up a monorepo yet. Get the thing shipped first. Tooling can be added later; momentum matters more.
The rule of thumb: Start with a monorepo if you know going in that you'll have shared code between multiple apps. Add a monorepo later if you hit the pain of syncing types or utilities manually and it's slowing you down. Don't set up a monorepo because "it seems like what serious projects do."
Deploying a Monorepo to Vercel, Railway, and Friends
One concern vibe coders often have when they first encounter monorepos: "how do I deploy this thing?" Modern platforms handle it well.
Vercel
Vercel has first-class monorepo support. When you connect your monorepo repository, Vercel lets you set a "root directory" for each project. For your Next.js frontend in apps/web:
- Root Directory:
apps/web - Build Command:
cd ../.. && turbo run build --filter=web(or let Vercel auto-detect) - Install Command:
pnpm install(at the repo root)
Vercel is smart enough to recognize Turborepo and configure itself automatically in many cases.
Railway
Railway works similarly — you connect the repository and specify which service lives in which folder. You can deploy your apps/api Express server as one Railway service and your apps/web frontend as another, both pointing at the same monorepo.
CI/CD Considerations
The main thing to get right in CI is only rebuilding and redeploying what actually changed. Turborepo helps here too — its --filter flag lets you target specific packages:
# Only build packages that changed relative to main
turbo run build --filter='...[origin/main]'
# Build a specific app and everything it depends on
turbo run build --filter=web...
This prevents a change to your README from triggering a full redeploy of every app in your monorepo. Your CI/CD pipeline stays efficient as the project grows.
What to Learn Next
If you're working with a monorepo or considering one, here's where to go deeper:
- What Is Turborepo? — A dedicated look at how Turborepo's caching and task pipeline work, with more examples of how to configure it for real projects.
- What Is npm? — Understanding how package managers and workspaces work makes monorepo configuration much less mysterious.
- What Is Git? — All of a monorepo's benefits — atomic commits, unified history, cross-project refactoring — depend on understanding how Git works underneath it all.
Frequently Asked Questions
What is the difference between a monorepo and a regular repository?
A regular repository holds one project — one frontend, one backend, or one library. A monorepo holds multiple related projects in a single repository, usually in separate folders. The key word is "related" — a monorepo isn't just a folder dump of random projects. It's an intentional structure where the projects share code, types, or configuration and benefit from being developed together. Tools like Turborepo and Nx add a build layer on top so each project can still be built and deployed independently.
Why does my AI suggest setting up a monorepo for my full-stack app?
When you're building a full-stack app — say a Next.js frontend and an Express API — both sides often share code: type definitions, validation logic, constants, utility functions. Without a monorepo, you either duplicate that code in both projects or publish it as a separate npm package (which adds a lot of overhead for an early project). A monorepo lets both your frontend and backend import from the same shared folder without any publishing step. That's why AI tools default to suggesting it — it solves the duplication problem elegantly.
Do I need Turborepo or Nx, or can I just use pnpm workspaces on their own?
You can absolutely start with just pnpm workspaces (or npm/yarn workspaces) without adding Turborepo or Nx. Workspaces handle the "link packages together" part. Turborepo and Nx add build caching, task pipelines, and dependency graph awareness on top — they know that if your shared package hasn't changed, there's no need to rebuild it before running your frontend. For small projects with two or three packages, plain workspaces are often enough. Add Turborepo when build times start to hurt.
Can I deploy a monorepo to Vercel, Railway, or Netlify?
Yes — most modern deployment platforms support monorepos natively. Vercel lets you set a "root directory" per project, so you can deploy your Next.js frontend from the apps/web folder of your monorepo and your Express API from apps/api separately. Railway works similarly. You connect the same GitHub repository but tell each service which subdirectory to build from and which build command to run. The platforms have gotten good at detecting monorepo setups and configuring themselves automatically.
When should I NOT use a monorepo?
Skip the monorepo when your projects are genuinely unrelated and don't share code. If you're building a marketing site and also a separate SaaS tool, those probably don't need to live together. Also skip it when you're just getting started on your first project and learning the basics — adding monorepo tooling on top of "how does JavaScript even work" is too much friction. The sweet spot for a monorepo is when you have two or more related apps that share types, utilities, or configuration and you're tired of keeping them in sync manually.