Your monorepo setup
How this monorepo is wired — pnpm workspaces and the version catalog, the Turborepo task graph, @repo/* packages, and the local quality gates.
This repo is a single pnpm + Turborepo monorepo: dozens of apps and ~50 shared packages living in one tree, sharing one dependency graph and one set of rules. This guide walks the real wiring so you can reason about it — and extend it — without guessing.
The workspace: two globs and a hoisted linker
pnpm-workspace.yaml is the root of everything. The packages: key tells pnpm
which folders are workspace members:
packages:
- apps/*
- packages/*
- k6
nodeLinker: hoistedapps/*is every deployable app (apps/web,apps/docs, …).packages/*is every shared library (packages/api,packages/auth, …).- A new folder under either glob is a workspace member automatically — you never register it anywhere. That's the whole point of the glob.
nodeLinker: hoistedmeans pnpm installs one flatnode_modulesat the root (npm/yarn-style) instead of the default symlinked store. It trades some strictness for a layout every tool understands — but it also means two packages asking for slightly different versions of the same dep can end up with two physical copies, which is why so much of this file isoverridespins forcing the graph onto one copy (Effect, Next, Vite all get pinned).
The overrides: block is not boilerplate — each pin has a comment explaining a real bug the
hoisted linker caused (e.g. a second effect copy tripping the language-service
duplicatePackage diagnostic, or two vite copies breaking TanStack Start's instanceof
checks). Read the comment before touching a pin.
The catalog: one version, one place
Under the catalog: key is a giant map of package → version. This is the
single source of truth for every third-party version in the repo:
catalog:
"@effect/vitest": 4.0.0-beta.94
drizzle-orm: 1.0.0-rc.1
effect: 4.0.0-beta.94
next: 16.3.0
react: 19.2.7
typescript: 7.0.2
# …~250 moreIndividual packages never write a version number. They reference the catalog
entry with the literal string catalog:. Look at packages/api/package.json:
"dependencies": {
"@repo/auth": "workspace:*",
"@repo/db": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"next": "catalog:"
}Two different specifier protocols are doing the work here:
catalog:— "use whatever version the root catalog pins." Bumpeffectin one place (pnpm-workspace.yaml) and every package moves together. No more hunting through 50package.jsonfiles to upgrade a dep, and no more two packages silently disagreeing on a version.workspace:*— "use the local package from this monorepo, any version."@repo/authresolves topackages/auth, linked from the workspace, not fetched from npm.
Because versions live only in the catalog + the packageManager field, the check:doc-versions
guard (below) can treat any hardcoded version in a README as drift. When you add a dep, add it to
the catalog first, then reference it as catalog: — never paste a version into a package.
How a @repo/* package exports itself
Internal packages are consumed as TypeScript source, not built artifacts.
The exports map in packages/api/package.json points straight at src/:
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./client": {
"types": "./src/client/index.ts",
"default": "./src/client/index.ts"
},
"./*": {
"types": "./src/*.ts",
"default": "./src/*.ts"
}
}- The
.entry is the package root:import { toJsonRoute } from "@repo/api"resolves tosrc/index.ts. ./clientis a subpath export — a second public entry point (@repo/api/client) so client-only code doesn't drag server code into a browser bundle../*is a wildcard:@repo/api/http-errorsmaps tosrc/http-errors.ts.
And src/index.ts is a thin barrel that just re-exports the real modules:
export { makeErrorMapper, toJsonRoute, toStreamRoute } from "./effect-http";
export type { ErrorLogger, ToJsonRouteOptions, ToStreamRouteOptions } from "./effect-http";
export * from "./http-errors";Because the entry points are .ts, a consuming app (built with Turbopack, which
strips types) gets the source directly — no dist/ needs to exist for local dev
to work. The build: "tsdown" script still exists for the cases that need a
compiled .d.ts/bundle, but day-to-day you edit a package and the app sees the
change with no rebuild step.
Turborepo: the task graph and caching
pnpm links the packages; Turborepo runs tasks across them in the right order
and caches the results. turbo.json declares each task and, crucially, its
dependsOn:
"tasks": {
"build": {
"dependsOn": ["codegen", "^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**", ".next/**"]
},
"check-types": {
"dependsOn": ["codegen", "^check-types"]
},
"test": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
}
}The ^ prefix is the key idea. It means "run this task in all of my
dependencies first."
"build": ["^build"]→ before buildingapps/web, Turbo builds every@repo/*packagewebdepends on, transitively, in topological order."codegen"(no^) is a same-package dependency — GraphQL codegen must run before that package's own build."test": ["^build"]→ a package's tests can assume its dependencies are already built.
Caching is driven by inputs/outputs:
inputsare the files that define a task's hash.$TURBO_DEFAULT$is "all git-tracked files in the package"; the extras narrow or widen it —buildadds.env*,lintadds the shared../../.oxlintrc.jsonso a root lint-rule change busts every package's lint cache.outputsare what Turbo saves and restores on a cache hit (.next/**,dist/**). If nothing ininputschanged, Turbo replays the cachedoutputsinstead of re-running — the "it built in 80ms" moment.envlists the environment variables that are part of the hash. IfDATABASE_URLchanges, the build re-runs. Non-hashed vars usepassThroughEnv(seedb:push) so they're available without invalidating the cache.
dev is cache: false + persistent: true — a long-running watcher, never
cached, and Turbo knows not to wait on it.
You almost never edit turbo.json when adding a package. The task graph is derived from the
dependencies in each package.json, so a new @repo/* package with a build/test/lint
script slots into the existing graph automatically.
The local quality gate: ci:precheck
Before code reaches GitHub, the .husky/pre-push hook runs the CI gates
locally (this repo runs them on every push — see the header comment about
Actions minutes). Its first and non-negotiable step is pnpm ci:precheck, a
superset of CI's "Static Checks" job, defined in the root package.json:
"ci:precheck": "pnpm lint && pnpm turbo run lint && pnpm check-types && pnpm format:check && pnpm check:boundaries && pnpm check:architecture && pnpm check:no-raw-getsession && pnpm check:doc-versions && pnpm fallow"Reading it left to right, that's:
pnpm lint— a single repo-wideoxlint .(fast, one pass).pnpm turbo run lint— each package's ownlintscript through the task graph (cached per-package).pnpm check-types—node scripts/tsgo-workspaces.mjs, the native-Go TypeScript preview (tsgo) across every workspace.pnpm format:check—oxfmt --check ..check:boundaries,check:architecture,check:no-raw-getsession,check:doc-versions— the guard scripts covered next.pnpm fallow—fallow audit, a dead-code / duplicate scan.
The hook then runs the test suite (turbo run test:ci --concurrency=1), the
HttpApi exerciser gates, and an affected build.
pnpm check-types alone is not "ready to push" — it skips the repo-wide oxlint, boundary,
and architecture guards that Static Checks runs. Run pnpm ci:precheck (or just let the hook
run). Never bypass with SKIP_PREPUSH=1 / --no-verify; the env-bound steps have their own
scoped skips (RUN_TESTS=0, RUN_BUILD=0), but the static gate needs no secrets and should
always pass.
The boundary and architecture guards
Two of those guard scripts encode the repo's structural rules — the ones a type checker can't express.
check:boundaries (scripts/check-package-boundaries.sh) enforces that a
shared package stays shareable. It greps each packages/*/src for two forbidden
import shapes:
# Rule 1: @/* imports inside packages
check_pattern "$pkg_name" "@/* import" "from ['\"]@/" "${pkg}src"
# Rule 2: apps/* imports inside packages
check_pattern "$pkg_name" "apps/* import" "from ['\"](\.\.\/)*apps/" "${pkg}src"- No
@/*imports —@/is an app-local path alias (it resolves inside a Next app). A package importing@/somethingwould only work inside one app, breaking its reusability. Packages use relative imports or other@repo/*packages. - No
apps/*imports — dependencies flow one way: apps depend on packages, never the reverse. A package reaching up into an app is a layering inversion.
check:architecture (scripts/check-architecture.mjs) is a small rule
engine — it loads a list of rule modules from scripts/architecture-rules/ and
fails if any returns violations:
const ruleFiles = [
"effect-http-exports.rule.mjs",
"auth-client-centralized.rule.mjs",
"route-seam-policy.rule.mjs",
"route-error-policy.rule.mjs",
"auth-client-allowlist.rule.mjs",
"design-system-primitives.rule.mjs",
"next-config-consolidation.rule.mjs",
"route-adapter-required.rule.mjs",
];Each rule is a plain object with a check(ctx) function. For example,
route-adapter-required.rule.mjs forbids app route handlers from hand-rolling
error responses — they must go through the shared @repo/api adapters:
export const rule = {
id: "ROUTE_ADAPTER_REQUIRED",
description:
"App route modules must use shared @repo/api adapters (toJsonRoute/toStreamRoute) " +
"and shared HTTP error helpers (badRequest, unauthorized, forbidden, notFound, " +
"internalServerError). Direct Response.json/NextResponse.json with hardcoded error " +
"strings is not allowed.",
// …matches Response.json({ error: "Unauthorized" }, …) and friends
};This is why the @repo/api barrel above re-exports toJsonRoute and
http-errors — those are the blessed exports the architecture rule points
every route at.
The third guard, check:no-raw-getsession
(scripts/check-no-raw-getsession.mjs), bans raw auth.api.getSession( calls
anywhere outside the blessed retry wrapper in packages/auth/src/request.ts. It
exists because the lint framework's banMember helper can only match a
two-level Identifier.prop, and auth.api.getSession is a three-level member —
so a targeted grep script is the fallback. (See the
Authentication guide for why the wrapper matters.)
Adding a package the right way
Putting it together, here's the mechanical checklist to add packages/foo:
- Create
packages/foo/package.jsonnamed@repo/foo,"type": "module", with anexportsmap pointing atsrc/index.ts(mirror@repo/api). Add the standardlint/check-types/build/testscripts so it joins the Turbo task graph. - Declare deps by protocol. Internal packages →
"@repo/db": "workspace:*". Third-party →"effect": "catalog:". If the version isn't in the catalog yet, add it topnpm-workspace.yaml'scatalog:block first, then reference it. - Never import
@/*orapps/*from inside the package — the boundary guard will fail the push. pnpm installto link it; it's picked up by thepackages/*glob with no further registration.- Sync the docs — a package README plus the relevant
obsidian-vault/note, in the same change (the docs-sync rule is enforced culture, not optional).
Adding an app under apps/* is the same, with two extras: it must ship a
README.md, and it uses next.config.ts (never next.config.mjs — the .mjs
form can't resolve the extensionless TS imports inside @repo/next-config).
Run pnpm ci:precheck after scaffolding, before your first push. It's faster to learn a boundary
violation from the local guard than from a red CI check.
See also
- Architecture — what the pieces are and how a request flows across them.
- Learn Effect v4 — the service/layer/error patterns
the
@repo/*packages are built from.