TypeScript Monorepo Magic: Organize, Build, and Ship Multi-Package Apps

typescript dev.to

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
Enter fullscreen mode Exit fullscreen mode

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/*'
Enter fullscreen mode Exit fullscreen mode

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:

  1. Reads every package.json in every workspace directory
  2. Hoists shared external dependencies into a single node_modules at the root
  3. 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:*"}
Enter fullscreen mode Exit fullscreen mode

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/**"]}}}
Enter fullscreen mode Exit fullscreen mode

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).

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)
Enter fullscreen mode Exit fullscreen mode

@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"
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

--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"}}
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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",...}}
Enter fullscreen mode Exit fullscreen mode

tooling/typescript/tsconfig.react.json — extends base, adds JSX:

{"extends":"./tsconfig.base.json","compilerOptions":{"jsx":"react-jsx"}}
Enter fullscreen mode Exit fullscreen mode

Each package inherits via extends:

apps/api/tsconfig.json:

{"extends":"../../tooling/typescript/tsconfig.base.json","compilerOptions":{"outDir":"./dist","rootDir":"./src"}}
Enter fullscreen mode Exit fullscreen mode

packages/ui/tsconfig.json:

{"extends":"../../tooling/typescript/tsconfig.react.json","include":["src/**/*.ts","src/**/*.tsx","tests/**/*.ts"]}
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

.githooks/pre-commit:

#!/usr/bin/env sh
pnpm precommit:staged
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials