TypeScript Monorepo (Turborepo)
Project context
This is a TypeScript monorepo managed with Turborepo and pnpm workspaces. We have shared packages/* for libraries and apps/* for deployable applications. Each workspace is a real, named package that other workspaces depend on through pnpm's workspace:* protocol.
Stack
- Turborepo 2+
- pnpm 9+ with workspaces
- TypeScript 5+ with project references
- Shared ESLint, Prettier, and tsconfig configs as internal packages
- Node.js 20+
- Optional: Changesets for versioning published packages
Folder structure
.
├── apps/
│ ├── web/ — Next.js app
│ └── api/ — Node service
├── packages/
│ ├── ui/ — shared React components
│ ├── db/ — shared Drizzle / Prisma schema and client
│ ├── config/ — shared runtime config
│ ├── eslint-config/ — internal ESLint config (extends from)
│ ├── tsconfig/ — base tsconfigs (extends from)
│ └── types/ — shared types
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
Workspace conventions
- Every workspace has a
package.jsonwith a real name (@repo/ui,@repo/db) - Internal dependencies use
"workspace:*"— never relative paths - One
package.jsonper workspace; never sharenode_modulesacross workspaces - Don't publish internal packages — keep
"private": true
TypeScript project references
- Every workspace's
tsconfig.jsonextends@repo/tsconfig/base.json - Apps reference packages they consume:
{
"extends": "@repo/tsconfig/nextjs.json",
"references": [
{ "path": "../../packages/ui" },
{ "path": "../../packages/db" }
]
}
- Each referenced package must have
"composite": truein its tsconfig - Run
tsc --build(not justtsc) at the root to leverage references
Build pipeline (turbo.json)
- Define tasks:
build,lint,typecheck,test,dev - Set
"dependsOn": ["^build"]so a task waits for upstream packages first - Set
"outputs"so Turborepo can cache (e.g.["dist/**", ".next/**"]) - Use
--filterfor partial runs:turbo build --filter=@repo/web - Enable remote caching:
turbo loginonce, thenturbo link
Internal packages
- A package exposes its public API through
package.json'sexportsfield - Use TypeScript directly (no build step) for non-published packages —
"main": "./src/index.ts","types": "./src/index.ts". Apps consume viatsx/ Next's compiler. - For published packages, build to
dist/withtsuportsc
{
"name": "@repo/ui",
"exports": {
".": "./src/index.ts",
"./button": "./src/button.tsx"
},
"scripts": { "lint": "eslint .", "typecheck": "tsc --noEmit" }
}
Shared configs
@repo/eslint-configexports preset configs (base.js,nextjs.js,node.js)@repo/tsconfigexports base tsconfigs (base.json,nextjs.json,node.json)- Apps and packages extend these, never duplicate
Database / schema sharing
- One
@repo/dbpackage owns the schema (Drizzle / Prisma) - Apps import the schema and the client from there
- Migrations live in
@repo/db/migrationsand are run via a script in that package
Patterns to follow
- One concern per workspace — UI components, DB schema, and config don't share a package
- Use Turborepo's caching aggressively — every task should declare its
outputs - Use
--filterin CI to only run jobs for changed workspaces (turbo build --filter='[origin/main]') - Lock down
enginesin the rootpackage.jsonto enforce Node + pnpm versions
Patterns to avoid
- Relative imports across workspaces. Always use the package name (
@repo/ui) — relative paths break TypeScript references - Hoisting hacks.
pnpmuses isolatednode_modulesper workspace; don't fight it - Different versions of the same lib in different workspaces. Use
pnpm.overridesif you must — but try to avoid drift - Shared
dist/between dev and CI. Each task declares its outputs. - Skipping
^builddeps to "speed up CI." It's faster overall to build properly with caching.
Testing
- Each workspace has its own
testscript that turbo runs - Vitest for both apps and packages — single runner across the monorepo
- Use vitest's
workspaceconfig to run all packages in one CLI invocation
Tooling
pnpm installat the root — installs all workspacesturbo dev— runs every workspace'sdevtask in parallelturbo build --filter=@repo/web— build one app and its depsturbo lint typecheck test— multi-task at oncepnpm changeset— only if publishing to npm
AI behavioral rules
- Always import internal packages by their workspace name (
@repo/ui), never relative - When adding a new internal package, register it in the consumer's
tsconfig.jsonreferences - When adding a new task, declare its
outputsinturbo.jsonso Turborepo caches it - Use
pnpm add <pkg> --filter <workspace>— don't add to root unless it's truly root-level (linting tools, etc.) - Don't install the same package in multiple workspaces with mismatched versions — use
pnpm.overridesto align - Run
turbo lint typecheck testfrom the root before declaring a task done