複数のパッケージやアプリケーションを1つのリポジトリで管理する「モノレポ」は、コードの共有やバージョン管理を効率化する手法として広く採用されています。TypeScriptプロジェクトでは、pnpm WorkspacesとTurborepoの組み合わせが定番の選択肢です。
本記事では、モノレポの基本概念から、pnpm Workspaces + Turborepoによる環境構築、パッケージ間の依存管理、CI/CDの設定まで実践的に解説します。
モノレポとは何か・なぜ採用するのか
モノレポ(Monorepo)とは、複数のプロジェクト・パッケージを1つのGitリポジトリで管理するアーキテクチャです。Google、Meta、Microsoftなどの大規模企業でも採用されています。
モノレポのメリット
コード共有が容易:共通のユーティリティ、型定義、UIコンポーネントを複数アプリで簡単に再利用できます。npmパッケージとして公開する手間もありません。
一貫したツール設定:ESLint、Prettier、TypeScriptの設定を1箇所で管理でき、全プロジェクトに統一されたルールを適用できます。
アトミックな変更:共有パッケージの変更とそれを利用するアプリの修正を1つのコミット・PRでまとめられるため、不整合が起きにくくなります。
依存関係の可視化:パッケージ間の依存関係がworkspace内で明確に管理され、循環依存の検出も容易です。
pnpm + Turborepoを選ぶ理由
pnpmは、ハードリンクとシンボリックリンクを活用した高速なパッケージマネージャーです。ディスク容量を節約し、幽霊依存(phantom dependencies)を防止するstrict node_modulesが特徴です。
Turborepoは、モノレポのタスク実行を最適化するビルドシステムです。キャッシュ、並列実行、依存グラフに基づくタスクの順序制御を自動で行います。
プロジェクトの初期セットアップ
ディレクトリ構成
# プロジェクト初期化
mkdir my-monorepo && cd my-monorepo
pnpm init
# Turborepoのインストール
pnpm add -D turbo
# ディレクトリ作成
mkdir -p apps/web apps/api packages/ui packages/shared packages/config-typescript packages/config-eslint
完成後のディレクトリ構成は以下のようになります。
my-monorepo/
├── apps/
│ ├── web/ # Next.jsフロントエンド
│ └── api/ # Express.js APIサーバー
├── packages/
│ ├── ui/ # 共有UIコンポーネント
│ ├── shared/ # 共有型定義・ユーティリティ
│ ├── config-typescript/ # 共有TSConfig
│ └── config-eslint/ # 共有ESLint設定
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
└── .gitignore
pnpm-workspace.yamlの設定
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
ルートpackage.jsonの設定
{
"name": "my-monorepo",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test",
"clean": "turbo clean",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\""
},
"devDependencies": {
"turbo": "^2.4.0",
"prettier": "^3.4.0"
},
"packageManager": "pnpm@9.15.0"
}
共有パッケージの作成
共有TypeScript設定(config-typescript)
// packages/config-typescript/package.json
{
"name": "@repo/config-typescript",
"private": true,
"files": ["base.json", "nextjs.json", "node.json"]
}
// packages/config-typescript/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist"]
}
// packages/config-typescript/node.json
{
"extends": "./base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ES2022",
"lib": ["ES2022"],
"outDir": "./dist"
}
}
// packages/config-typescript/nextjs.json
{
"extends": "./base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ES2017",
"lib": ["dom", "dom.iterable", "ES2017"],
"jsx": "preserve",
"noEmit": true,
"plugins": [{ "name": "next" }]
}
}
共有型定義・ユーティリティ(shared)
// packages/shared/package.json
{
"name": "@repo/shared",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist"
},
"devDependencies": {
"@repo/config-typescript": "workspace:*",
"typescript": "^5.7.0"
}
}
// packages/shared/src/index.ts
export * from "./types";
export * from "./utils";
export * from "./constants";
// packages/shared/src/types.ts
export interface User {
id: number;
name: string;
email: string;
role: UserRole;
createdAt: string;
updatedAt: string;
}
export type UserRole = "admin" | "editor" | "viewer";
export type UserCreateInput = Omit<User, "id" | "createdAt" | "updatedAt">;
export type UserUpdateInput = Partial<UserCreateInput>;
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
pagination: {
page: number;
perPage: number;
total: number;
totalPages: number;
};
}
// packages/shared/src/utils.ts
export function formatDate(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("ja-JP", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function chunk<T>(array: T[], size: number): T[][] {
return Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
array.slice(i * size, i * size + size)
);
}
共有UIコンポーネント(ui)
// packages/ui/package.json
{
"name": "@repo/ui",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@repo/config-typescript": "workspace:*",
"react": "^19.0.0",
"typescript": "^5.7.0"
}
}
// packages/ui/src/Button.tsx
import React from "react";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
}
export function Button({
variant = "primary",
size = "md",
isLoading = false,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? "読み込み中..." : children}
</button>
);
}
Turborepoの設定とタスク管理
turbo.jsonの設定
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
},
"clean": {
"cache": false
}
}
}
dependsOn: ["^build"]は「依存しているパッケージのbuildが完了してから実行する」という意味です。sharedパッケージのbuildが完了してから、webやapiのbuildが実行されます。
フィルタリングによる部分実行
# 特定のアプリだけビルド
pnpm turbo build --filter=web
# 特定のパッケージとその依存先だけビルド
pnpm turbo build --filter=web...
# 変更されたパッケージだけビルド(CI向け)
pnpm turbo build --filter='...[HEAD^1]'
# 特定パッケージを除外
pnpm turbo build --filter='!@repo/ui'
キャッシュの仕組み
Turborepoは、各タスクの入力(ソースコード、依存関係、環境変数)をハッシュ化し、結果をキャッシュします。入力が変わっていなければビルドをスキップし、キャッシュされた成果物を復元します。
// turbo.json でキャッシュ対象の環境変数を指定
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["NODE_ENV", "API_URL"]
}
},
"globalEnv": ["CI"]
}
アプリケーションの構築
Next.jsフロントエンド(apps/web)
// apps/web/package.json
{
"name": "web",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@repo/shared": "workspace:*",
"@repo/ui": "workspace:*",
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@repo/config-typescript": "workspace:*",
"typescript": "^5.7.0"
}
}
// apps/web/src/app/page.tsx
import { Button } from "@repo/ui";
import { formatDate } from "@repo/shared";
import type { User } from "@repo/shared";
export default async function HomePage() {
const res = await fetch(`${process.env.API_URL}/api/users`);
const { data: users } = (await res.json()) as { data: User[] };
return (
<main>
<h1>ユーザー一覧</h1>
{users.map((user) => (
<div key={user.id}>
<p>{user.name} - {formatDate(user.createdAt)}</p>
<Button variant="primary" size="sm">詳細</Button>
</div>
))}
</main>
);
}
Express APIサーバー(apps/api)
// apps/api/package.json
{
"name": "api",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"clean": "rm -rf dist"
},
"dependencies": {
"@repo/shared": "workspace:*",
"express": "^5.0.0"
},
"devDependencies": {
"@repo/config-typescript": "workspace:*",
"@types/express": "^5.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
// apps/api/src/index.ts
import express from "express";
import type { User, ApiResponse, UserCreateInput } from "@repo/shared";
import { formatDate } from "@repo/shared";
const app = express();
app.use(express.json());
app.get("/api/users", (req, res) => {
const response: ApiResponse<User[]> = {
success: true,
data: [],
};
res.json(response);
});
app.post("/api/users", (req, res) => {
const input: UserCreateInput = req.body;
// バリデーション・保存処理
res.json({ success: true, data: { ...input, id: 1 } });
});
app.listen(4000, () => {
console.log("API server running on http://localhost:4000");
});
CI/CDの設定
GitHub Actionsの設定
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Turborepoのリモートキャッシュ
- name: Build
run: pnpm turbo build --filter='...[HEAD^1]'
- name: Lint
run: pnpm turbo lint --filter='...[HEAD^1]'
- name: Test
run: pnpm turbo test --filter='...[HEAD^1]'
--filter='...[HEAD^1]'により、前回のコミットから変更があったパッケージとその依存先だけがビルド・テスト対象になります。
パッケージの追加・依存関係の管理
# 特定のworkspaceにパッケージを追加
pnpm add zod --filter=api
# ルートにdevDependencyを追加
pnpm add -D prettier -w
# workspace間の依存を追加
pnpm add @repo/shared --filter=web --workspace
# すべてのworkspaceで依存を更新
pnpm update --recursive
まとめ
pnpm WorkspacesとTurborepoの組み合わせは、TypeScriptモノレポの構築において実績のある選択肢です。
pnpm Workspacesが提供するのは、効率的な依存関係管理、ワークスペース間のパッケージ参照、そしてstrict node_modulesによる安全なモジュール解決です。
Turborepoが提供するのは、依存グラフに基づいたタスクの順序制御と並列実行、ローカル・リモートキャッシュによるビルド高速化、そして変更検知フィルターによるCIの効率化です。
モノレポの導入は、共有コードが2つ以上のアプリで必要になった時点で検討する価値があります。初期セットアップには手間がかかりますが、プロジェクトが成長するにつれて、その恩恵は大きくなります。まずはsharedパッケージで型定義を共有するところから始めてみてください。
関連記事
AIエージェント開発入門|自律型AIの仕組みと構築方法を解説【2026年版】
AI駆動コーディングワークフロー|Claude Code・Cursor・Copilotの実践的使い分け
プロンプトエンジニアリング上級編|Chain-of-Thought・Few-Shot・ReActの実践
APIレート制限の設計と実装|トークンバケット・スライディングウィンドウ解説
APIバージョニング戦略|URL・ヘッダー・クエリパラメータの使い分け
BIツール入門|Metabase・Redash・Looker Studioでデータ可視化する方法
チャットボット開発入門|LINE Bot・Slack Botの構築方法と活用事例
CI/CDパイプラインの基礎|継続的インテグレーション・デリバリーの全体像