Monorepo 全端開發實踐:用 Turborepo 管理 Next.js + NestJS 專案
分享使用 Turborepo + pnpm workspace 管理全端 Monorepo 的實際經驗,涵蓋目錄結構設計、共享套件策略、建置管線優化與 Docker 部署。
為什麼用 Monorepo
在開發一個全端專案(前端 Next.js + 後端 NestJS + 共用型別/配置)時,最常見的選擇是:
Multi-repo:前端和後端各自一個 repository
- 優點:職責清晰、各自獨立部署
- 缺點:共用程式碼(型別定義、utils)難以同步;跨 repo 的改動需要多個 PR
Monorepo:所有程式碼在同一個 repository
- 優點:共用程式碼直接 import、原子性的跨模組改動、統一的工具鏈
- 缺點:需要額外的工具管理建置順序和快取
對我們的場景——前後端共用大量型別定義、且團隊人數不多——Monorepo 是更好的選擇。
目錄結構設計
project-root/
├── apps/
│ ├── web/ # Next.js 前端
│ └── api/ # NestJS 後端
├── packages/
│ ├── database/ # Drizzle schema + migrations
│ ├── config/ # 共用設定(ESLint, TypeScript)
│ └── types/ # 共用型別定義
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
為什麼用這種結構?
apps/放可獨立部署的應用packages/放被多個 app 共用的程式碼- 清楚的依賴方向:
apps/依賴packages/,反過來不行
pnpm workspace 設定
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
每個 package 的 package.json
// packages/database/package.json
{
"name": "@project/database",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate"
}
}
// apps/web/package.json
{
"name": "@project/web",
"dependencies": {
"@project/database": "workspace:*",
"@project/types": "workspace:*"
}
}
workspace:* 告訴 pnpm:這個依賴來自同一個 workspace,不要去 npm registry 找。
Turborepo:建置管線與快取
turbo.json 設定
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"dev": {
"persistent": true,
"cache": false
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
},
"db:generate": {
"cache": false
}
}
}
關鍵是 "dependsOn": ["^build"]——^ 代表「先建置我依賴的 packages」。所以當你 build apps/web 時,Turborepo 會自動先 build packages/database 和 packages/types。
快取的威力
Turborepo 會對每個 task 做 content-hash based 快取。如果你只改了 apps/web 的程式碼:
packages/database的 build → 命中快取(跳過)packages/types的 build → 命中快取(跳過)apps/web的 build → 重新建置
在大型 monorepo 中,這能把 CI 時間從 10 分鐘降到 2 分鐘。
共用 Database Package
這是 Monorepo 最有價值的地方之一——前後端共用同一份資料庫 schema 定義:
// packages/database/src/schema.ts
import { pgTable, varchar, integer, timestamp, jsonb } from 'drizzle-orm/pg-core';
export const cards = pgTable('cards', {
id: varchar('id').primaryKey(),
name: varchar('name').notNull(),
setCode: varchar('set_code').notNull(),
rarity: varchar('rarity'),
imageUrl: varchar('image_url'),
prices: jsonb('prices'), // { nm: 120, lp: 100, mp: 80 }
updatedAt: timestamp('updated_at').defaultNow(),
});
export const listings = pgTable('listings', {
id: integer('id').generatedAlwaysAsIdentity().primaryKey(),
cardId: varchar('card_id').references(() => cards.id),
sellerId: integer('seller_id').references(() => users.id),
condition: varchar('condition').notNull(), // NM, LP, MP, HP, DMG
price: integer('price').notNull(),
quantity: integer('quantity').notNull(),
createdAt: timestamp('created_at').defaultNow(),
});
// ... 更多 table
// packages/database/src/index.ts
export * from './schema';
export * from './client';
export type { InferSelectModel, InferInsertModel } from 'drizzle-orm';
前後端各自使用
// apps/api/src/listings/listings.service.ts
import { db, listings, cards } from '@project/database';
import { eq, and, gte, lte } from 'drizzle-orm';
async function searchListings(filters: SearchFilters) {
return db.select()
.from(listings)
.innerJoin(cards, eq(listings.cardId, cards.id))
.where(and(
filters.minPrice ? gte(listings.price, filters.minPrice) : undefined,
filters.maxPrice ? lte(listings.price, filters.maxPrice) : undefined,
));
}
// apps/web/src/lib/api.ts
import type { InferSelectModel } from '@project/database';
import type { listings, cards } from '@project/database';
// 前端用 schema 推導出的型別,確保與 DB 同步
type Listing = InferSelectModel<typeof listings>;
type Card = InferSelectModel<typeof cards>;
改了 schema → 前後端同時得到型別更新 → 不一致的地方立刻報錯。
共用型別 Package
除了 DB schema 衍生的型別,還有 API request/response 的型別:
// packages/types/src/api.ts
export interface SearchRequest {
cardName?: string;
setCode?: string;
minPrice?: number;
maxPrice?: number;
condition?: CardCondition[];
page?: number;
pageSize?: number;
}
export interface SearchResponse {
items: ListingWithCard[];
pagination: {
page: number;
totalPages: number;
totalItems: number;
};
}
export type CardCondition = 'NM' | 'LP' | 'MP' | 'HP' | 'DMG';
前後端都從 @project/types import,任何 API 格式的變動都會被 TypeScript 即時捕捉。
開發體驗
一行指令啟動所有服務
// root package.json
{
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint"
}
}
pnpm dev 同時啟動 Next.js dev server + NestJS watch mode + 任何其他需要的服務。Turborepo 會在一個整合的終端機中顯示所有服務的 log。
TypeScript Project References
為了讓 IDE 的跳轉和自動完成正確運作,需要在 tsconfig 中設定 project references:
// apps/web/tsconfig.json
{
"compilerOptions": {
"paths": {
"@project/database": ["../../packages/database/src"],
"@project/types": ["../../packages/types/src"]
}
},
"references": [
{ "path": "../../packages/database" },
{ "path": "../../packages/types" }
]
}
這樣在 VS Code 中 Ctrl+Click 就能直接跳到 package 的原始碼。
Docker 部署策略
Monorepo 的 Docker 化比較需要技巧——你不想每個 app 的映像都包含整個 monorepo。
使用 Turborepo prune
# Stage 1: 用 turbo prune 提取只與 web app 相關的檔案
FROM node:20-alpine AS pruner
WORKDIR /app
COPY . .
RUN npx turbo prune @project/web --docker
# Stage 2: 安裝依賴
FROM node:20-alpine AS deps
WORKDIR /app
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile
# Stage 3: 建置
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/ .
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter=@project/web
# Stage 4: 執行
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./.next/static
COPY --from=builder /app/apps/web/public ./public
CMD ["node", "server.js"]
turbo prune 會只提取目標 app 和它的依賴 packages,排除不相關的程式碼。最終映像乾淨且精簡。
踩過的坑
坑 1:Workspace 依賴的版本解析
// 不好:用 "*" 可能匹配到 npm registry 的套件
"@project/database": "*"
// 好:明確指定是 workspace 來源
"@project/database": "workspace:*"
坑 2:循環依賴
如果 packages/a import 了 packages/b,packages/b 又 import 了 packages/a,Turborepo 的 build 會報錯。
解法:把被雙方共用的東西抽到第三個 package(如 packages/shared)。
坑 3:Root dependencies vs Package dependencies
共用的開發工具(ESLint、TypeScript、Prettier)裝在 root。業務邏輯的依賴裝在各自的 package/app 中。
# 裝在 root(-w flag)
pnpm add -Dw eslint typescript prettier
# 裝在特定 app
pnpm add react next --filter @project/web
坑 4:Next.js 的 transpilePackages
Next.js 預設不會 transpile node_modules 中的套件。但 workspace packages 是 TypeScript 原始碼,需要告訴 Next.js 處理它們:
// apps/web/next.config.ts
const nextConfig = {
transpilePackages: ['@project/database', '@project/types'],
};
何時不該用 Monorepo
Monorepo 不是銀彈。以下情況可能不適合:
- 團隊很大(50+ 人):不同團隊可能有不同的 release 節奏,monorepo 的「全部一起」可能拖慢速度
- 技術棧差異太大:如果前端是 React + TypeScript、後端是 Go 或 Rust,共享程式碼的價值有限
- 需要獨立的存取權限:monorepo 中所有人都能看到所有程式碼
- 已有成熟的 CI/CD:如果現有的 multi-repo CI 已經很順,遷移成本可能大於收益
結語
Monorepo 的核心價值不是「把程式碼放在一起」,而是「消除跨模組的同步成本」。型別不一致、版本不同步、跨 repo PR 的協調——這些在 multi-repo 中常見的痛點,在 Monorepo 中幾乎不存在。
Turborepo + pnpm workspace 是目前 JavaScript/TypeScript 生態中最成熟的組合。初始設定需要花一些時間,但一旦跑起來,開發體驗會明顯提升。
關於作者

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。