pnpm monorepo で複数 Astro アプリを一元管理する設計ガイド

pnpm monorepo で複数 Astro アプリを一元管理する設計ガイド 使い方・設定
pnpm monorepo で複数 Astro アプリを一元管理する設計ガイドのイメージ

個人でサイドプロジェクトを複数抱えていると、「リポジトリが増えすぎてコードが散らかる」という問題にぶつかります。同じような Tailwind コンポーネントを各 Astro リポジトリにコピーしたり、TypeScript 設定を何度も書き直したりと、メンテナンスコストが地味に積み上がっていきます。

この記事では、pnpm workspace を使って複数の Astro アプリをモノレポで管理する方法について解説します。筆者が実際に運用している lifeevent-tools(4つの Astro アプリで構成)を題材に、構成の設計方針・共有パッケージの作り方・Cloudflare Pages へのデプロイ手順を紹介します。

結論から言うと、pnpm workspace + Astro の組み合わせは個人開発のサイドプロジェクトでも十分機能します。ただし「何を共有して、何を分けるか」という設計判断が品質を左右するポイントです。同じドメインの Astro アプリが 2〜4 つある場合はモノレポが特に効果的です。

📋 この記事でわかること

  • pnpm workspace でモノレポを構築する基本手順
  • 複数 Astro アプリを apps/ 以下に配置する設計パターン
  • 共有コンポーネント・ユーティリティパッケージの作り方
  • pnpm --filter を使った単一アプリの操作方法
  • Cloudflare Pages へのモノレポ対応デプロイ設定
  • モノレポ vs ポリレポのトレードオフと選択基準

モノレポとポリレポ、どちらを選ぶべきか

モノレポとポリレポ、どちらを選ぶべきかのイメージ

まず設計の前提として、モノレポとポリレポのトレードオフを整理してから判断してください。

観点 モノレポ ポリレポ
共有コードの管理 ✅ 一箇所で管理、すぐ反映 ❌ コピー or npm publish が必要
依存関係の一貫性 ✅ lockfile が 1 つ ❌ バージョン齟齬が起きやすい
CI/CD の設定 ❌ アプリごとの設定が必要 ✅ リポジトリ単位でシンプル
リポジトリの分離 ❌ 全体 clone が必要 ✅ アプリ単位で独立
ビルドキャッシュ ✅ Turborepo 等で最適化可 ❌ 各リポジトリ別々
初期設定コスト ❌ workspace 設定が必要 ✅ ゼロ設定で始められる
型チェック共有 ✅ tsconfig.base.json を共有 ❌ 各リポジトリで設定
linting 設定 ✅ biome.json を root 共有 ❌ 重複設定

モノレポを選ぶべきケース:

  • 複数アプリ間で UI コンポーネント・ユーティリティ関数を共有したい
  • TypeScript 設定や lint ルールを統一して管理したい
  • 2〜4 つ程度の Astro アプリが同じドメイン・ブランドに属している(5つ以上になるなら Turborepo の導入も検討する)

ポリレポを選ぶべきケース:

  • アプリ同士が技術スタック・ドメインとも完全に独立している
  • チームメンバーがリポジトリ単位で分かれている
  • 1 アプリだけを試作している段階

筆者の lifeevent-tools は「家族・ライフイベント向けツール群」という同じドメインで4アプリを運用しているため、モノレポを選択しました。実際に biome の設定と tsconfig.base.json の共有だけで設定の重複がかなり削減されています。

pnpm workspace の基本セットアップ

pnpm workspace の基本セットアップのイメージ

ディレクトリ構成

実際の lifeevent-tools の構成は以下のとおりです。


lifeevent-tools/
├── pnpm-workspace.yaml         # ワークスペース定義
├── package.json                # root: devDependencies 共有
├── tsconfig.base.json          # TypeScript 共有設定
├── biome.json                  # lint/format 共有設定
├── apps/
│   ├── lifeevent-hub/          # ブログ (Astro)
│   │   ├── package.json
│   │   ├── astro.config.mjs
│   │   ├── wrangler.toml
│   │   └── src/
│   ├── yarutoko/               # タスクボード (Astro)
│   │   ├── package.json
│   │   ├── astro.config.mjs
│   │   ├── wrangler.toml
│   │   └── src/
│   ├── shugi-navi/             # ご祝儀計算機 (Astro)
│   └── kazoku-hikaku/          # 家族比較ツール (Astro)
└── packages/
    ├── ui/                     # 共有 Tailwind コンポーネント (@lifeevent/ui)
    └── utils/                  # 共有ユーティリティ (@lifeevent/utils)

pnpm-workspace.yaml の設定

pnpm workspace の中核となる設定ファイルです。


# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

これだけで、apps/packages/ 以下のすべてのディレクトリがワークスペースとして認識されます。

root の package.json

root の package.json には、全アプリ共通の devDependencies を置きます。


{
  "name": "lifeevent-tools",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev:hub": "pnpm --filter lifeevent-hub dev",
    "dev:yarutoko": "pnpm --filter yarutoko dev",
    "build": "pnpm -r build",
    "typecheck": "pnpm -r typecheck",
    "lint": "biome check .",
    "format": "biome format --write ."
  },
  "devDependencies": {
    "@biomejs/biome": "^1.9.0",
    "typescript": "^5.7.0"
  }
}

private: true は必須です。root パッケージが誤って npm publish されるのを防ぎます。

tsconfig.base.json の共有

TypeScript の共通設定を root に置いて、各アプリが extends するパターンです。


// tsconfig.base.json(root)
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

各アプリの tsconfig.json はこれを継承します。


// apps/yarutoko/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "include": ["src"],
  "compilerOptions": {
    "baseUrl": "."
  }
}

これにより、strict モードの変更やターゲット変更を root 1ファイルで全アプリに反映できます。

各アプリの package.json 設計

各アプリの package.json 設計のイメージ

各アプリは自分自身の依存関係だけを持ちます。共有パッケージへの参照は workspace:* プロトコルで記述してください。


// apps/yarutoko/package.json
{
  "name": "yarutoko",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@lifeevent/ui": "workspace:*",
    "@lifeevent/utils": "workspace:*",
    "astro": "^5.0.0",
    "@astrojs/cloudflare": "^12.0.0"
  }
}

workspace:* と書くことで、ローカルの packages/ui をそのまま参照します。npm publish 不要で、変更が即時反映されます。

共有パッケージの設計

共有パッケージの設計のイメージ

@lifeevent/ui:共有 Tailwind コンポーネント


// packages/ui/package.json
{
  "name": "@lifeevent/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./components/*": "./src/components/*"
  }
}

コンポーネントのエントリポイントを exports に定義することで、アプリ側から import { Button } from '@lifeevent/ui' と書けます。

@lifeevent/utils:共有ユーティリティ関数

日付フォーマット・バリデーション・型定義などの汎用関数をここに集約します。各アプリで同じロジックをコピーするのではなく、一箇所で管理することでバグ修正も一度で済みます。


// packages/utils/src/date.ts
export function formatJapaneseDate(date: Date): string {
  const year = date.getFullYear();
  const month = date.getMonth() + 1;
  const day = date.getDate();
  return `${year}年${month}月${day}日`;
}

D1 データベースは共有しない設計判断

実際に「D1 を共有すべきか」を検討しましたが、各アプリが独立したデータベースを持つ設計を選択しました。

理由は以下のとおりです:

  • アプリ間でスキーマが変わると影響範囲が広がる
  • 独立したデプロイ・ロールバックができなくなる
  • Cloudflare D1 の制限(1DB あたりのクエリ数)が問題になりやすい

例えば、shugi-navi のスキーマ変更が yarutoko に影響することは避けたいです。コードは共有しても、データストアは分けるという判断が運用上の安全弁になっています。

pnpm –filter でアプリを操作するコマンド集

pnpm --filter でアプリを操作するコマンド集のイメージ

モノレポ運用で最もよく使うコマンドパターンを整理してください。

単一アプリの操作


# yarutoko だけを開発サーバーで起動する
pnpm --filter yarutoko dev

# shugi-navi だけをビルドする
pnpm --filter shugi-navi build

# lifeevent-hub の依存パッケージを追加する
pnpm --filter lifeevent-hub add @astrojs/sitemap

全アプリへの一括操作


# 全アプリの型チェックを実行する
pnpm -r typecheck

# 全アプリをビルドする
pnpm -r build

# 全アプリのテストを実行する
pnpm -r test

-r--recursive の省略形で、すべてのワークスペースパッケージに対してコマンドを実行します。

依存関係ツリーを考慮した実行順

共有パッケージが存在する場合は、--workspace-concurrency--sort で依存関係順に実行できます。


# 依存関係を考慮してビルドを実行する
pnpm -r --sort build

実際に @lifeevent/ui の変更を全アプリに反映するときは、先に packages をビルドしてから apps をビルドする必要があります。--sort オプションを付けることでこれが自動的に解決されます。

Cloudflare Pages へのデプロイ設定

各アプリの wrangler.toml

各アプリは独自の wrangler.toml を持ち、Cloudflare Pages への設定を管理します。


# apps/yarutoko/wrangler.toml
name = "yarutoko"
compatibility_date = "2024-01-01"
pages_build_output_dir = "dist"

[[d1_databases]]
binding = "DB"
database_name = "yarutoko-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Cloudflare Pages のビルド設定

Cloudflare Pages の管理画面(Settings > Build & deployments)でモノレポを設定する手順は以下のとおりです。

1. Build command: pnpm --filter yarutoko build

2. Build output directory: apps/yarutoko/dist

3. Root directory: /(リポジトリルートを指定)

管理画面では「Settings > Build & deployments > Build configuration」を開いて上記を入力してください。コツは「Build command」に --filter を使うことです。これで Cloudflare 側のビルドプロセスが該当アプリだけをビルドします。アプリが4つある場合は、それぞれ別の Cloudflare Pages プロジェクトとして登録し、各プロジェクトで --filter を変えるだけで対応できます。

pnpm を Cloudflare Pages で使う際の注意点

Cloudflare Pages はデフォルトで npm を使おうとします。pnpm を使うには環境変数の設定が必要です。


ENABLE_EXPERIMENTAL_PNPM = 1

または、pnpm-workspace.yaml が存在すると自動で pnpm が使われるケースもあります。ビルドが失敗した場合はログを確認して、pnpm install が実行されているかを確認してください。

Biome による統一 lint/format 設定

Biome は高速な lint + format ツールで、モノレポとの相性が良いです。root に 1 つ biome.json を置くだけで全アプリに適用されます。


// biome.json(root)
{
  "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true
    }
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2
  },
  "files": {
    "ignore": ["**/dist/**", "**/.astro/**", "**/node_modules/**"]
  }
}

ESLint + Prettier の組み合わせと比べると、設定ファイルが 1 つで済む点が大きなメリットです。実際に biome に移行してから、PR ごとの lint エラーが大幅に減りました。

よくある疑問・注意点

Q. 共有パッケージに変更を加えたとき、どうやって各アプリに反映する?

workspace:* で参照している場合、追加の publish は不要です。packages/ui の変更を保存したら、apps/yarutoko dev で即座に反映されます。ただし型定義ファイルが古い場合は pnpm -r typecheck を走らせて確認してください。

Q. 特定のアプリだけ Astro のバージョンを上げたい場合は?

各アプリの package.json で管理しているため、個別にアップグレードできます。pnpm --filter shugi-navi update astro で対象アプリだけ更新できます。ただし共有パッケージが依存している場合は影響範囲を確認してください。

Q. node_modules はどこに作られる?

pnpm は hoist の仕組みにより、共通の依存パッケージは root の node_modules/.pnpm に保存されます。各アプリの node_modules にはシンボリックリンクが作られます。これによりディスク容量を節約しつつ、依存関係の競合を防いでいます。

Q. モノレポが大きくなったら Turborepo を入れるべき?

ビルド時間が問題になってきたら Turborepo の導入を検討してください。pnpm workspace 単体でも十分動きますが、4 アプリ以上でビルドに 5 分以上かかるようになったらキャッシュ戦略を見直す手順として Turborepo が有効です。今の lifeevent-tools 規模(4 アプリ)では pnpm workspace だけで十分運用できています。

Q. CI/CD はどう設定する?

GitHub Actions の場合、各アプリごとに workflow ファイルを作るか、path フィルターで変更があったアプリだけビルドするパターンが一般的です。Cloudflare Pages の Git 連携を使えば push 時に自動デプロイが走るため、個人プロジェクトでは CI/CD の設定コストをかなり抑えられます。

✅ まとめ

この記事では、pnpm workspace を使って複数の Astro アプリをモノレポで管理する設計パターンを整理しました。

要点をまとめると:

  • pnpm-workspace.yamlapps/*packages/* を定義するだけで基本構成が完成する
  • 共有パッケージworkspace:* プロトコルで参照し、npm publish なしで変更を即反映できる
  • tsconfig.base.json と biome.json を root に置くことで設定の重複を解消できる
  • D1 など状態を持つインフラ はアプリ間で分離するのが安全な設計判断
  • Cloudflare Pages では --filter を使ったビルドコマンドでモノレポ対応できる

結論として、個人サイドプロジェクトで 2〜4 アプリを同じドメインで運用するなら、pnpm workspace は十分なメリットがあります。Turborepo などの追加ツールを入れなくても、pnpm -r typecheckpnpm --filter dev で日常の開発には対応できます。

次のアクションとしては、まず pnpm-workspace.yaml を作成して既存アプリを移行するところから試してみてください。共有パッケージの切り出しはその後の段階でも問題なく進められます。

コメント