Workflow · pnpm 11 · Turborepo 2.10 · TypeScript 6 · Node 24
Monorepo
Shared packages, task caching and clean boundaries — a monorepo that scales.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou maintain a TypeScript monorepo built on pnpm workspaces and Turborepo. Good means: one lockfile, no phantom dependencies, packages imported by name through explicit exports, tasks cached deterministically by dependsOn + outputs, releases driven by changesets, and every change touching only the affected packages.
Stack
- Node 24 LTS (Active LTS). Pin with
"engines": { "node": ">=24" }and an.nvmrc/.node-versionof24. Node 22 is Maintenance-only; do not target it for new apps. - pnpm 11.9 as the sole package manager. Pin exactly:
"packageManager": "pnpm@11.9.0"in the rootpackage.json(Corepack reads this). pnpm 11 ships as pure ESM and enables supply-chain defaults. - Turborepo 2.10 (
turbo@2.10.x). Config key istasks(renamed frompipelinein 2.0). Useturbo boundariesfor import-boundary checks andturbo prunefor Docker. - TypeScript 6.0 (last JS-based compiler). Use
"type": "module"everywhere. For very large repos you may add the TS 7 native preview (@typescript/native-preview,tsgo) for fast typecheck, but keeptsc6.0 as the source of truth until 7.0 is GA. - ESLint 10.6 flat config only (
eslint.config.ts)..eslintrc*was removed in v10 — do not create one. Lint TypeScript withtypescript-eslint8.x (tseslint.config(...)). - Vitest 4.1 for tests, using the root
test.projectsarray (theworkspacefield is deprecated since 3.2). Requires Vite >= 6. - Changesets
@changesets/cli2.31 for versioning and publishing. - Formatting: Prettier 3 (or Biome 2 if the repo standardizes on it) — pick one, run it through Turbo, never both.
Project conventions
- Root holds only orchestration:
package.json(with"private": true),pnpm-workspace.yaml,turbo.json,tsconfig.json(references only),eslint.config.ts,.changeset/. No application source at the root. - Two workspace roots:
apps/*(deployable units — Next.js, API, worker) andpackages/*(shared libraries and config). Optionallytooling/*for build/lint/tsconfig config packages. - Every package name is scoped to an internal namespace, e.g.
@repo/ui,@repo/eslint-config,@acme/api. Choose one scope and use it for all internal packages so--filter=@repo/*works. - One responsibility per package. If a package has two unrelated export surfaces, split it.
- Folder = kebab-case, package
name= scope + kebab-case, matching the folder. TypeScript files kebab-case; React components PascalCase file names are fine if that is the app's convention — be consistent per app. - Every package sets
"type": "module", an"exports"map, and its owntsconfig.jsonextending a shared base. Apps additionally set"private": true. - Imports: absolute by package name (
import { Button } from "@repo/ui"), or path aliases within a package (~/); never deep-relative across packages (../../packages/ui/src/button).
Structure
apps/
web/ # Next.js app, private
api/ # service, private
packages/
ui/ # @repo/ui — shared React components
core/ # @repo/core — framework-agnostic domain logic
tooling/
tsconfig/ # @repo/tsconfig — base tsconfig files
eslint-config/ # @repo/eslint-config — flat config presets
- Apps depend on packages; packages never depend on apps. Enforce with
turbo boundariesand a dependency-cycle check. - Prefer internal (unbundled) packages: a package's
exportspoint at.tssource and consumers compile it as part of their own build. This is the fastest default for internal-only code and keeps HMR working. Only add a build step (compiled package,exports→dist/) when the package is published to npm or consumed by a runtime that cannot transpile TS. - Keep leaf packages small and single-purpose so Turbo can cache and parallelize them independently.
pnpm workspaces
- Declare members and the catalog in
pnpm-workspace.yaml(in pnpm 11 thepackagesfield lives here, not in rootpackage.json):
packages:
- "apps/*"
- "packages/*"
- "tooling/*"
catalog:
react: ^19.2.0
typescript: ^6.0.0
zod: ^4.0.0
- Reference workspace packages with
workspace:*independencies:"@repo/ui": "workspace:*". pnpm rewrites this to a real version on publish. Never hand-write a version range for an internal package. - Use the
catalog:protocol for third-party versions shared across packages:"react": "catalog:". This guarantees a single version repo-wide and moves upgrades to one file, killing merge conflicts inpackage.json. SetcatalogMode: strictinpnpm-workspace.yamlsopnpm addrefuses out-of-catalog versions. - No phantom dependencies. Every import must correspond to a dependency declared in that package's own
package.json. pnpm's non-flatnode_moduleswill fail these at runtime — treat that as correct, and add the missing dep to the right package (not the root). - One lockfile at the root:
pnpm-lock.yaml, committed. CI installs withpnpm install --frozen-lockfile. Never delete or regenerate the lockfile to "fix" an install. - Run
pnpm dedupeafter dependency changes; commit the result. Usepnpm.overrides(rootpackage.json) only to force a transitive security fix, with a comment linking the advisory. - Add dependencies to a specific package:
pnpm --filter @repo/ui add clsx. Add a dev tool to the root only when it is genuinely repo-wide:pnpm add -Dw turbo. - Build scripts are blocked by default (pnpm 10+). Approve only the packages you trust via
onlyBuiltDependenciesinpnpm-workspace.yaml; audit each entry.
Shared config as packages
@repo/tsconfigexports base files (base.json,react-library.json,nextjs.json). Each package'stsconfig.jsondoes"extends": "@repo/tsconfig/base.json"and sets onlycompilerOptions.outDir/rootDirandinclude. Base sets"strict": true,"moduleResolution": "bundler"(or"nodenext"for pure Node services),"verbatimModuleSyntax": true,"isolatedModules": true, and"isolatedDeclarations": truewhen packages emit.d.ts(enables parallel declaration emit).@repo/eslint-configexports flat-config arrays (base,react,next). Consumers spread them:export default tseslint.config(...base, ...react). Includeeslint-plugin-import-xor theturboplugin to flag undeclared/phantom deps.@repo/uiand other shared runtime packages are consumed by name only; theirexportsmap defines the public surface.- These config packages are
devDependenciesof consumers and useworkspace:*. They are private (not published).
Turbo pipeline
- Define tasks in
turbo.jsonundertasks. Every task that produces files must declareoutputsor it will not be cached:
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"env": ["NODE_ENV"]
},
"lint": { "dependsOn": ["^build"] },
"typecheck": { "dependsOn": ["^build"], "outputs": [] },
"test": { "dependsOn": ["^build"], "outputs": ["coverage/**"] },
"dev": { "cache": false, "persistent": true }
}
}
dependsOn: ["^build"]means "build all internal dependencies first" — the^is topological. Use a bare task name ("build") for same-package ordering.- Mark long-running tasks (
dev,start)"persistent": trueand"cache": false. Turbo refuses to let another taskdependsOna persistent one unless you usewith. - Run only affected packages. Locally:
turbo run test --filter=@repo/ui. In CI on a PR:turbo run build lint typecheck test --affected(compares against the merge base) or--filter=...[origin/main]. Never run the whole graph when a scope suffices. - Make caching honest: list every env var a task reads in
env(task-level) orglobalEnv(affects all tasks). Unlisted env vars are not part of the hash, so a changed value silently reuses a stale cache. Turbo 2.x defaults to strict env mode — tasks only see declared env vars; add runtime-only vars topassThroughEnv. - Enable Remote Cache (Vercel or self-hosted) so CI and teammates share artifacts. Add
.turboto.gitignore. - For Docker, use
turbo prune --docker @acme/apito emit a minimal pruned workspace + lockfile, then install and build in the image. NeverCOPYthe whole monorepo.
Boundaries
- Import across packages only by package name, resolved through the target's
exportsmap. Deep relative imports (../../packages/...) and importing unexported internals are forbidden — they break caching, pruning, and refactors. - Control each package's public API with
exports; do not add a"."wildcard that leaks the wholesrc:
{
"exports": {
".": "./src/index.ts",
"./button": "./src/button.tsx"
}
}
- Enforce with
turbo boundariesin CI (flags imports of undeclared packages and reaching into another package's non-exported files). - No circular dependencies between packages. Add a cycle check (
turbo boundariesormadge --circular --extensions ts,tsx .) to CI. Break cycles by extracting the shared piece into a lower-level package. - The dependency graph is a DAG pointing down:
apps → feature packages → core/util packages → config packages. A util package importing an app or feature package is a design error.
Versioning and release with changesets
- Every PR that changes a publishable package must include a changeset:
pnpm changeset. It records the bump type (patch/minor/major) per package and a changelog line. CI fails PRs missing one (changeset status --since=origin/main). - Configure
.changeset/config.json:"access": "public"for public packages,"updateInternalDependencies": "patch", and"linked"/"fixed"groups only if packages must move in lockstep. - Release flow:
changeset versionbumps versions, rewritesworkspace:*/catalog:to concrete ranges in the published output, and writesCHANGELOG.md; thenpnpm -r publish(orchangeset publish). Automate with thechangesets/actionGitHub Action, which opens a "Version Packages" PR and publishes on merge. - Publish with npm provenance (
--provenance, or the action's provenance option) so consumers can verify the build. Never publish from a laptop for production packages — publish from CI with an npm automation token. - Private apps (
"private": true) are ignored by publish; still add changesets for them if you want changelog history, or set them in the changesets configignorelist.
Environments and secrets
- Never put a root
.envthat leaks into every workspace. Each app owns its own.env/.env.localnext to its code; packages read config passed in, notprocess.envdirectly. - Declare which env vars each task depends on in
turbo.json(env/globalEnv), so cache keys reflect config. Runtime-only secrets that must not bust the cache go inpassThroughEnv. - Keep secrets out of git and out of the build hash. Load them at runtime from the platform (Vercel/Doppler/1Password/SSM), not baked into cached
dist. - Validate env at the app boundary with a schema (
zod+@t3-oss/env-core) so a missing var fails fast at boot, not deep in a request. .env*files are in.gitignore; commit a.env.exampleper app listing required keys with dummy values.
Testing
- Vitest 4 with a single root
vitest.config.tsusingtest.projects(not the deprecatedworkspacefile). Point projects atpackages/*andapps/*globs; share common settings via an importedvitest.shared.ts(project configs cannotextendsthe root config). - Coverage, reporters, and snapshot-path resolution are root-only in project mode — configure
coverage(v8 provider) once at the root, not per project. - Run tests through Turbo so results cache:
turbo run test. A package whose inputs are unchanged replays cached results instead of re-running. - Test the package's public API (its
exports), not private internals. Colocate tests as*.test.tsbeside source. - Use browser mode (stable in Vitest 4) or a jsdom
environmentfor@repo/uicomponent tests; keep pure logic packages in the default node environment for speed. - CI runs
turbo run typecheck lint test --affected— typecheck and lint are first-class gates, not afterthoughts.
Security
- Keep pnpm 11's
minimumReleaseAgeon (default ~1 day): newly published versions are quarantined, blunting the window for compromised-package supply-chain attacks. Do not lower it to 0. - Keep build scripts disabled by default; only packages listed in
onlyBuiltDependenciesmay runpostinstall. Review each addition — arbitrary install scripts are the top supply-chain vector. - Commit
pnpm-lock.yaml; CI uses--frozen-lockfile. A drifting or regenerated lockfile in a PR is a red flag. - Run
pnpm audit(and anoverridespin) in CI; fail on high/critical. Pin transitive fixes via rootpnpm.overrideswith a linked advisory. - Publish with provenance and npm 2FA/automation tokens scoped per package; never a personal token with global publish rights.
- No secrets in the repo, in
turbo.jsonenv values, or in cached outputs. Treat the remote cache as a shared artifact store — nothing sensitive may land inoutputs. - Use a single catalog version per dependency so a security bump lands everywhere at once instead of leaving one package on a vulnerable pin.
Do
- Add each import's package to that package's own
package.json; fix "phantom dep" errors by declaring, not hoisting to root. - Use
workspace:*for internal deps andcatalog:for shared third-party versions. - Give every file-producing Turbo task an
outputsarray and every env-reading task anenvlist. - Scope commands:
pnpm --filter <pkg>andturbo run <task> --affected/--filter. - Extend shared
@repo/tsconfigand@repo/eslint-config; keep per-package config to overrides only. - Ship a changeset with every user-visible package change; let CI publish from the "Version Packages" PR.
- Keep packages single-purpose and depend downward only; run
turbo boundariesand a cycle check in CI.
Avoid
- Do not run
npm/yarnhere, or commit their lockfiles. One tool, onepnpm-lock.yaml. - Do not use
turbo.json's oldpipelinekey — it istaskssince Turbo 2.0. - Do not write
.eslintrc*— ESLint 10 is flat-config only; useeslint.config.ts. - Do not deep-import across packages (
../../packages/ui/src/...); import@repo/uiand expand itsexportsif you need more surface. - Do not hand-bump internal dependency versions or edit
CHANGELOG.mdby hand — changesets owns both. - Do not add a dependency to the root
package.jsonto satisfy a leaf package's import; add it to the leaf. - Do not run
-w/--recursivebuilds of the whole repo in CI when--affectedcovers the change. - Do not use
pnpm install --no-frozen-lockfilein CI, disableminimumReleaseAge, or blanket-allow build scripts. - Do not omit
outputson a build task (breaks caching) or read an undeclared env var in a task (produces stale cache hits). - Do not create circular package dependencies or import an app from a library.
When you code
- Make small, single-package diffs. If a change spans packages, do it as one coherent PR but keep each package's edit minimal and explain the boundary crossing.
- After editing, run the affected gates locally:
pnpm install(if deps changed) →turbo run typecheck lint test --filter=<changed>.... Fix, don't suppress. - When you add or move a dependency, update the owning
package.json, runpnpm install+pnpm dedupe, and add a changeset if a publishable package's public behavior changed. - When you touch a task's inputs/outputs/env, update
turbo.jsonin the same commit so caching stays correct. - Ask before: introducing a new top-level package or app, adding a compiled/bundled build step to a currently-unbundled package, changing the release/versioning strategy, upgrading a major version of pnpm/Turbo/TypeScript/ESLint, or editing root config that affects every workspace.
- Prefer extending existing shared config packages over adding new tooling. Do not introduce a second formatter, test runner, or bundler alongside the established one.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on pnpm 11 · Turborepo 2.10 · TypeScript 6 · Node 24.