← 글 목록으로

A pnpm + Turborepo Monorepo Primer — Lessons from Mass-Producing Web Tools

monorepoindie-dev

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

The 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 build of 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-kitthe foundation every app must use (i18n, metadata generation, OG images, analytics, root layout)
  • ui — general-purpose UI components and global CSS
  • editor-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 --filter plus 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 paths filters. Never forget to include packages/**