Monorepo 与 Turborepo
当项目增长到一定规模——多个 Next.js 应用、共享 UI 组件库、公共工具函数、统一的配置——把它们分散在多个 Git 仓库中会带来版本同步噩梦。Monorepo 把所有代码放在一个仓库,用工作空间(workspace)管理依赖,用 Turborepo 加速构建。本章从零搭建一个生产级 Monorepo。
1. 为什么用 Monorepo
1.1 Monorepo vs Polyrepo
| 维度 | Monorepo | Polyrepo(多仓库) |
|---|---|---|
| 代码共享 | 直接 import,零发布成本 | 发 npm 包,版本管理 |
| 原子提交 | 一个 PR 改多个包 | 多仓库分别 PR |
| 依赖一致性 | 全局统一版本 | 各仓库版本可能不同 |
| CI/CD | 统一 pipeline | 每个仓库独立配置 |
| 构建速度 | 需要增量构建工具 | 天然独立 |
| 仓库体积 | 较大 | 较小 |
| 权限控制 | 需要 CODEOWNERS | 仓库级隔离 |
适合 Monorepo 的场景:
- 多个 Next.js 应用共享组件和逻辑
- 前端应用 + 共享 UI 库 + 工具包
- 全栈项目(Web + API + Worker)
- 团队 3-30 人
1.2 技术选型
包管理器:pnpm(workspace 原生支持 + 磁盘效率高)
构建编排:Turborepo(增量构建 + 远程缓存)
代码共享:Internal Packages(不发布到 npm)
2. pnpm Workspace 基础
2.1 初始化项目
mkdir my-saas && cd my-saas
pnpm init
# 创建 workspace 配置
cat > pnpm-workspace.yaml << 'EOF'
packages:
- "apps/*"
- "packages/*"
EOF2.2 目录结构
my-saas/
├── apps/
│ ├── web/ # 主 Web 应用(Next.js)
│ │ ├── app/
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── admin/ # 管理后台(Next.js)
│ │ ├── app/
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── docs/ # 文档站(Next.js / VitePress)
│ ├── package.json
│ └── ...
├── packages/
│ ├── ui/ # 共享 UI 组件库
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── db/ # 数据库 Schema + 连接
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── auth/ # 认证逻辑
│ │ ├── src/
│ │ └── package.json
│ ├── email/ # 邮件模板
│ │ ├── src/
│ │ └── package.json
│ ├── config-eslint/ # 共享 ESLint 配置
│ │ └── package.json
│ ├── config-typescript/ # 共享 TS 配置
│ │ └── package.json
│ └── utils/ # 通用工具函数
│ ├── src/
│ └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── .gitignore
2.3 根 package.json
{
"name": "my-saas",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"type-check": "turbo type-check",
"db:push": "pnpm --filter @my-saas/db db:push",
"db:studio": "pnpm --filter @my-saas/db studio",
"format": "prettier --write \"**/*.{ts,tsx,md,json}\""
},
"devDependencies": {
"prettier": "^3.2.0",
"turbo": "^2.3.0"
},
"packageManager": "[email protected]"
}2.4 包之间的依赖
// apps/web/package.json
{
"name": "@my-saas/web",
"dependencies": {
"@my-saas/ui": "workspace:*",
"@my-saas/db": "workspace:*",
"@my-saas/auth": "workspace:*",
"@my-saas/utils": "workspace:*",
"next": "^15.1.0",
"react": "^19.0.0"
}
}workspace:* 表示从本地 workspace 解析,pnpm 自动建立符号链接。
3. Turborepo 配置
3.1 turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"type-check": {
"dependsOn": ["^build"]
}
}
}关键概念:
dependsOn: ["^build"]:先构建依赖的包,再构建自己。^表示上游依赖。outputs:声明构建产物,用于缓存。inputs:声明影响构建的文件,变化时才重新构建。cache: false:dev 命令不缓存。persistent: true:dev 是长运行命令。
3.2 增量构建原理
第一次 build:
packages/ui → build → ✅ 缓存
packages/db → build → ✅ 缓存
apps/web → build → ✅ 缓存(依赖 ui + db)
修改了 packages/ui/src/button.tsx:
packages/ui → build → 🔄 重新构建(文件变了)
packages/db → build → ⚡ 命中缓存(没变)
apps/web → build → 🔄 重新构建(依赖的 ui 变了)
apps/admin → build → 🔄 重新构建(也依赖 ui)
apps/docs → build → ⚡ 命中缓存(不依赖 ui)
3.3 远程缓存
# 登录 Vercel(Turborepo 远程缓存托管在 Vercel)
npx turbo login
# 链接项目
npx turbo link
# 现在 CI 和团队成员可以共享构建缓存
# 团队成员 A 构建过的结果,成员 B 直接复用效果:CI 构建从 10 分钟降到 2 分钟(大部分包命中缓存)。
3.4 过滤执行
# 只构建 web 应用(及其依赖)
turbo build --filter=@my-saas/web
# 只构建变化的包(相对于 main 分支)
turbo build --filter=...[main]
# 只运行 web 的 dev
turbo dev --filter=@my-saas/web
# 运行 web 和它依赖的所有包的 dev
turbo dev --filter=@my-saas/web...4. 共享包实践
4.1 UI 组件库
// packages/ui/package.json
{
"name": "@my-saas/ui",
"version": "0.0.0",
"private": true,
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx",
"./dialog": "./src/dialog.tsx",
"./form": "./src/form.tsx",
"./globals.css": "./src/globals.css"
},
"dependencies": {
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0"
},
"peerDependencies": {
"react": "^19.0.0"
},
"devDependencies": {
"@my-saas/config-typescript": "workspace:*",
"typescript": "^5.5.0"
}
}// packages/ui/src/button.tsx
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from './utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button className={cn(buttonVariants({ variant, size, className }))} {...props} />
)
}在应用中使用:
// apps/web/app/page.tsx
import { Button } from '@my-saas/ui/button'
import { Card } from '@my-saas/ui/card'
export default function Page() {
return (
<Card>
<Button variant="default">Click me</Button>
</Card>
)
}4.2 数据库包
// packages/db/package.json
{
"name": "@my-saas/db",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema.ts"
},
"scripts": {
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"studio": "drizzle-kit studio"
},
"dependencies": {
"drizzle-orm": "^0.38.0",
"@neondatabase/serverless": "^0.10.0"
},
"devDependencies": {
"drizzle-kit": "^0.30.0"
}
}// packages/db/src/index.ts
import { neon } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-http'
import * as schema from './schema'
const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql, { schema })
export type Database = typeof db// packages/db/src/schema.ts
import { pgTable, uuid, text, timestamp, jsonb } from 'drizzle-orm/pg-core'
export const tenants = pgTable('tenants', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').unique().notNull(),
plan: text('plan').default('free'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
email: text('email').unique().notNull(),
name: text('name'),
role: text('role').default('member'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
name: text('name').notNull(),
description: text('description'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})多个应用共享同一份 Schema:
// apps/web/lib/actions/project.ts
import { db } from '@my-saas/db'
import { projects } from '@my-saas/db/schema'
export async function getProjects(tenantId: string) {
return db.select().from(projects).where(eq(projects.tenantId, tenantId))
}4.3 共享 TypeScript 配置
// packages/config-typescript/base.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force"
}
}
// packages/config-typescript/nextjs.json
{
"extends": "./base.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
}
}应用中引用:
// apps/web/tsconfig.json
{
"extends": "@my-saas/config-typescript/nextjs.json",
"compilerOptions": {
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}4.4 共享 ESLint 配置
// packages/config-eslint/next.js
module.exports = {
extends: [
'next/core-web-vitals',
'next/typescript',
'prettier',
],
rules: {
'no-console': 'warn',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'import/no-duplicates': 'error',
},
}// apps/web/.eslintrc.json
{ "extends": ["@my-saas/config-eslint/next"] }5. Next.js Monorepo 专项
5.1 Tailwind CSS 跨包配置
// apps/web/tailwind.config.ts
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./app/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
// 关键:扫描 UI 包中的类名
'../../packages/ui/src/**/*.{ts,tsx}',
],
theme: { extend: {} },
plugins: [],
}
export default config5.2 next.config.ts 的 transpilePackages
// apps/web/next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
// 告诉 Next.js 编译这些 workspace 包
transpilePackages: ['@my-saas/ui', '@my-saas/utils'],
}
export default config5.3 环境变量管理
# 根目录 .env(共享变量)
DATABASE_URL=postgresql://...
# apps/web/.env.local(应用特定变量)
NEXT_PUBLIC_APP_URL=http://localhost:3000
CLERK_SECRET_KEY=sk_test_xxx
# apps/admin/.env.local(另一个应用)
NEXT_PUBLIC_APP_URL=http://localhost:3001
ADMIN_SECRET=xxx
turbo.json 中声明环境变量依赖:
{
"globalEnv": ["DATABASE_URL"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_*", "CLERK_*", "STRIPE_*"]
}
}
}6. CI/CD
6.1 GitHub Actions
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm type-check
- run: pnpm build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}6.2 选择性部署
# 只有 apps/web 变化时才部署 web
- name: Check changes
id: changes
run: |
CHANGED=$(npx turbo build --filter=@my-saas/web --dry-run=json | jq '.packages | length')
echo "web_changed=$([[ $CHANGED -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
- name: Deploy Web
if: steps.changes.outputs.web_changed == 'true'
run: vercel deploy --prod7. 常见问题
7.1 版本管理
# 使用 changesets 管理版本(如果需要发布包到 npm)
pnpm add -D @changesets/cli -w
npx changeset init
# 日常:记录变更
npx changeset
# → 选择变更的包
# → 选择版本类型(patch/minor/major)
# → 写变更说明
# 发布时
npx changeset version # 更新版本号
npx changeset publish # 发布到 npm对于纯内部包(private: true),不需要 changesets——直接用 workspace:* 引用即可。
7.2 开发体验
# 同时启动所有应用的 dev
pnpm dev
# 只启动 web(推荐,节省资源)
pnpm dev --filter=@my-saas/web
# 在指定包中安装依赖
pnpm add zod --filter=@my-saas/web
# 在根目录安装开发依赖
pnpm add -D prettier -w # -w = workspace root
# 在所有包中运行命令
pnpm -r exec -- rm -rf node_modules7.3 从单仓库迁移
步骤:
1. 创建 Monorepo 骨架(pnpm-workspace.yaml + turbo.json)
2. 把现有 Next.js 应用移到 apps/web/
3. 识别可复用的代码,提取到 packages/
4. 更新 import 路径
5. 配置 transpilePackages 和 Tailwind content
6. 测试构建
本章小结
- Monorepo 优势:代码共享零成本、依赖统一、原子提交、统一 CI/CD
- pnpm Workspace:
workspace:*本地链接、高效磁盘管理、原生 workspace 支持 - Turborepo:增量构建(只构建变化的包)、远程缓存(团队共享)、依赖图自动推导
- 共享包模式:UI 组件库、数据库 Schema、TS/ESLint 配置——Internal Packages 不发 npm
- Next.js 专项:
transpilePackages编译 workspace 包、Tailwind content 跨包扫描 - CI/CD:远程缓存加速 + 选择性部署 = 高效流水线