A pnpm monorepo for an AI SaaS — what worked, what didn't
ClipFlow lives in a single Git repo with this layout:
clip-flow/
├── apps/
│ ├── web/ # Next.js 14 — main app
│ ├── api/ # Fastify + Prisma
│ ├── worker/ # Python (own subtree, not in pnpm graph)
│ └── portfolio/ # this site
├── packages/
│ ├── ui/ # @clipflow/ui — shared React components
│ ├── shared-types/ # @clipflow/shared-types — Zod schemas
│ ├── tsconfig/ # shared TS configs
│ └── eslint-config/ # shared ESLint
├── pnpm-workspace.yaml
└── turbo.json
Three of the apps are TypeScript (web, api, portfolio); the worker is Python with its own venv but lives in the same Git repo for deployment convenience. Worth writing down what monorepo gave me and what it cost.
What worked
Workspace-linked TypeScript types. Every API contract is defined
once in packages/shared-types as a Zod schema, parsed at the API
boundary, and imported as inferred TS types in the web app. When I
add a new field to the Project model, the type error appears
immediately in every consumer. This single pattern saves probably 8
hours/month of "did the API change?" debugging.
Shared UI package. packages/ui exports Button, Card, Icon
- a Tailwind preset with the design tokens. Building the portfolio
site (this one) took half a day because all the design language was
ready to import —
"@clipflow/ui": "workspace:*"in package.json, done. The same components, the same colors, the same dark theme across two completely separate apps.
One CI pipeline. GitLab CI watches apps/** and packages/**
paths; affected services rebuild. Turbo's task graph caches the
unaffected ones. A typo fix in the web app rebuilds only the web
container; a shared-types change rebuilds web + api but caches
worker. Cuts deploy time by ~60% vs naive "rebuild everything".
Atomic refactors across boundary. When I added the contentMode enum to the Project model, the migration touched: Prisma schema, API route input validation, shared-types Zod, worker Python (via JSON contract), and four UI files. One commit, one PR review (well — me, in this case), one deploy. In separate repos that's four PRs and four deploys with potential for state-mismatch in between.
What didn't
Python doesn't fit in pnpm. The worker has its own
requirements.txt, its own venv, its own Dockerfile. That's fine —
it's still in the same Git repo so the deploy CI is unified — but it
doesn't get Turbo caching, doesn't share types via workspace link
(I duplicate the ASMR job payload schema in Python TypedDict
manually), doesn't benefit from pnpm install running once. If I
had a second Python service I'd consider extracting packages/-style
shared modules with a private wheel; for one service it's not worth.
Dockerfile-per-app is awkward. Each app's Dockerfile copies
pnpm-lock.yaml + the workspace manifest + the package's own files,
then pnpm install --filter @clipflow/<app>... to grab only the
transitive deps. Works, but it's ~50 lines of glue per Dockerfile
and a copy/paste tax when I add a new app. There are tools that
auto-generate this (Vercel's pnpm-workspace-deploy etc.) but I
haven't migrated.
Turbo config drift. turbo.json defines tasks (build, dev,
type-check, lint) as a graph. If you add a new package you can
forget to wire it; CI passes, local dev breaks. I caught one
instance because pnpm install from a fresh checkout failed; if I
hadn't I would've shipped a broken state to a colleague (if I had
one).
Tooling indirection. Sometimes I miss the simplicity of "cd
into one folder, all your deps live there". pnpm --filter @clipflow/api exec prisma migrate dev is more verbose than cd apps/api && pnpm prisma migrate dev. After 9 months I'm used to
it; for a new collaborator it'd be a learning curve.
What I'd skip next time
Don't build packages/eslint-config until you need it. I
created it on day 1 because all the templates do. Then I never
configured it strictly enough to catch real bugs, and ESLint 9 broke
the next-eslint plugin compat. Now it just sits there. Default
eslint config in each app would've been fine.
Don't extract packages/shared-types if there's only one
consumer. Day-1 ClipFlow had only the API + web. Putting the
schemas in apps/api/src/schemas and importing across packages via
relative path was working. I extracted to packages/shared-types
when the worker started consuming, which was the right time. The
mistake would have been extracting on day 1 just because "monorepo".
Honest take
For a solo developer with 3+ apps that share types and UI, pnpm + Turbo is worth it. The mental tax of learning the workspace semantics is paid back within a month by faster iteration.
For a solo developer with 1 app, just put it in a folder and don't
overthink it. Start with git init, add a Dockerfile, ship. The
abstraction muscle you build by needing a monorepo is more
durable than the one you build by choosing one upfront.