name: t3-monorepo-patterns description: T3 Turbo monorepo patterns for pnpm workspaces, Turborepo task configuration, package extraction, and cross-package type sharing. Use when structuring monorepo packages, configuring Turbo tasks, or managing workspace dependencies. user-invocable: false allowed-tools: Read, Glob, Grep
T3 Turbo Monorepo Patterns
Structure Reference
apps/
web/ — Next.js app (user-facing)
dashboard/ — Next.js admin
packages/
db/ — Drizzle schema + client
api/ — tRPC router
ui/ — Shared React components
auth/ — Better Auth config
validators/ — Zod schemas
logger/ — pino + OpenTelemetry mixin (see § Logger Package)
contracts/ — schema-only wire-format types (optional — see § Contracts Boundary)
tooling/
eslint/
typescript/
Logger Package
Codified from ss's pattern (2026-05-17 fleet audit). Reference impl: ss/packages/logger/src/index.ts:25-27.
packages/logger is a pure leaf (depends only on pino and @opentelemetry/api) that wires pino's mixin() to inject the active OTel trace/span IDs into every log line. This gives every log entry a traceId and spanId automatically — making Sentry-to-log joins trivial in production.
// packages/logger/src/index.ts shape:
import pino from "pino";
import { trace } from "@opentelemetry/api";
export const logger = pino({
mixin() {
const span = trace.getActiveSpan();
if (!span) return {};
const ctx = span.spanContext();
return { traceId: ctx.traceId, spanId: ctx.spanId };
},
});
The tRPC middleware (packages/api/src/trpc.ts § timingMiddleware) bolts a per-request child logger onto ctx.logger via withRequestContext(), so every router has a request-scoped logger with requestId, path, userId baked in — without thinking about it.
Rule: T3 projects that ship to production SHOULD include packages/logger with this shape. console.log is acceptable in dev-only scripts (seeders, one-off backfills).
Package manager: pnpm workspaces. Build system: Turborepo. TypeScript project references.
1. Package Extraction Decisions
Extract to packages/ ONLY when 2+ apps consume the code.
| Signal | Action |
|---|---|
| 2+ apps import the same module | Extract to packages/ |
| 1 consumer for >3 months | Inline back into the app |
| >200 lines with clear public API | Ready to extract |
| Single utility function | Never extract — inline it |
Single-consumer code belongs in the app. Premature extraction creates a package that only one app uses, requiring cross-package PRs for every change.
2. Turbo Task Dependency Graph
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"], // dependencies built first
"outputs": [".next/**", "dist/**"]
},
"typecheck": {
"dependsOn": [], // NO ^build — use project references instead
"outputs": []
},
"test": {
"dependsOn": [], // independent, runs in parallel
"outputs": ["coverage/**"]
},
"db:generate": {
"cache": false // always re-run, never cached
},
"db:migrate": {
"cache": false // never cached — side effects
}
}
}
Key rules:
typecheckmust NOT depend on^buildin dev — it becomes a full build chain. Usecomposite: trueand TypeScript project references instead.db:generateanddb:migratealways set"cache": false— they have side effects.outputsdrives what Turborepo caches. Omitting outputs = nothing cached for that task.
3. TypeScript Project References
Each package uses two tsconfig files:
tsconfig.json— for development (used by editors andtsc --noEmit)tsconfig.build.json— for declaration output (composite: true, emits.d.ts)
Root tsconfig paths resolve during development. Package-level references drive
incremental compilation. Both must be consistent.
Adding a new package — checklist
- Create
packages/newpkg/package.jsonwith"name": "@workspace/newpkg"and"exports"field. - Add
"@workspace/newpkg": ["../../packages/newpkg/src"]to roottsconfig.json→paths. - Add
{ "path": "../../packages/newpkg" }to consuming package'stsconfig.json→references. - Run
pnpm add @workspace/newpkg --filter @workspace/consuming-appto register the dep. - Verify:
pnpm turbo run typecheckfrom monorepo root passes.
If Cannot find module '@workspace/newpkg' appears, all four steps above are required — missing
any one causes the error.
4. pnpm Workspace Gotchas
# ALWAYS filter — never add to root
pnpm add zod --filter @workspace/validators
# Internal deps use workspace:* protocol
# package.json: "dependencies": { "@workspace/db": "workspace:*" }
# After git worktree add — REQUIRED before any build
git worktree add .worktrees/my-branch origin/base-branch
cd .worktrees/my-branch && pnpm install --frozen-lockfile
# Scope a command to one package
pnpm --filter @workspace/web dev
# Run across all packages
pnpm -r typecheck
Skipping pnpm install --frozen-lockfile after git worktree add causes turbo: command not found and Cannot find module errors — workspace symlinks are not set up until install runs.
4b. with-env Scripts Must Use dotenv -o (Override)
T3 monorepo with-env scripts (apps/*/package.json, packages/db/package.json) load the root
.env for local tooling (dev server, drizzle-kit generate, seeds, db:migrate). They MUST pass the
-o (override) flag so the project .env wins over any stray global ~/.env or exported shell
var:
"with-env": "dotenv -o -e ../../.env --"
// seeds/migrations inherit it — never re-load .env inline:
"seed:foo": "pnpm with-env tsx src/seed-foo.ts"
Without -o, a pre-set shell var (e.g. a machine-global POSTGRES_URL=...localhost...) shadows
the project value and every tool silently hits the wrong DB — dev server renders no data,
drizzle-kit generate/db:migrate/seeds target the wrong database. Verify in a shell that has the stray var set:
pnpm with-env node -e 'console.log(new URL(process.env.POSTGRES_URL).host)' # must NOT be localhost
DB policy: schema changes are migration-based only —
drizzle-kit generate→ commit the migration → deploy appliesdb:migrate. NEVERdb:push(state-based live-diff; collides withdb:migratereplay → drift). Seet3-code-patterns§ Migrations.
Stay on dotenv-cli (not dotenvx; not ~/.env layering). Full rationale + the override
precedence proof: deploy-and-env skill § -o Override. A running dev server caches the bad
connection — restart it after fixing.
5. Common Monorepo Mistakes
| Mistake | Correct Pattern |
|---|---|
import from '../../packages/db/src/schema' |
import from '@workspace/db' |
pnpm install inside packages/db/ |
pnpm install from monorepo root only |
Missing "exports" in package.json |
Always define exports — types won't resolve without it |
db imports from api, api imports from db |
Circular — restructure; db is always a leaf |
import { db } from '@workspace/db/client' via deep path |
Use the exports map; add subpath export if needed |
NEVER
- NEVER run
turbo run buildfrom inside a package directory — always from monorepo root. - NEVER add
node_modulesordistto source control. - NEVER import across packages via relative paths (
../../packages/db) — use@workspace/alias. - NEVER create a package for a single utility function — inline it.
- NEVER add a dependency to the root
package.jsonunless it is a tooling-level dep (e.g., turbo, typescript). App and package deps go in their ownpackage.json.
Contracts Boundary (Schema-Only packages/contracts)
Codified from xx's pattern (2026-05-17 fleet audit). Reference impl: xx/packages/contracts/src/proposal.ts + xx/packages/contracts/package.json:31-33.
A schema-only contracts package is the narrow-waist between server and client. It declares wire-format types (Zod or Effect Schema definitions) and NOTHING else — no runtime logic, no DB clients, no Effect.gen, no business code.
Strict one-dependency invariant
packages/contracts/package.json MUST list exactly ONE runtime dependency (the schema library — zod or effect). Zero internal workspace dependencies. The test:
cat packages/contracts/package.json | jq '.dependencies'
# Expected: { "zod": "catalog:" } OR { "effect": "catalog:" }
# If 3+ entries, you have a coupling problem
What it buys you
| Benefit | Mechanism |
|---|---|
| Server can swap implementations without breaking client | Client depends on Schema, not InferSelectModel<typeof users> |
| Smaller client bundles | apps/web doesn't transitively pull Drizzle, Better Auth, etc. |
| Posted evolution policy | JSDoc block at the top of each schema file states the additive-only rule (xx: "New fields MUST be Schema.optional or carry withDecodingDefault. Deprecated fields MUST be @deprecated and remain parseable") |
| Older clients fail closed | Schema.Literals([...]) rejects unknown values at decode time — not silent string fallback |
| Spec rationale lives with the type | JSDoc cites date + proposal slug + reason inline for every literal addition |
Retrofit recipe (T3 fleet)
Most T3 projects mash Zod schemas into router files. Retrofitting per-domain (don't try fleet-wide at once):
- Pick one domain with low coupling (Stripe webhook payloads, event payloads — NOT auth)
- Create
packages/contracts/if it doesn't exist, with the one-dependency invariant enforced viapackage.jsonreview - Move that domain's Zod schemas to
packages/contracts/src/<domain>.ts - Update routers to import from
@{ws}/contractsinstead of inline declarations - Update UI to import types from
@{ws}/contractsinstead ofRouterOutputs[...]aliases - Add ESLint
no-restricted-importsrule banning internal-workspace imports frompackages/contracts/
Recommended pilot domains for oo: Stripe webhook event payloads, badge addon configuration. Both have clean wire-format shapes with no business-logic coupling.
Anti-pattern: "fancy re-export"
A contracts package that imports drizzle-orm schemas referencing DB tables is NOT schema-only — it's a re-export of internal types dressed up as a boundary. Same coupling, more files.
Why xx invented this: Effect Service/Layer composition makes Server-side runtime types extremely heavy. Without a contracts boundary, the client's tsdown build walks the entire server graph (Effect, ProviderAdapter, Layer, Service). With it, the client only sees the schema lib. The performance win was the original driver; the architectural cleanliness is a bonus.