t3-monorepo-patterns

star 0

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.

leonardoacosta By leonardoacosta schedule Updated 6/16/2026

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:

  • typecheck must NOT depend on ^build in dev — it becomes a full build chain. Use composite: true and TypeScript project references instead.
  • db:generate and db:migrate always set "cache": false — they have side effects.
  • outputs drives 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 and tsc --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

  1. Create packages/newpkg/package.json with "name": "@workspace/newpkg" and "exports" field.
  2. Add "@workspace/newpkg": ["../../packages/newpkg/src"] to root tsconfig.jsonpaths.
  3. Add { "path": "../../packages/newpkg" } to consuming package's tsconfig.jsonreferences.
  4. Run pnpm add @workspace/newpkg --filter @workspace/consuming-app to register the dep.
  5. Verify: pnpm turbo run typecheck from 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 applies db:migrate. NEVER db:push (state-based live-diff; collides with db:migrate replay → drift). See t3-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 build from inside a package directory — always from monorepo root.
  • NEVER add node_modules or dist to 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.json unless it is a tooling-level dep (e.g., turbo, typescript). App and package deps go in their own package.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):

  1. Pick one domain with low coupling (Stripe webhook payloads, event payloads — NOT auth)
  2. Create packages/contracts/ if it doesn't exist, with the one-dependency invariant enforced via package.json review
  3. Move that domain's Zod schemas to packages/contracts/src/<domain>.ts
  4. Update routers to import from @{ws}/contracts instead of inline declarations
  5. Update UI to import types from @{ws}/contracts instead of RouterOutputs[...] aliases
  6. Add ESLint no-restricted-imports rule banning internal-workspace imports from packages/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.

Install via CLI
npx skills add https://github.com/leonardoacosta/central-claude --skill t3-monorepo-patterns
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
leonardoacosta
leonardoacosta Explore all skills →