Turborepo monorepo in practice: structure, caching, and the mistakes to skip
Remote caching is free on Vercel. pnpm workspaces + Turborepo is the settled stack. Here's the folder structure, turbo.json, and the three decisions that actually matter.
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 filesturbo.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.
Written by Appesto Engineering.