A pnpm + Turborepo Monorepo Primer — Lessons from Mass-Producing Web Tools
To mass-produce small web tools, I run 15 Next.js apps in a pnpm + Turborepo monorepo. Here is the setup, and what actually operating it has taught me.
What is a monorepo?
Managing multiple apps and packages in a single repository. The opposite — one app per repository — is a polyrepo.
For an indie developer cranking out tools, a monorepo is overwhelmingly the better fit:
- Shared code becomes instantly available as packages (no copy-pasting i18n, SEO, UI components around)
- Fix something shared and every app gets it at once. With polyrepos you'd need npm publishing or copy-paste syncing
- Dependency versions stay aligned across all apps easily
- It also pairs well with AI coding (Claude Code etc.): one session can make changes across every app
The downsides: builds get heavy as the repo grows (→ solved by Turborepo's cache), and shared-package changes affect every app (→ you must stay aware of blast radius).
Overall layout
nova-workspace/
├── apps/ # deployable units (15 Next.js apps)
│ ├── novare-orbis/ # hub site
│ ├── nova-paint/
│ └── ...
├── packages/ # shared packages referenced by apps
│ ├── app-kit/ # the foundation all apps use: i18n, SEO, analytics
│ ├── ui/ # UI components + global CSS + the app registry constant
│ ├── editor-kit/ # shared logic used only by the editor apps
│ ├── eslint-config/ # ESLint config (apps extend it)
│ └── typescript-config/ # tsconfig (same)
├── pnpm-workspace.yaml # workspace definition + catalog
├── turbo.json # task pipeline definition
└── package.jsonThe point is the two tiers: apps/ (deploy units) and packages/ (shared things). Turborepo's official docs recommend this layout too.
pnpm workspace essentials
Reference internal packages with workspace:*
An app's package.json references shared packages like this:
{
"dependencies": {
"@repo/app-kit": "workspace:*",
"@repo/ui": "workspace:*"
}
}workspace:* is a link to the package inside the same repository, not the npm registry. Edit a shared package and every app picks it up immediately, no publishing step.
Centralize versions with the catalog
pnpm's catalog: feature centralizes dependency versions in pnpm-workspace.yaml:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
catalog:
next: ^15.3.4
react: ^19.1.0
typescript: ^5.8.3// each app's package.json
{
"dependencies": {
"next": "catalog:",
"react": "catalog:"
}
}Even with 15 apps, upgrading Next.js is a one-line change in the yaml. The "apps on different React versions break the shared packages" accident becomes structurally impossible. Personally, this is the single highest-value mechanism in the monorepo.
Turborepo essentials
Turborepo handles "which tasks run in what order" and "caching the results."
// turbo.json (excerpt)
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "out/**"]
},
"lint": {},
"dev": { "cache": false, "persistent": true }
}
}dependsOn: ["^build"]= build dependencies first (^means "of my dependencies")- Tasks whose inputs haven't changed return instantly from cache. A full
pnpm buildof 15 apps actually builds only the changed app plus its dependencies
The everyday commands:
pnpm dev --filter nova-paint # dev server for one app only
pnpm --filter nova-paint build # build one app only
pnpm build # everything (cache applies)Learning --filter alone covers most daily operation.
How to slice shared packages (what operation taught me)
Separate "all apps" from "some apps"
It started as a single ui package and settled into three tiers over time:
app-kit— the foundation every app must use (i18n, metadata generation, OG images, analytics, root layout)ui— general-purpose UI components and global CSSeditor-kit— shared logic used only by the three editor apps
The base rule: "about to write the same code in a second app → move it to a shared package." But prematurely sharing something only two apps use means weighing both apps' needs on every change — waiting until the third duplication is about right.
Keep single sources in one place
The app catalog (names, URLs, icons, categories) is centralized in an APP_REGISTRY constant in packages/ui. The hub site's app list, every app's footer, the launcher, and JSON-LD are all generated from it, so adding an app or changing an icon is a one-file edit that propagates across every site. Monorepos make this "single source → cross-cutting propagation" pattern easy to build.
Make configs packages too
ESLint and tsconfig live in packages/eslint-config and packages/typescript-config; apps just extend them. Change a rule and every app gets it.
Per-app CI deploys
Even in a monorepo, deploys are independent workflows per app (one app = one Firebase project). GitHub Actions' paths filter runs them "only when the change affects that app":
on:
push:
branches: [main]
paths:
- "apps/nova-paint/**"
- "packages/**" # shared-package changes affect every app
- "pnpm-lock.yaml"The gotcha is including packages/**. "Changed a shared package but the app didn't redeploy" is the hardest accident to notice. The flip side: a one-line shared-package change redeploys every app, which naturally teaches you to split commits.
Add new apps with a generator
The more apps you have, the more the "new app procedure" becomes an asset. Set up a Turborepo generator (turbo gen) to scaffold from a template and a new tool boots in minutes. Codify the procedure instead of documenting it.
Summary
- For tool mass-production, an
apps/+packages/monorepo is strong: sharing, bulk updates, and single sources come structurally - pnpm's
workspace:*for internal references,catalog:for centralized versions — these two are the foundation - Turborepo is
--filterplus caching. Full builds stay light - Don't rush to share (wait for the third duplication) — but single sources like configs and the app registry belong in one place from day one
- CI deploys per app via
pathsfilters. Never forget to includepackages/**