
What is a Monorepo? A monorepo is a single Git repository that contains multiple distinct...
shrimp-monorepo as the ExampleA 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.
pnpm is the package manager. Workspaces are its monorepo feature.
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.
When you run pnpm install at the repo root, pnpm:
package.json in every workspace directorynode_modules at the rootworkspace:* protocolLook 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.
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:
pnpm workspaces handle dependencies. Turbo handles tasks (build, test, lint, etc.).
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-types beforehand, in the right order.
"dependsOn": ["build"] (no ^) — run the same package's build task first. Used by test:e2e: build the app before running E2E tests.
"cache": false — never cache this task. dev is 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).
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.
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.
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.
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.
@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.
tooling/typescriptRather 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.
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.
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.
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
| 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.
aiMost of us have seen a coding agent fail to complete a task we know it can do. We just don't...
googlecloudWhen building Generative AI applications, developers often encounter a massive bottleneck: sequential...
discussI’ve been thinking about sharing some electronic circuit posts on Dev.to — small circuits, DIY...
agentsWhat nobody tells you about exporting your multi-agent prototype to a local workspace. Every...
agenticarchitectAutonomous agents are genuinely good at answering messy business questions. Give one an LLM and a set...
aiPR volume went up, ticket quality didn't, and the gap got filled with LLMs on both sides of the review: bots reviewing, bots replying, bots occasionally arguing with bots about priorities that only existed in a teammate's head. Our CEO named the actual problem, and it's bigger than code review.
Workflows from the Neura Market marketplace related to this Stable Diffusion resource