← All Posts

Building a Monorepo That Actually Works

Lessons from building grim-gambit — a TypeScript monorepo with npm workspaces, shared packages, and edge deployment to Cloudflare.

Setting up a monorepo sounds simple in theory. In practice, it’s a maze of dependency resolution, build ordering, and tooling configuration. Here’s what I learned building grim-gambit.

Why Not Just Separate Repos?

The standard argument for monorepos is code sharing. But the real benefit is atomic changes. When I update the Card component in @grim/ui, I can update every consumer in the same commit. No version bumping, no coordinated releases, no “which version of the shared package does this app use?” debugging.

For a solo developer or small team, this eliminates an entire category of friction.

npm Workspaces Over Turborepo

I chose plain npm workspaces over Turborepo, Nx, or Lerna. The reasoning:

  1. Zero additional dependencies — npm workspaces ship with Node.js
  2. No configuration language to learn — just package.json workspace definitions
  3. Sufficient at this scale — with 3 packages and 3 apps, I don’t need distributed caching or parallel task orchestration
  4. Fewer moving parts — when something breaks, the debugging surface is smaller

The tradeoff is that I don’t get incremental builds or remote caching. At the current codebase size, a full build takes under 30 seconds. When that changes, I’ll reconsider.

Shared Package Design

The three shared packages follow a simple principle: each package does one thing, with clear boundaries.

  • @grim/ui exports components (Button, Input, Card). It has no runtime dependencies beyond TypeScript types.
  • @grim/storage wraps Cloudflare R2 with a typed API. It depends on Cloudflare Workers types.
  • @grim/ai provides a unified interface across LLM providers. It abstracts away the differences between Gemini, DeepSeek, and Claude APIs.

No package imports from another shared package. The dependency graph is flat: apps depend on packages, packages depend on external libraries. This prevents circular dependencies and keeps the build order deterministic.

Static Output as Default

The portfolio app (the one you’re reading this on) uses Astro 5 with static output. Every page is rendered to HTML at build time. There’s no JavaScript bundle, no hydration step, no client-side routing.

For a content site, this is the correct architecture. The content doesn’t change between requests. There’s no interactive state. The result is a Lighthouse score of 100 and page loads under 50ms from any Cloudflare edge location.

What I’d Do Differently

  1. Start with stricter TypeScript paths earlier — I added @/* path aliases late, which meant updating imports across multiple files
  2. Define the content schema before writing content — Astro’s content collections with Zod schemas are powerful, but retrofitting them is tedious
  3. Set up CI before the second app — I waited too long to add GitHub Actions, and fixing accumulated lint issues in bulk is never fun

The Stack

  • Monorepo: npm workspaces
  • Apps: Astro 5 (static), Cloudflare Workers
  • Packages: TypeScript with declaration maps
  • Deployment: Cloudflare Pages, Wrangler CLI
  • CI: GitHub Actions with per-app build targets

The full source is on GitHub.