What is a Monorepo?
A monorepo is a single Git repository that contains multiple distinct packages or applications. The alternative is a polyrepo: one repo per app or package.
This repo is shrimp-monorepo. It contains:
shrimp-monorepo/
├── apps/
│ ├── api/ ← Node.js backend (Elysia + gRPC)
│ ├── client-user/ ← React frontend for end users
│ └── client-admin/ ← React frontend for admins
├── packages/
│ ├── shared-types/ ← TypeScript types used everywhere
│ ├── ui/ ← Shared React components
│ ├── proto/ ← Protobuf definitions + generated code
│ ├── grpc-client/ ← gRPC client wrapper
│ └── db-schema/ ← Drizzle ORM schema
└── tooling/
└── typescript/ ← Shared tsconfig files
The core idea: code that is needed by multiple apps lives in packages/, and all apps can import it directly — no npm publishing required.
Tool 1: pnpm Workspaces — The Foundation
pnpm is the package manager. Workspaces are its monorepo feature.
How it's declared
pnpm-workspace.yaml:
packages:
- 'apps/*'
- 'packages/*'
- 'tooling/*'
This tells pnpm: treat every directory in these globs as a package. That's it — three lines define the entire workspace.
What this enables
When you run pnpm install at the repo root, pnpm:
- Reads every
package.jsonin every workspace directory - Hoists shared external dependencies into a single
node_modulesat the root - Creates symlinks for internal packages so they can import each other
The workspace:* protocol
Look at apps/api/package.json:
"dependencies": {
"@shrimp/db-schema": "workspace:*",
"@shrimp/proto": "workspace:*",
"@shrimp/shared-types": "workspace:*"
}
workspace:* means: don't fetch this from npm — link it from this workspace instead. When api imports @shrimp/db-schema, Node resolves it to packages/db-schema/src/index.ts on disk via a symlink in node_modules/.pnpm.
This is what makes internal sharing work without publishing packages.
Why pnpm over npm/yarn?
pnpm uses a content-addressable store (the .pnpm-store/ directory in this repo). Every version of every package is stored once globally. Workspaces get hard links to the store, not copies. This means:
- Faster installs
- Significantly less disk space
- Strict by default — packages can't accidentally import things they didn't declare
Tool 2: Turborepo — Task Orchestration and Caching
pnpm workspaces handle dependencies. Turbo handles tasks (build, test, lint, etc.).
The problem Turbo solves
If you run pnpm run build in a repo with 8 packages, you have to figure out the order yourself. proto must build before grpc-client, which must build before api. Do it wrong and you get stale or missing types.
Also, if nothing in packages/ui changed, you shouldn't need to rebuild it.
Turbo solves both: dependency-aware task ordering + task result caching.
turbo.json — the pipeline
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".output/**", "generated/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"test:unit": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}
Key concepts:
"dependsOn": ["^build"]— the^prefix means build all packages that this package depends on first. So when building@shrimp/api, Turbo automatically builds@shrimp/proto,@shrimp/db-schema, and@shrimp/shared-typesbeforehand, in the right order."dependsOn": ["build"](no^) — run the same package'sbuildtask first. Used bytest:e2e: build the app before running E2E tests."cache": false— never cache this task.devis persistent/interactive, so caching makes no sense."persistent": true— this task runs forever (a dev server). Turbo knows not to treat it as something that finishes."outputs"— Turbo hashes these paths to know what "done" looks like. If inputs haven't changed and outputs still exist, Turbo skips the task entirely (cache hit).
How the build graph flows
proto:generate
↓
@shrimp/proto (build)
↓
@shrimp/grpc-client (build)
↓
apps/api (build)
apps/client-user (build)
apps/client-admin (build)
@shrimp/shared-types, @shrimp/db-schema, and @shrimp/ui also build in parallel before their consumers, since they have no inter-dependencies.
Filtering — run tasks for specific packages
The root package.json uses --filter for targeted dev:
"dev:user": "turbo run dev --filter=@shrimp/client-user",
"dev:admin": "turbo run dev --filter=@shrimp/client-admin",
"dev:api": "turbo run dev --filter=@shrimp/api"
--filter accepts package names, directory globs, or git-based expressions.
Affected-only in CI
The CI workflow uses the most powerful filter:
run: pnpm turbo run typecheck lint test:unit build --affected
--affected uses the git diff against the base branch to determine which packages changed, then runs tasks only on those packages (and their dependents). A PR that only touches packages/ui won't re-run api tests.
Tool 3: Shared Packages — How Internal Code is Shared
Source-level packages (no compilation step)
Most packages in this repo point directly at TypeScript source:
packages/shared-types/package.json:
{
"name": "@shrimp/shared-types",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
There is no build script. When @shrimp/api imports @shrimp/shared-types, it imports .ts files directly. The consuming app's bundler or TypeScript compiler handles the compilation.
This is sometimes called the "internal packages" pattern — no dist/ folder, no compile step, just source. It works because all consumers in the monorepo are TypeScript themselves.
Packages that do need a build step
@shrimp/proto generates TypeScript from .proto files:
"scripts": {
"proto:generate": "pnpm exec protoc --ts_out ./generated ...",
"build": "pnpm run proto:generate"
}
Turbo's "dependsOn": ["^build"] ensures proto:generate runs before anything that consumes @shrimp/proto.
Tool 4: Shared TypeScript Config — tooling/typescript
Rather than duplicating 25 lines of compilerOptions across 8 packages, this repo centralizes TypeScript config in tooling/typescript/.
tooling/typescript/tsconfig.base.json — strict settings shared by all packages:
{
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "bundler",
...
}
}
tooling/typescript/tsconfig.react.json — extends base, adds JSX:
{
"extends": "./tsconfig.base.json",
"compilerOptions": { "jsx": "react-jsx" }
}
Each package inherits via extends:
apps/api/tsconfig.json:
{
"extends": "../../tooling/typescript/tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
}
}
packages/ui/tsconfig.json:
{
"extends": "../../tooling/typescript/tsconfig.react.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"]
}
Why this matters: change one setting in tsconfig.base.json and it propagates to every package in the repo. No drift, no "why is strict mode off in this one package" surprises.
Tool 5: Biome — Unified Linting and Formatting
Biome replaces ESLint + Prettier with a single fast tool. A single biome.json at the root applies to the entire repo.
biome.json key config:
{
"linter": {
"rules": {
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error"
},
"style": { "useConst": "error" }
}
},
"formatter": {
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100
}
}
Each package runs biome check . as its lint script, but the rules are defined once. This is the same pattern as TypeScript config inheritance: one source of truth, many consumers.
Tool 6: Git Hooks — Enforcing Quality at Commit Time
The repo uses a custom git hooks directory instead of the default .git/hooks. This means hooks can be committed and versioned.
Setup (runs on pnpm install via the prepare lifecycle):
"prepare": "git config core.hooksPath .githooks"
.githooks/pre-commit:
#!/usr/bin/env sh
pnpm precommit:staged
scripts/biome-staged.mjs — runs Biome only on staged files:
const staged = spawnSync("git", ["diff", "--cached", "--name-only", "-z", ...]);
// ... filters to .ts/.tsx/.js/.json files
spawnSync("pnpm", ["exec", "biome", "check", "--no-errors-on-unmatched", ...files]);
The key insight: it doesn't lint the whole repo on every commit — only the files currently staged. This keeps commits fast while still enforcing quality.
The Full Picture: How These Tools Interact
Developer commits
↓
git pre-commit hook (.githooks/pre-commit)
↓ runs biome-staged.mjs
↓ Biome checks only staged files
↓ (fails → abort commit; passes → continue)
↓
pnpm workspace resolves internal deps via workspace:* symlinks
↓
turbo run build
↓ reads turbo.json pipeline
↓ resolves dependency graph from package.json deps
↓ builds packages in order: proto → grpc-client → api/clients
↓ caches outputs in .turbo/
↓
CI: turbo run ... --affected
↓ diffs against main
↓ runs only impacted packages
↓ TypeScript config inherited from tooling/typescript/
↓ Biome config inherited from root biome.json
Summary: Why This Stack
| Problem | Tool | Mechanism |
|---|---|---|
| Share code without publishing to npm | pnpm workspaces |
workspace:* + symlinks |
| Run tasks in dependency order | Turbo | "dependsOn": ["^build"] |
| Don't rebuild unchanged packages | Turbo cache |
outputs hashing |
| Run tasks on only changed code in CI | Turbo --affected
|
git diff |
| Consistent TypeScript config | tooling/typescript |
extends inheritance |
| One linter/formatter config | Biome root biome.json
|
single config, all packages |
| Enforce quality before commits | Git hooks (.githooks/) |
staged-file Biome check |
The monorepo isn't one tool — it's these tools composing together. pnpm handles what exists, Turbo handles when things run, and the tooling layer handles how they're configured.
Top comments (0)