「apps/とpackages/を分けたい」「ビルドが遅すぎる」「型の共有でts設定の地獄に陥った」——複数のNext.js/Remix/Expressアプリを束ねて運用するチームにとって、Monorepoは避けて通れません。本記事はTurborepo 2.x + pnpm workspaceを軸に、初期セットアップからキャッシュ・並列ビルド・Remote Caching・CI/CD・Next.js連携まで、コピペで動く実コード40本以上で解説する完全ガイドです。Lerna/Nxとの違いも実コードベースで比較します。
※ 個別フレームワーク詳細は姉妹記事 frameworks一覧 を参照してください。本記事は「複数アプリ・複数パッケージを束ねる土台」の話に集中します。
- Turborepoの全体像 — 2026年のMonorepo事情
- 初期セットアップ — create-turboから始める
- pnpm workspaceの設定 — パッケージ解決の土台
- turbo.json 完全解剖 — タスクを設計する
- キャッシュシステム — Local CacheとRemote Caching
- フィルタと並列実行 — 大規模化を支える機能
- internal packages — 型と設定を共有する
- shared/ui パッケージ — コンポーネントを共有する
- shared/utils パッケージ — ロジックを共通化する
- Next.js × Turborepo 実戦パターン
- Remix × Turborepo
- Express × Turborepo — APIサーバーを共存させる
- CI/CD — GitHub Actionsでキャッシュを最大化する
- Vercelデプロイ — Turborepo公式の最短ルート
- Cloudflare Pages / Workersへのデプロイ
- Nx・Lernaとの比較 — どれを選ぶべきか
- トラブルシューティング — よくある詰まりポイント
- 運用Tips — チームで使うための小ワザ集
- 学習ロードマップと案件獲得
- まとめ — 2026年のMonorepo戦略
Turborepoの全体像 — 2026年のMonorepo事情
2025年に Turborepo 2.x がリリースされ、$TURBO_DEFAULT$ 構文・UI mode(TUI)・改善されたフィルタなど、運用面が大きく改善されました。pnpm workspaceとの組み合わせが事実上のデファクトです。
なぜTurborepoなのか — Lerna/Nxとの立ち位置
- Turborepo: Vercel製。
turbo.json1ファイルで完結。学習コスト低・キャッシュ強力 - Nx: Nx製。プラグイン豊富・コードジェネレータ強力だが設定が膨大
- Lerna: 元祖。Nx社がメンテ中。publish特化・ビルドオーケストレーションは弱い
- pnpm workspace単体: パッケージ解決のみ。タスクオーケストレーションは別途必要
本記事のゴール構成
最終的に以下のディレクトリ構造を構築します。コピペで動かしながら読み進められます。
my-monorepo/
├── apps/
│ ├── web/ # Next.js 15
│ ├── docs/ # Next.js (Documentation)
│ ├── api/ # Express
│ └── admin/ # Remix
├── packages/
│ ├── ui/ # 共通UIコンポーネント
│ ├── utils/ # 共通ユーティリティ
│ ├── eslint-config/
│ ├── tsconfig/
│ ├── tailwind-config/
│ └── vitest-config/
├── pnpm-workspace.yaml
├── turbo.json
└── package.json
必要バージョン早見表(2026年5月時点)
| ツール | 推奨バージョン | 備考 |
|---|---|---|
| Node.js | 20.x LTS or 22.x | 22.x推奨 |
| pnpm | 9.x | corepack enable で導入 |
| Turborepo | 2.3.x 以上 | turbo.json v2 schema |
| TypeScript | 5.5 以上 | moduleResolution: “bundler” |
初期セットアップ — create-turboから始める
まずは公式テンプレートで雛形を作り、構造を理解しましょう。create-turboはpnpmを自動選択してくれます。
事前準備:Node.jsとpnpmのインストール
# Node.js 22をvoltaで管理
volta install node@22
# corepackでpnpm 9を有効化
corepack enable
corepack prepare pnpm@latest --activate
# バージョン確認
node -v # v22.x
pnpm -v # 9.x
create-turboで新規プロジェクト
# 最新版のTurborepoテンプレートを生成
pnpm dlx create-turbo@latest my-monorepo
# プロンプトの選択肢
# ? Which package manager do you want to use? → pnpm
# ? Where would you like to create your Turborepo? → ./my-monorepo
cd my-monorepo
pnpm install
生成された初期構造を確認
my-monorepo/
├── apps/
│ ├── docs/ # Next.js
│ └── web/ # Next.js
├── packages/
│ ├── eslint-config/
│ ├── typescript-config/
│ └── ui/ # 共通UI
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json
初回ビルドで体感する
# 初回ビルド(キャッシュなし)
pnpm turbo build
# 2回目ビルド(キャッシュヒット)→ 数秒で完了
pnpm turbo build
# ✔ web:build cache hit, replaying logs
# ✔ docs:build cache hit, replaying logs
# Tasks: 2 successful, 2 total
# Cached: 2 cached, 2 total
# Time: 1.2s >>> FULL TURBO
FULL TURBOと表示されればキャッシュが完全に効いている証拠です。1回目で30秒かかったビルドが2回目で1秒——これがTurborepoの最大の魅力です。
pnpm workspaceの設定 — パッケージ解決の土台
Turborepoは「タスクオーケストレーション」を担当し、パッケージ解決はpnpm workspaceが担います。両者の役割分担を理解しましょう。
pnpm-workspace.yaml の基本
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "tooling/*"
# 除外したい場合
- "!**/test/**"
ルートのpackage.json
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"typecheck": "turbo run typecheck",
"clean": "turbo run clean && rm -rf node_modules"
},
"devDependencies": {
"turbo": "^2.3.0",
"typescript": "^5.5.0",
"prettier": "^3.3.0"
},
"packageManager": "pnpm@9.12.0",
"engines": {
"node": ">=20"
}
}
内部パッケージの命名規則
scope付き(@my-monorepo/uiのような形式)を強く推奨します。npm公開しなくてもscopeは衝突防止に有効です。
// packages/ui/package.json
{
"name": "@my-monorepo/ui",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./button": "./src/button.tsx",
"./card": "./src/card.tsx"
},
"scripts": {
"lint": "eslint .",
"typecheck": "tsc --noEmit"
}
}
workspace依存の書き方:workspace:* プロトコル
// apps/web/package.json
{
"name": "web",
"private": true,
"dependencies": {
"@my-monorepo/ui": "workspace:*",
"@my-monorepo/utils": "workspace:*",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@my-monorepo/eslint-config": "workspace:*",
"@my-monorepo/tsconfig": "workspace:*"
}
}
追加の依存をピンポイントで入れる
# apps/web にだけ next を追加
pnpm add next --filter web
# packages/ui に react を peer dependency として追加
pnpm add react --save-peer --filter @my-monorepo/ui
# ルートにのみ devDependency を追加
pnpm add -Dw prettier
# 全workspaceに型定義を追加
pnpm add -D @types/node -r
turbo.json 完全解剖 — タスクを設計する
turbo.jsonはTurborepoの心臓部です。Turborepo 2.xではpipelineがtasksにリネームされ、$TURBO_DEFAULT$などの新構文が導入されました。
最小構成のturbo.json
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"clean": {
"cache": false
}
}
}
dependsOn の3つのモード
"^build"(キャレット): 依存パッケージのbuildタスクが先に完了することを要求"build"(プレフィックスなし): 同一パッケージ内のbuildタスクに依存"app#build"(#区切り): 特定パッケージのタスクに依存(横断的)
{
"tasks": {
"build": {
"dependsOn": ["^build"]
},
"deploy": {
"dependsOn": ["build", "test", "lint"]
},
"e2e": {
"dependsOn": ["web#build", "api#build"]
}
}
}
inputs と outputs:キャッシュ精度を決める
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": [
"$TURBO_DEFAULT$",
".env.production",
"!**/*.test.ts",
"!**/*.stories.tsx"
],
"outputs": [
".next/**",
"!.next/cache/**",
"dist/**",
"storybook-static/**"
]
}
}
}
$TURBO_DEFAULT$はTurborepo 2.0で導入された便利な構文で、「git管理下の全ファイル(.gitignore除く)」を指します。これに追加ファイルや除外を重ねるのが2026年のベストプラクティスです。
環境変数の扱い:env と passThroughEnv
{
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_*", "DATABASE_URL", "NODE_ENV"],
"passThroughEnv": ["GITHUB_TOKEN", "VERCEL_*"]
}
},
"globalEnv": ["NODE_ENV", "CI"],
"globalPassThroughEnv": ["AWS_*"]
}
env: キャッシュキーに含まれる(値が変わるとキャッシュミス)passThroughEnv: タスクには渡すが、キャッシュキーには含めない(トークン類)globalEnv/globalPassThroughEnv: 全タスク共通
並列実行と永続タスク:persistent
{
"tasks": {
"dev": {
"cache": false,
"persistent": true,
"interactive": true
},
"watch": {
"cache": false,
"persistent": true
}
}
}
persistent: trueはturbo devで複数アプリを並列起動するときに必須です。「タスクが終わらないこと」を明示的に宣言する設定です。
キャッシュシステム — Local CacheとRemote Caching
Turborepoのキャッシュは「inputs hash → outputsをzstd圧縮して保存」というシンプルかつ強力な仕組みです。理解すればCI時間を桁違いに短縮できます。
Local Cacheの仕組みを覗く
# キャッシュは ~/.turbo or node_modules/.cache/turbo に保存
ls node_modules/.cache/turbo/
# 詳細ログを見る
TURBO_LOG_VERBOSITY=2 pnpm turbo build
# どのファイルがハッシュに含まれているか確認
pnpm turbo build --dry-run=json | jq '.tasks[0].inputs'
キャッシュキーは何で構成されるか
キャッシュキー = SHA256(
- inputsで指定されたファイル群の内容
- envで指定された環境変数の値
- turbo.jsonの該当タスク定義
- 依存パッケージのアウトプットhash
- グローバル設定(globalDependencies等)
)
キャッシュを意図的に無効化する
# 強制的に再ビルド
pnpm turbo build --force
# 特定パッケージのキャッシュだけ破棄
pnpm turbo build --filter=web --force
# キャッシュディレクトリを物理削除
rm -rf node_modules/.cache/turbo .turbo
Remote Caching:チーム全体でキャッシュ共有
これがTurborepoの最大の差別化要素です。Vercel公式のRemote Cacheを無料で使えます。チームメンバーAがビルドしたキャッシュを、メンバーBやCIが利用できるようになります。
# Vercelアカウントとリンク
pnpm turbo login
# Remote Cacheにリンク
pnpm turbo link
# 以後、ビルド結果が自動でアップロード/ダウンロードされる
pnpm turbo build
# ✔ web:build cache hit, replaying logs (remote)
# Remote caching enabled
Self-hosted Remote Cache(S3互換)
# Cloudflare R2やAWS S3でセルフホストする例
# 環境変数で接続先を指定
export TURBO_API="https://my-cache-server.example.com"
export TURBO_TOKEN="my-secret-token"
export TURBO_TEAM="my-team"
pnpm turbo build --remote-cache-timeout=30
CIだけアップロード/ローカルはダウンロードのみ
# ローカルはダウンロード専用(誤った汚染を防ぐ)
pnpm turbo build --cache=remote:r,local:rw
# CIではフル読み書き
pnpm turbo build --cache=remote:rw,local:rw
フィルタと並列実行 — 大規模化を支える機能
Monorepoが大きくなると「特定アプリだけ」「変更があったパッケージだけ」をビルドしたい場面が増えます。--filterはその要です。
–filterの基本パターン10連発
# 特定パッケージだけビルド
pnpm turbo build --filter=web
# scopeつきパッケージ
pnpm turbo build --filter=@my-monorepo/ui
# 複数指定(OR)
pnpm turbo build --filter=web --filter=api
# 依存先も含めて(uiが依存しているパッケージ全部)
pnpm turbo build --filter=@my-monorepo/ui...
# 依存元も含めて(uiに依存しているアプリ全部)
pnpm turbo build --filter=...@my-monorepo/ui
# 依存元+ui自身
pnpm turbo build --filter=...@my-monorepo/ui...
# 除外
pnpm turbo build --filter=!docs
# パスベース(apps配下のみ)
pnpm turbo build --filter='./apps/*'
# git変更があったパッケージだけ
pnpm turbo build --filter=...[HEAD^]
# 特定ブランチからの差分
pnpm turbo build --filter=...[origin/main]
並列度の制御:–concurrency
# 並列度を明示(デフォルトはCPU数)
pnpm turbo build --concurrency=4
# 完全直列(デバッグ用)
pnpm turbo build --concurrency=1
# 並列度をパーセント指定
pnpm turbo build --concurrency=50%
topological(依存順序)実行の仕組み
Turborepoは「依存グラフを解析し、依存先を先にビルドする」を自動で行います。--dry-runで実行順を可視化できます。
# 実行計画を確認(実行はしない)
pnpm turbo build --dry-run
# JSON形式で取得
pnpm turbo build --dry-run=json > build-plan.json
# 依存グラフを画像化(graphvizが必要)
pnpm turbo build --graph=graph.png
UI mode(TUI)— Turborepo 2.xの目玉機能
# インタラクティブなターミナルUIを起動
pnpm turbo dev --ui=tui
# 個別タスクのログに切り替え可能(矢印キーで選択)
# - Web Server
# - Docs Server
# - API Server
従来は複数アプリのログが混ざって見づらかったdevモードが、TUIによってタブ切り替えできるようになりました。--ui=streamで従来の挙動にも戻せます。
internal packages — 型と設定を共有する
Monorepoの真価は「設定」と「コード」を共通化できることにあります。型・ESLint・Tailwind・Vitest・Storybookの設定を一元管理しましょう。
tsconfig共有パッケージ
// packages/tsconfig/package.json
{
"name": "@my-monorepo/tsconfig",
"version": "0.0.0",
"private": true,
"files": ["base.json", "nextjs.json", "react-library.json", "node.json"]
}
// packages/tsconfig/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"incremental": true,
"forceConsistentCasingInFileNames": true
}
}
// packages/tsconfig/nextjs.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "ES2022"],
"jsx": "preserve",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"noEmit": true,
"plugins": [{ "name": "next" }]
}
}
// apps/web/tsconfig.json
{
"extends": "@my-monorepo/tsconfig/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
ESLint共有設定(Flat Config時代)
// packages/eslint-config/base.js
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import prettier from 'eslint-config-prettier'
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
rules: {
'no-console': ['warn', { allow: ['warn', 'error'] }],
'@typescript-eslint/consistent-type-imports': 'error',
},
},
{
ignores: ['dist/**', '.next/**', 'node_modules/**'],
},
)
// packages/eslint-config/next.js
import base from './base.js'
import nextPlugin from '@next/eslint-plugin-next'
import reactPlugin from 'eslint-plugin-react'
import reactHooksPlugin from 'eslint-plugin-react-hooks'
export default [
...base,
{
plugins: { '@next/next': nextPlugin, react: reactPlugin, 'react-hooks': reactHooksPlugin },
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs['core-web-vitals'].rules,
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
]
// apps/web/eslint.config.js
import nextConfig from '@my-monorepo/eslint-config/next'
export default nextConfig
Tailwind共有設定
// packages/tailwind-config/tailwind.config.js
import type { Config } from 'tailwindcss'
const config: Omit<Config, 'content'> = {
theme: {
extend: {
colors: {
brand: {
50: '#eff6ff',
500: '#3b82f6',
900: '#1e3a8a',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}
export default config
// apps/web/tailwind.config.js
import sharedConfig from '@my-monorepo/tailwind-config'
/** @type {import('tailwindcss').Config} */
export default {
...sharedConfig,
content: [
'./src/**/*.{ts,tsx}',
'../../packages/ui/src/**/*.{ts,tsx}',
],
}
Vitest共有設定
// packages/vitest-config/base.ts
import { defineConfig } from 'vitest/config'
export const baseConfig = defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.config.*', '**/node_modules/**', '**/dist/**'],
},
},
})
// packages/vitest-config/react.ts
import { mergeConfig, defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { baseConfig } from './base'
export const reactConfig = mergeConfig(
baseConfig,
defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
setupFiles: ['./vitest.setup.ts'],
},
}),
)
// apps/web/vitest.config.ts
import { reactConfig } from '@my-monorepo/vitest-config/react'
export default reactConfig
Storybook共有設定
// packages/storybook-config/main.ts
import type { StorybookConfig } from '@storybook/nextjs'
export const baseStorybookConfig: Partial<StorybookConfig> = {
addons: [
'@storybook/addon-essentials',
'@storybook/addon-a11y',
'@storybook/addon-interactions',
],
framework: { name: '@storybook/nextjs', options: {} },
docs: { autodocs: 'tag' },
}
// apps/web/.storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs'
import { baseStorybookConfig } from '@my-monorepo/storybook-config/main'
const config: StorybookConfig = {
...baseStorybookConfig,
stories: ['../src/**/*.stories.@(ts|tsx)'],
}
export default config
shared/ui パッケージ — コンポーネントを共有する
2つ以上のアプリで同じButton/Cardを使うなら、すぐにpackages/uiに切り出すべきです。重要なのは「ビルドせずにソースのまま参照」する方針です。
packages/ui の構造
packages/ui/
├── src/
│ ├── index.ts
│ ├── button.tsx
│ ├── card.tsx
│ └── input.tsx
├── package.json
├── tsconfig.json
└── eslint.config.js
exports field で複数エントリーポイント
// packages/ui/package.json
{
"name": "@my-monorepo/ui",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx",
"./input": "./src/input.tsx",
"./styles.css": "./src/styles.css"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@my-monorepo/eslint-config": "workspace:*",
"@my-monorepo/tsconfig": "workspace:*",
"@types/react": "^19.0.0",
"react": "^19.0.0",
"typescript": "^5.5.0"
}
}
Button コンポーネント本体
// packages/ui/src/button.tsx
import { forwardRef, type ComponentPropsWithoutRef } from 'react'
type Variant = 'primary' | 'secondary' | 'danger'
type Size = 'sm' | 'md' | 'lg'
export interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
variant?: Variant
size?: Size
isLoading?: boolean
}
const variants: Record<Variant, string> = {
primary: 'bg-brand-500 hover:bg-brand-600 text-white',
secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-900',
danger: 'bg-red-500 hover:bg-red-600 text-white',
}
const sizes: Record<Size, string> = {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2',
lg: 'px-6 py-3 text-lg',
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', isLoading, children, className = '', ...rest }, ref) => (
<button
ref={ref}
className={`rounded ${variants[variant]} ${sizes[size]} ${className}`}
disabled={isLoading || rest.disabled}
{...rest}
>
{isLoading ? '...' : children}
</button>
),
)
Button.displayName = 'Button'
Next.js側でtranspilePackages
「ソースのまま参照」方式の場合、Next.jsには明示的に「このパッケージはTranspileしてね」と伝える必要があります。
// apps/web/next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: [
'@my-monorepo/ui',
'@my-monorepo/utils',
],
experimental: {
typedRoutes: true,
},
}
export default nextConfig
使う側のコード
// apps/web/src/app/page.tsx
import { Button } from '@my-monorepo/ui/button'
export default function Home() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold">Welcome</h1>
<Button variant="primary" size="lg" onClick={() => alert('hi')}>
Click me
</Button>
</main>
)
}
shared/utils パッケージ — ロジックを共通化する
日付処理・バリデーション・API clientなど、フロントエンドとバックエンドで共有したいロジックはpackages/utilsに集めましょう。
packages/utils の構造
packages/utils/
├── src/
│ ├── index.ts
│ ├── date.ts
│ ├── validation.ts
│ ├── api-client.ts
│ └── env.ts
├── package.json
└── tsconfig.json
環境変数のスキーマ検証(Zod)
// packages/utils/src/env.ts
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NEXT_PUBLIC_API_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'test', 'production']),
})
export type Env = z.infer<typeof envSchema>
export function validateEnv(env: NodeJS.ProcessEnv = process.env): Env {
const parsed = envSchema.safeParse(env)
if (!parsed.success) {
console.error('❌ Invalid env:', parsed.error.flatten().fieldErrors)
throw new Error('Invalid environment variables')
}
return parsed.data
}
共通APIクライアント
// packages/utils/src/api-client.ts
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
this.name = 'ApiError'
}
}
export interface ApiClientOptions {
baseUrl: string
headers?: Record<string, string>
}
export function createApiClient({ baseUrl, headers = {} }: ApiClientOptions) {
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...headers, ...init?.headers },
})
if (!res.ok) throw new ApiError(res.status, await res.text())
return res.json() as Promise<T>
}
return {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
}
}
Next.js × Turborepo 実戦パターン
本記事の主要ターゲットであるNext.js連携を詳しく見ていきます。「2つのNext.jsアプリ + 共通UI」が最頻出構成です。
apps/web(メインサイト)のpackage.json
{
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@my-monorepo/ui": "workspace:*",
"@my-monorepo/utils": "workspace:*",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
apps/docs(ドキュメント)のpackage.json
{
"name": "docs",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@my-monorepo/ui": "workspace:*",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
両方を同時起動する
# TUIモードで2アプリ同時起動(タブで切り替え)
pnpm turbo dev --ui=tui
# 個別起動も可能
pnpm turbo dev --filter=web
pnpm turbo dev --filter=docs
Next.js App RouterからUIコンポーネントをImport
// apps/web/src/app/products/page.tsx
import { Button } from '@my-monorepo/ui/button'
import { Card } from '@my-monorepo/ui/card'
import { createApiClient } from '@my-monorepo/utils'
const api = createApiClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL! })
export default async function ProductsPage() {
const products = await api.get<Product[]>('/products')
return (
<div className="grid grid-cols-3 gap-4 p-8">
{products.map(p => (
<Card key={p.id}>
<h2>{p.name}</h2>
<Button>Buy</Button>
</Card>
))}
</div>
)
}
interface Product { id: string; name: string; price: number }
Remix × Turborepo
Next.js以外のフレームワークもTurborepoに乗せやすい設計になっています。Remixでの実例を見ましょう。
Remixアプリの追加
cd apps
pnpm dlx create-remix@latest admin
# - JavaScript or TypeScript? → TypeScript
# - Initialize a new git repository? → No
共通UIをRemixで使う
// apps/admin/app/routes/_index.tsx
import { Button } from '@my-monorepo/ui/button'
import { Card } from '@my-monorepo/ui/card'
export default function Index() {
return (
<div className="p-8">
<Card>
<h1>Admin Dashboard</h1>
<Button variant="danger">Delete</Button>
</Card>
</div>
)
}
RemixのserverDependenciesToBundle設定
// apps/admin/remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
export default {
serverDependenciesToBundle: [
/^@my-monorepo/.*/,
],
tailwind: true,
}
Express × Turborepo — APIサーバーを共存させる
フロント+バックエンドを1リポジトリで開発するチームには必須の構成です。型を共有できるのが最大のメリットです。
apps/api(Express)の初期化
mkdir -p apps/api/src
cd apps/api
pnpm init
// apps/api/package.json
{
"name": "api",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --build",
"start": "node dist/index.js",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@my-monorepo/utils": "workspace:*",
"express": "^4.21.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/express": "^4.17.0",
"tsx": "^4.19.0",
"typescript": "^5.5.0"
}
}
Expressコード本体
// apps/api/src/index.ts
import express from 'express'
import { z } from 'zod'
import { validateEnv } from '@my-monorepo/utils'
const env = validateEnv()
const app = express()
app.use(express.json())
const ProductSchema = z.object({
id: z.string(),
name: z.string().min(1),
price: z.number().positive(),
})
const products: z.infer<typeof ProductSchema>[] = []
app.get('/products', (_req, res) => res.json(products))
app.post('/products', (req, res) => {
const parsed = ProductSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json(parsed.error.flatten())
products.push(parsed.data)
res.status(201).json(parsed.data)
})
app.listen(4000, () => console.log(`API listening on :4000 (env=${env.NODE_ENV})`))
型を共有する:packages/types
// packages/types/src/index.ts
import { z } from 'zod'
export const ProductSchema = z.object({
id: z.string(),
name: z.string().min(1),
price: z.number().positive(),
})
export type Product = z.infer<typeof ProductSchema>
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
})
export type User = z.infer<typeof UserSchema>
これでapps/apiのサーバー側とapps/webのクライアント側で、1つのZodスキーマから型と実行時バリデーションを両方とも得ることができます。
CI/CD — GitHub Actionsでキャッシュを最大化する
Turborepoの真価はCIで発揮されます。Remote Cacheと組み合わせれば、変更がないジョブは数秒で完了します。
GitHub Actions基本ワークフロー
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
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
- run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm turbo lint --filter=...[origin/main]
- name: Typecheck
run: pnpm turbo typecheck --filter=...[origin/main]
- name: Build
run: pnpm turbo build --filter=...[origin/main]
- name: Test
run: pnpm turbo test --filter=...[origin/main]
差分ビルド:変更があったアプリだけビルド
--filter=...[origin/main]がポイントです。「origin/mainから変更があったパッケージ + それに依存する全アプリ」だけを実行します。
# ローカルで動作確認
pnpm turbo build --filter=...[origin/main] --dry-run
# 例:packages/uiだけ変更した場合
# → packages/ui、apps/web、apps/docs、apps/admin が対象
# → apps/api は対象外(uiを使っていないため)
matrixでパッケージごとに並列ジョブ
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
package: [web, docs, admin, api, '@my-monorepo/ui']
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm turbo test --filter=${{ matrix.package }}
turboのキャッシュをGitHub Actions Cacheで保存
- name: Cache turbo build setup
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
Vercelデプロイ — Turborepo公式の最短ルート
Vercelは「Turborepoの実家」なので、最も摩擦のないデプロイ先です。
各アプリを個別Projectで設定
# apps/web をVercelにデプロイ
cd apps/web
vercel link
# Root Directoryを apps/web に指定
# Build Command: cd ../.. && pnpm turbo build --filter=web...
# Output Directory: .next
# Install Command: cd ../.. && pnpm install --frozen-lockfile
vercel.json で設定を固定
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "cd ../.. && pnpm turbo build --filter=web...",
"installCommand": "cd ../.. && pnpm install --frozen-lockfile",
"ignoreCommand": "cd ../.. && pnpm turbo-ignore web"
}
turbo-ignore で不要なデプロイをスキップ
# webに関係ない変更ならビルドをスキップ
# (例:docsだけ変更されたPushでwebをデプロイしない)
pnpm turbo-ignore web
VercelはignoreCommandが終了コード0を返すとビルドをスキップします。turbo-ignoreはこれを自動判定してくれる便利ツールです。
Cloudflare Pages / Workersへのデプロイ
Vercel以外も問題なく動きます。Cloudflareへのデプロイ手順を見ましょう。
Cloudflare Pages(Next.js)
# @cloudflare/next-on-pagesを使う
pnpm add -D @cloudflare/next-on-pages --filter=web
# ビルドコマンド
pnpm turbo build --filter=web && pnpm dlx @cloudflare/next-on-pages
# wrangler.toml
name = "my-web"
compatibility_date = "2025-01-01"
pages_build_output_dir = ".vercel/output/static"
[vars]
NEXT_PUBLIC_API_URL = "https://api.example.com"
Cloudflare Workers(Hono API)
// apps/api-worker/src/index.ts
import { Hono } from 'hono'
import { ProductSchema } from '@my-monorepo/types'
const app = new Hono()
app.get('/products', c => c.json([{ id: '1', name: 'Test', price: 100 }]))
app.post('/products', async c => {
const body = await c.req.json()
const parsed = ProductSchema.safeParse(body)
if (!parsed.success) return c.json(parsed.error, 400)
return c.json(parsed.data, 201)
})
export default app
Nx・Lernaとの比較 — どれを選ぶべきか
「Turborepoが一強」ではありません。プロジェクト規模・チーム文化によってベストな選択は変わります。実コードベースで比較します。
機能比較マトリクス
| 機能 | Turborepo 2.x | Nx 19+ | Lerna 8+ |
|---|---|---|---|
| タスクオーケストレーション | ◎ | ◎ | ○ |
| Local Cache | ◎ | ◎ | ×(Nx統合後はNx側) |
| Remote Cache(無料) | ◎(Vercel) | △(Nx Cloud有料中心) | × |
| コードジェネレータ | ×(create-turboのみ) | ◎(豊富なplugin) | △ |
| 影響範囲検出 | ◎(–filter=…[ref]) | ◎(nx affected) | △ |
| publish機能 | ×(changeset併用) | ○ | ◎(本家機能) |
| 学習コスト | 低 | 中〜高 | 低 |
| 設定ファイル量 | turbo.json 1個 | project.json多数 | lerna.json 1個 |
Nxと同じことをTurborepoで書くと?
// Nx: project.json(各パッケージに1個)
{
"name": "ui",
"targets": {
"build": {
"executor": "@nx/js:swc",
"outputs": ["{options.outputPath}"],
"options": { "outputPath": "dist/packages/ui" }
}
}
}
// Turborepo: turbo.json 1つ + 各package.jsonにscripts
{
"tasks": {
"build": { "outputs": ["dist/**"] }
}
}
// packages/ui/package.json
{
"scripts": { "build": "swc src -d dist" }
}
選び方の指針
- Turborepo: 「ビルドキャッシュとシンプルさが欲しい」「Vercelデプロイがメイン」 → 第一候補
- Nx: 「Angular中心」「コードジェネレータで標準化したい」「Nx Cloudを既に契約」
- Lerna(Nx統合版): 「OSSをnpm publishしたい」「version管理を一括でやりたい」
トラブルシューティング — よくある詰まりポイント
1. キャッシュが効かない
# 何がキャッシュキーに含まれているか確認
pnpm turbo build --dry-run=json | jq '.tasks[] | {task, hash, inputs}'
# 環境変数の差で毎回ミスする → globalEnvまたはtask.envに明示
# .envファイルの違いで毎回ミスする → inputsに .env* を含める or 除外
2. transpilePackagesが効かない
// apps/web/next.config.mjs
const nextConfig = {
transpilePackages: ['@my-monorepo/ui', '@my-monorepo/utils'],
// ❌ 'ui' のようにscopeなしだとマッチしない
// ❌ ワイルドカードは未対応(2026年時点)
}
3. 型が解決できない:moduleResolutionの罠
// packages/tsconfig/base.json
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler", // ← これでないとexportsフィールドが効かない
"verbatimModuleSyntax": true
}
}
4. pnpm install後にlockfile差分が出る
# CIで「lockfileが古い」エラーになる場合
pnpm install --frozen-lockfile # CI用
pnpm install --lockfile-only # lockfileだけ更新
# .npmrcに以下を追加
echo "auto-install-peers=true" >> .npmrc
echo "strict-peer-dependencies=false" >> .npmrc
5. turbo dev で複数アプリのログが混ざる
# TUIモードで切り替え
pnpm turbo dev --ui=tui
# またはログを別ファイルに
pnpm turbo dev --log-prefix=task --log-order=stream
6. Vercelでmonorepoの正しいRoot Directoryが選べない
Vercel Project Settings →
- Root Directory: apps/web
- Framework Preset: Next.js
- Build Command: cd ../.. && pnpm turbo build --filter=web...
- Install Command: cd ../.. && pnpm install --frozen-lockfile
7. circular dependencyで詰まる
# 循環参照を検出
pnpm dlx madge --circular --extensions ts,tsx packages/ apps/
# turboレベルでも検出される
pnpm turbo build
# Error: detected cycle: ui < utils < ui
8. PNPMのpeer dependencies警告を消す
# pnpm-workspace.yaml に追加(pnpm 9+)
packages:
- "apps/*"
- "packages/*"
# .npmrc
auto-install-peers=true
strict-peer-dependencies=false
shamefully-hoist=false
運用Tips — チームで使うための小ワザ集
1. Changeset でversion管理
pnpm add -Dw @changesets/cli
pnpm dlx changeset init
# PRごとに変更内容を記録
pnpm changeset
# versionとchangelog生成
pnpm changeset version
# publish(該当時)
pnpm changeset publish
2. husky + lint-staged でcommit時に検証
pnpm add -Dw husky lint-staged
pnpm dlx husky init
echo "pnpm lint-staged" > .husky/pre-commit
// package.json (root)
{
"lint-staged": {
"*.{ts,tsx}": ["pnpm turbo lint --filter=...[HEAD]", "prettier --write"],
"*.{json,md,yaml}": ["prettier --write"]
}
}
3. VS Code workspaceの設定
// .vscode/settings.json
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"eslint.workingDirectories": [
{ "pattern": "apps/*" },
{ "pattern": "packages/*" }
],
"search.exclude": {
"**/node_modules": true,
"**/.turbo": true,
"**/.next": true
}
}
4. turbo runの実行時間を可視化
# Chrome Trace形式で出力
pnpm turbo build --profile=profile.json
# chrome://tracing で profile.json を開く
# → 並列実行・依存待ち時間が視覚化される
学習ロードマップと案件獲得
3週間で習熟するためのプラン
- Week 1: create-turboで雛形 → turbo.jsonとpnpm-workspaceの基本を読み解く
- Week 2: packages/ui + packages/utils + packages/tsconfig を自前で実装
- Week 3: Remote Caching + GitHub Actions + Vercelデプロイまで通す
ポートフォリオに載せるべき成果物
- 2つのNext.jsアプリ + 共通UI + 共通utilsの完全動作Monorepo
- GitHub Actionsで差分ビルドが効いているスクリーンショット(time比較)
- turbo –dry-run=jsonの実行結果・graph画像
- Remote Cacheが効いた証拠ログ(cache hit, replaying logs (remote))
案件で問われる定番質問
- 「なぜLernaやNxではなくTurborepoを選んだ?」 → シンプルさ・Remote Cache無料・Vercel連携
- 「キャッシュキーは何で構成される?」 → inputs + env + turbo.json + 依存hash
- 「
^buildとbuildの違いは?」 → 依存パッケージ vs 同一パッケージ - 「monorepoで型を共有する最小構成は?」 → packages/tsconfig + workspace:* + moduleResolution: bundler
- 「Vercelで2アプリを別Projectで動かす方法は?」 → Root Directory + Build Commandを各アプリで設定
独学が辛いと感じたら
Turborepoは「動かす」までは独学で行けても、キャッシュキー設計・CI最適化・大規模化に伴うリファクタリングは実務での試行錯誤が必要な領域です。複数アプリ・複数チームの実プロジェクトで揉まれることで初めて勘所が掴めます。スクールでメンターに直接質問できる環境を作ると、学習効率は数倍変わります。
- テックアカデミー — Next.js / Reactコースで実プロジェクトを伴走指導。週2回のメンタリングでMonorepo設計の判断まで相談できる
- 侍エンジニア — マンツーマンでTurborepo・モダンフロント全般を学べる。オーダーメイドカリキュラム対応で自社の構成に近づけられる
- DMM WEBCAMP — 転職保証付き。Next.js + Monorepo構成のモダン現場への転職を目指すなら最有力
- レバテックフリーランス — 学習後の案件獲得に。Turborepo構成の月単価70万円〜の案件も増加中
まとめ — 2026年のMonorepo戦略
結論を1行で:新規プロジェクト + Next.js/Remix中心 = Turborepo 2.x + pnpm workspace + Vercel Remote Cache がデファクトです。Nxの強力なジェネレータが欲しい、Lernaのpublish機能が必須、というケースを除けば、Turborepoが2026年の最良の選択肢です。
本記事のコードはすべてコピペで動作確認できます。まずはpnpm dlx create-turbo@latestで雛形を作り、packages/uiとpackages/tsconfigを自作してから、Remote CachingとGitHub Actionsまで一気通貫で組み上げてみてください。一度動かしてしまえば、その後の生産性は劇的に変わります——「変更があったアプリだけ、1分以内にCIが終わる」体験は、一度味わうと戻れなくなります。
関連記事: Next.js 15完全実践ガイド / App Router完全実践ガイド / Remix完全実践ガイド / Vite 6完全実践ガイド / frameworks一覧

コメント