pnpm + Turborepoのモノレポ入門 — Webツール量産で運用して分かったこと
小さな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コンポーネントとグローバルCSSeditor-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/**を含め忘れない