Turborepo monorepo in practice: structure, caching, and the mistakes to skip
Monorepos have a reputation for complexity that's mostly earned by the teams that adopt them prematurely. The case for a monorepo is simple: if you have two or more apps sharing code – a component library, shared utilities, a type layer – a monorepo eliminates the publish-and-bump cycle entirely and lets you make atomic cross-package changes in a single commit. Turborepo is the tool we reach for and the one the JavaScript ecosystem has settled on for most use cases. Here's how we structure it.
The three-layer folder structure
The structure that's emerged as the standard across the community is three directories: `apps/` for deployable applications (Next.js, Vite SPAs, API services), `packages/` for shared code (component libraries, utils, shared types, config), and `tooling/` for shared development configuration (ESLint config, TypeScript base configs, Tailwind presets). Apps should be thin – they consume packages, they don't own business logic. Packages are where shared complexity lives.
monorepo/
├── apps/
│ ├── web/ # Next.js or TanStack Start app
│ └── api/ # Hono or Express API service
├── packages/
│ ├── ui/ # Shared component library
│ ├── db/ # Drizzle/Prisma schema + client
│ └── validators/ # Zod schemas shared by api and web
└── tooling/
├── eslint/ # @acme/eslint-config package
└── typescript/ # Base tsconfig.json files
turbo.json: the three pipeline decisions that matter
Turborepo's task pipeline is where most configuration time gets spent. Three decisions drive most of the behaviour: which tasks depend on `^build` (meaning the task must run in dependency order – typically `build` and `typecheck`), which tasks are cacheable (almost everything that has deterministic output), and which tasks should run in watch mode (`dev` tasks should never be cached). Getting these right means fast CI and a sensible local dev experience.
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {
"dependsOn": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
Remote caching
Turborepo's remote cache shares build artifacts across machines – your CI pipeline hits the same cache your local machine populated. On Vercel, remote caching is free for repos linked to a Vercel project (since late 2024); for self-hosted setups, Turborepo supports a remote cache server that you can run on any S3-compatible storage. The practical effect: after the first CI run on a given commit, subsequent runs for PRs that haven't changed a given package skip its build entirely. On a monorepo with eight packages, this cuts CI time by 60–70% after the cache is warm.
The mistakes worth avoiding
- Creating too many packages too early – start with three to five packages, not twenty. Each package boundary adds overhead (versioning, build order, TS paths). Extract only when the sharing need is concrete, not anticipated.
- Putting business logic in apps/ – apps should be routing, layout, and composition. Logic that might be shared belongs in packages/ even if only one app uses it today.
- Forgetting to set `dependsOn: ["^build"]` on typecheck – without this, TypeScript will check your app against un-built package types and produce confusing errors.
- Using `npm workspaces` instead of pnpm – pnpm's `workspace:` protocol and strict hoisting behavior catch dependency issues that npm silently masks through its flat node_modules structure.

