← 記事一覧へ

pnpm + Turborepoのモノレポ入門 — Webツール量産で運用して分かったこと

monorepo個人開発

小さなWebツールを量産するために、pnpm + Turborepoのモノレポで15個のNext.jsアプリを運用している。その構成と、実際に運用して分かったことを整理する。

モノレポとは

複数のアプリ・パッケージを1つのリポジトリで管理する方式。逆は「1アプリ=1リポジトリ」のポリレポ。

個人開発でツールを量産する場合、モノレポが圧倒的に向いている:

  • 共通コードをパッケージとして即共有できる(i18n、SEO、UIコンポーネント等をコピペしなくていい)
  • 共通部分を直したら全アプリに一括で効く。ポリレポだとnpm公開やコピペ同期が必要になる
  • 依存パッケージのバージョンを全アプリで揃えやすい
  • AIコーディング(Claude Code等)とも相性がいい。1セッションで全アプリ横断の変更ができる

デメリットは、リポジトリが育つとビルドが重くなること(→ Turborepoのキャッシュで解決)と、共通パッケージの変更が全アプリに影響すること(→ 影響範囲を意識する必要がある)。

全体構成

nova-workspace/
├── apps/                  # デプロイされる単位(15個のNext.jsアプリ)
│   ├── novare-orbis/      # ハブサイト
│   ├── nova-paint/
│   └── ...
├── packages/              # アプリから参照される共有パッケージ
│   ├── app-kit/           # i18n・SEO・アナリティクスなど全アプリ共通の土台
│   ├── ui/                # UIコンポーネント + グローバルCSS + アプリ一覧定数
│   ├── editor-kit/        # エディタ系アプリだけが使う共通ロジック
│   ├── eslint-config/     # ESLint設定(各アプリがextendする)
│   └── typescript-config/ # tsconfig(同上)
├── pnpm-workspace.yaml    # ワークスペース定義 + catalog
├── turbo.json             # タスクパイプライン定義
└── package.json

ポイントは apps/(デプロイ単位)と packages/(共有物)の2階層に分けること。Turborepo公式もこの構成を推奨している。

pnpm workspaceの要点

workspace:* で内部パッケージを参照

アプリのpackage.jsonから共有パッケージをこう参照する:

{
  "dependencies": {
    "@repo/app-kit": "workspace:*",
    "@repo/ui": "workspace:*"
  }
}

workspace:* はnpmレジストリではなく同リポジトリ内のパッケージへのリンク。共有パッケージを編集すると、公開作業なしで全アプリに即反映される。

catalogでバージョンを一元管理

pnpmの catalog: 機能を使うと、依存バージョンを pnpm-workspace.yaml に一元化できる:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
catalog:
  next: ^15.3.4
  react: ^19.1.0
  typescript: ^5.8.3
// 各アプリのpackage.json
{
  "dependencies": {
    "next": "catalog:",
    "react": "catalog:"
  }
}

アプリが15個あっても、Next.jsのバージョンアップはyamlの1行を変えるだけ。「アプリごとにReactのバージョンが違って共有パッケージが壊れる」事故も構造的に起きなくなる。個人的にはモノレポで一番効いている仕組み。

Turborepoの要点

Turborepoは「どのタスクをどの順番で実行するか」と「結果のキャッシュ」を担当する。

// turbo.json(抜粋)
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "out/**"]
    },
    "lint": {},
    "dev": { "cache": false, "persistent": true }
  }
}
  • dependsOn: ["^build"] = 依存パッケージのbuildを先に実行(^ が「依存先の」の意味)
  • 入力が変わっていないタスクはキャッシュから即返る。15アプリの pnpm build も、変更が1アプリならその1つ+依存だけが実際にビルドされる

よく使うコマンド:

pnpm dev --filter nova-paint     # 特定アプリだけdevサーバー起動
pnpm --filter nova-paint build   # 特定アプリだけビルド
pnpm build                       # 全体(キャッシュが効く)

--filter を覚えるだけで日常運用はほぼ足りる。

共有パッケージの切り方(運用して分かったこと)

「全アプリ共通」と「一部アプリ共通」を分ける

最初は ui 1つだったが、運用するうちに3層になった:

  • app-kit全アプリが必ず使う土台(i18n、メタデータ生成、OG画像、アナリティクス、ルートレイアウト)
  • ui — 汎用UIコンポーネントとグローバルCSS
  • editor-kitエディタ系3アプリだけが使う共通ロジック

「2つ目のアプリで同じコードを書きそうになったら共通パッケージへ」が基本。ただし2アプリでしか使わないものを早まって共通化すると、変更のたびに両方の都合を考える羽目になるので、3回目の重複まで待つくらいでいい。

単一ソースを1箇所に置く

アプリ一覧(名前・URL・アイコン・カテゴリ)を packages/ui の定数 APP_REGISTRY に集約している。ハブサイトのアプリ一覧、各アプリのフッター、ランチャー、JSON-LDが全部ここから生成されるので、新アプリの追加やアイコン変更が1ファイルの編集で全サイトに反映される。モノレポはこういう「単一ソース→横断反映」が作りやすい。

設定もパッケージにする

ESLintとtsconfigも packages/eslint-config packages/typescript-config に置き、各アプリはextendするだけにする。ルールを変えたら全アプリに効く。

アプリ別CIデプロイ

モノレポでも、デプロイはアプリごとに独立したワークフローにしている(1アプリ=1 Firebaseプロジェクト)。GitHub Actionsの paths フィルタで「そのアプリに影響する変更のときだけ」走らせる:

on:
  push:
    branches: [main]
    paths:
      - "apps/nova-paint/**"
      - "packages/**" # 共有パッケージの変更は全アプリに影響
      - "pnpm-lock.yaml"

注意点は packages/** を含めること。共有パッケージを変えたのにアプリが再デプロイされない、が一番気づきにくい事故になる。逆に言うと共有パッケージを1行変えると全アプリの再デプロイが走るので、コミットを分ける意識も生まれる。

新アプリの追加はジェネレーターで

アプリが増えるほど「新規作成の手順」が資産になる。Turborepoのジェネレーター(turbo gen)でテンプレートから雛形を生成できるようにしておくと、新ツールの立ち上げが数分で終わる。手順書よりコード化。

まとめ

  • ツール量産なら apps/ + packages/ のモノレポが強い。共通化・一括更新・単一ソースが構造的に手に入る
  • pnpmの workspace:* で内部参照、catalog: でバージョン一元管理。この2つがモノレポの土台
  • Turborepoは --filter とキャッシュ。全体ビルドが重くならない
  • 共通化は焦らない(3回目の重複まで待つ)。ただし設定とアプリ一覧のような単一ソースは最初から1箇所に
  • CIは paths フィルタでアプリ別デプロイ。packages/** を含め忘れない