Monorepo vs Single Repo: Architecture Choices in SaaS Boilerplates
TL;DR
Single repo (regular Next.js) for solo founders, early-stage SaaS, and products with one frontend. Monorepo (T3 Turbo or Turborepo) when you have multiple apps sharing code — web + mobile + marketing site, or web + API + workers. Most successful solo SaaS products start single-repo and evolve to monorepo only when the code-sharing problem actually exists.
What "Monorepo" Actually Means
A monorepo is a single Git repository containing multiple packages or apps:
my-saas/ ← Single repo (just a Next.js app)
└── src/
my-saas-monorepo/ ← Monorepo (multiple apps + packages)
├── apps/
│ ├── web/ ← Next.js web app
│ ├── mobile/ ← React Native (Expo)
│ └── marketing/ ← Astro or another Next.js app
├── packages/
│ ├── ui/ ← Shared component library
│ ├── api/ ← tRPC router (shared between apps)
│ ├── db/ ← Prisma schema + client
│ └── config/ ← Shared TypeScript, ESLint config
└── turbo.json
The monorepo pattern exists to solve the code-sharing problem: when you have multiple deployable apps that need to share types, components, or business logic.
Single Repo: Simple, Fast, Focused
A standard Next.js app in a single repo is what most boilerplates ship:
starterpick-single/
├── app/ ← Next.js App Router
│ ├── (auth)/
│ ├── (dashboard)/
│ └── api/
├── components/
├── lib/
├── prisma/
└── package.json
Advantages:
- Zero tooling overhead (no Turborepo, no workspace configs)
- Fast
npm install, simpler CI - Every developer understands the structure immediately
- Easy to deploy on Vercel with zero config
Disadvantages:
- Code is tightly coupled — if you add mobile later, you copy code
- Marketing site lives in the same deploy as the app
- API and frontend can't be deployed separately
T3 Turbo: The Popular Monorepo Boilerplate
T3 Turbo is the most popular monorepo boilerplate. Built on Turborepo with TypeScript, tRPC, and Next.js.
t3-turbo/
├── apps/
│ ├── nextjs/ ← Main web app
│ └── expo/ ← React Native mobile
├── packages/
│ ├── api/ ← tRPC router
│ ├── auth/ ← NextAuth config
│ ├── db/ ← Prisma schema + client
│ └── validators/ ← Zod schemas shared between apps
├── turbo.json
└── package.json
The Code Sharing Pattern in Practice
// packages/api/src/router/post.ts
// This router runs on BOTH web and mobile — defined once
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const postRouter = createTRPCRouter({
create: protectedProcedure
.input(z.object({ content: z.string().min(1).max(500) }))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: { content: input.content, authorId: ctx.session.userId },
});
}),
feed: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.post.findMany({
where: { authorId: ctx.session.userId },
orderBy: { createdAt: 'desc' },
take: 20,
});
}),
});
// apps/nextjs/src/app/dashboard/page.tsx
import { api } from '@/trpc/server'; // Server-side caller
// apps/expo/src/screens/feed.tsx
import { api } from '@acme/api/native'; // React Query on mobile
Same router, same types, same validation — no code duplication.
Turborepo Build Caching
// turbo.json — parallel builds with smart caching
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"], // Build packages before apps
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"typecheck": {
"dependsOn": ["^typecheck"]
},
"lint": {},
"dev": {
"cache": false,
"persistent": true
}
}
}
Turborepo caches build outputs. If packages/db didn't change, it doesn't rebuild. On a large monorepo, this saves 30-90% of CI time.
The Real Trade-Offs
Monorepo Adds Overhead
Setting up a monorepo correctly takes effort:
// packages/ui/package.json — every shared package needs this
{
"name": "@acme/ui",
"version": "0.0.1",
"exports": {
".": {
"import": "./src/index.tsx",
"types": "./src/index.tsx"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
}
}
Every shared package needs its own package.json, export maps, TypeScript config, and lint config. Small issue, but multiplied across 5-10 packages.
CI Becomes More Complex
# Single repo CI — simple
- run: npm install
- run: npm run build
- run: npm run test
# Monorepo CI — need to be smart about what changed
- run: npm install
- run: npx turbo build --filter=[HEAD^1] # Only build affected packages
- run: npx turbo test --filter=[HEAD^1]
Turborepo's --filter flag limits builds to changed packages. You need to understand this to avoid slow CI.
Decision Framework
Choose single repo if:
- Solo founder or small team (1-3 devs)
- One frontend (web only)
- Moving fast and iterating on product
- No immediate plans for mobile
- Deploying on Vercel
Choose monorepo if:
- You're building web + mobile from day one
- Multiple teams working on different parts
- Need a shared design system/component library
- Marketing site and app need separate deploys
- You expect the codebase to grow significantly
The honest rule: Don't add monorepo complexity until you feel the code-sharing pain. If you're copying types between two projects, it's time for a monorepo. If you're not, you don't need one yet.
Popular Monorepo Options Beyond T3 Turbo
| Option | Stack | Best For |
|---|---|---|
| T3 Turbo | Next.js + Expo + tRPC | Web + mobile with shared API |
| Nx | Any stack | Enterprise teams, complex build graphs |
| Turborepo standalone | Any stack | Teams who want flexibility without T3 opinions |
| pnpm workspaces | Any stack | Minimal tooling, just npm workspaces |
Starting Single, Going Monorepo Later
You can extract to a monorepo later if you start single:
# Create new monorepo
mkdir my-saas-mono && cd my-saas-mono
npx create-turbo@latest --example with-prisma
# Move your existing app
mv ../my-saas apps/web
# Extract shared packages
mkdir packages/db
mv apps/web/prisma packages/db/
# ... update import paths
It's work, but not catastrophic. Most successful SaaS products make this transition at Series A or when adding mobile.
Find monorepo and single-repo boilerplates on StarterPick.
Check out this boilerplate
View T3 Turbo on StarterPick →