albertonline.org docs
Learn

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: hoisted
  • apps/* 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: hoisted means pnpm installs one flat node_modules at 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 is overrides pins 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 more

Individual 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." Bump effect in one place (pnpm-workspace.yaml) and every package moves together. No more hunting through 50 package.json files 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/auth resolves to packages/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 to src/index.ts.
  • ./client is 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-errors maps to src/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 building apps/web, Turbo builds every @repo/* package web depends 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:

  • inputs are the files that define a task's hash. $TURBO_DEFAULT$ is "all git-tracked files in the package"; the extras narrow or widen it — build adds .env*, lint adds the shared ../../.oxlintrc.json so a root lint-rule change busts every package's lint cache.
  • outputs are what Turbo saves and restores on a cache hit (.next/**, dist/**). If nothing in inputs changed, Turbo replays the cached outputs instead of re-running — the "it built in 80ms" moment.
  • env lists the environment variables that are part of the hash. If DATABASE_URL changes, the build re-runs. Non-hashed vars use passThroughEnv (see db: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-wide oxlint . (fast, one pass).
  • pnpm turbo run lint — each package's own lint script 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 @/something would 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:

  1. Create packages/foo/package.json named @repo/foo, "type": "module", with an exports map pointing at src/index.ts (mirror @repo/api). Add the standard lint / check-types / build / test scripts so it joins the Turbo task graph.
  2. Declare deps by protocol. Internal packages → "@repo/db": "workspace:*". Third-party → "effect": "catalog:". If the version isn't in the catalog yet, add it to pnpm-workspace.yaml's catalog: block first, then reference it.
  3. Never import @/* or apps/* from inside the package — the boundary guard will fail the push.
  4. pnpm install to link it; it's picked up by the packages/* glob with no further registration.
  5. 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.

On this page