하나의 Turborepo에서 단독으로 4개의 앱 배송

작성자

카테고리:

← 피드로
DEV Community · Jonas · 2026-07-20 개발(SW)

Jonas

SportsFlow is four apps: a web app, a native (Expo) app, a marketing site, and a docs site. I’m one person. The only way that’s sustainable is to make the four apps share as much as possible without coupling them into a tangle.

This is the final post in my build-in-public arc. It’s the meta one: the conventions that let one brain hold the whole thing.

The layout

apps/
  web/        the product            (TanStack Start)
  marketing/  the public site        (TanStack Start, no auth/db/trpc)
  fumadocs/   the docs
  native/     mobile                 (Expo)
packages/
  ui/         design system + app-agnostic court geometry
  api/        tRPC router
  auth/       better-auth + org + stripe plugins
  db/         Drizzle schema + relations
  analytics/  pure-compute stats (runs everywhere)
  env/        t3-env validated config
  config/     shared tsconfig

Enter fullscreen mode Exit fullscreen mode

Turborepo + pnpm. The pnpm catalog pins shared dependency versions in one place, so all four apps move in lockstep instead of slowly drifting into four different React versions.

Conventions are the real architecture

When you’re solo, you can’t rely on review to enforce consistency — there’s no second person. So the rules have to be structural, the kind a type-checker or a linter catches.

Zod-first types. Every type is a zod schema + z.infer. No standalone interfaces anywhere. This isn’t a style preference; it means runtime validation and compile-time types are the same artifact and can’t drift. Value vocabularies live as code constants + zod, not Postgres enums — extending a list is a deploy, not a migration.

The tRPC procedure ladder. Authorisation is a staircase, and you physically cannot skip a step:

publicProcedure
  → protectedProcedure   (valid session)
    → orgProcedure       (active team + verified membership; injects orgId)
      → staffProcedure   (mutations; staff role only)

Enter fullscreen mode Exit fullscreen mode

Every domain query filters on orgId, and orgProcedure is the thing that provides orgId to the context. You can’t write a query that forgets tenancy scoping, because the scoping comes from the procedure you built on. Multi-tenant safety becomes a property of where you start, not a checklist you remember.

One design system, two consumers. packages/ui holds brand-locked, app-agnostic atoms — Button, ScorePill, PlayerChip — plus the court geometry: the SVG math for a handball court, goal zones, and play playback. Both the web tracking UI and the marketing site’s live demos render the same court from the same geometry. The marketing animations aren’t a reimplementation; they’re the real component with demo data.

Billing stays out of the domain. Subscriptions are per team (referenceId = orgId) via better-auth’s Stripe plugin. But domain tables know nothing about billing — tier limits live in one file (limits.ts, e.g. assertCanCreateMatch) called from the API. The schema never grows a plan column. If pricing changes, exactly one file changes.

What the monorepo buys me concretely

  • Change a type once. A field added to a zod schema in db ripples through the tRPC router, the web app, and the native app as type errors — a to-do list the compiler writes for me.
  • The same analytics run everywhere (see post #3) precisely because it’s a shared package, not copy-paste.
  • Marketing has no auth/db/trpc at all. It’s deliberately a different dependency graph — a fast static site that imports only the design system. The monorepo lets me share the look without sharing the weight.

What it costs

Honesty section. A monorepo isn’t free:

  • Native is early. It’s scaffolded and has a first cut of season analytics, but it lags the web app. Sharing packages helps, but React Native still needs its own UI primitives.
  • The better-auth schema is generated, which means a re-merge checklist every time (strip the old relations blocks, since relations live centrally for the new query builder). Generated code in a monorepo needs guardrails.
  • One person is still one person. The tooling multiplies what I can build; it doesn’t multiply the hours.

The throughline

Across this whole series — offline-first capture, cross-platform analytics, this monorepo — the same idea keeps showing up: make the correct thing structural. Idempotency in the schema instead of the network. Tenancy in the procedure instead of the query. Types from one zod definition instead of two. Stats from one pure package instead of three implementations.

When you’re solo, discipline you have to remember will eventually fail. Discipline the system enforces is the only kind that scales to one person building four apps.

Thanks for following the series. If you’re building something in this space — live sports, offline-first, multi-tenant SaaS, solo — come say hi.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다