SaaS 架构设计与技术选型

SaaS(Software as a Service)不只是"把软件放到云上"——它是一种商业模式,要求技术架构支撑多租户隔离、订阅计费、弹性扩展、自助服务。用 Next.js 构建 SaaS 有天然优势:SSR 保证 SEO(营销页)、App Router 组织复杂路由(Dashboard)、Server Actions 简化表单处理、Edge 支持全球分发。本章系统讲解从零设计一个 SaaS 产品的架构决策。

1. SaaS 产品的技术需求

1.1 SaaS 与传统软件的区别

维度传统软件SaaS
部署客户本地安装供应商托管
更新客户手动升级供应商统一更新
计费一次性购买订阅制(月/年)
用户单租户多租户共享基础设施
定制按需定制配置化定制
数据客户自己管理供应商托管 + 备份

1.2 SaaS 产品的核心功能模块

模块说明技术关注点
营销站首页、定价页、博客SEO、性能、转化率
认证系统注册、登录、SSO安全、第三方登录
租户管理组织/团队/成员多租户隔离
计费系统订阅、付费、发票Stripe 集成、Webhook
核心功能产品的核心价值业务逻辑
管理后台用户管理、数据分析权限控制
API开放 API限流、文档、版本化
通知系统邮件、应用内通知异步处理

1.3 SaaS 的非功能需求

需求指标说明
可用性99.9%+每月停机 < 43 分钟
延迟P99 < 1s全球用户
安全SOC 2 / GDPR企业客户要求
扩展性10x 增长无需重构支撑业务增长
隔离租户数据不互通安全 + 合规
审计操作日志企业客户要求

2. 技术栈选型

2.1 Next.js SaaS 技术栈(2025)

层级推荐方案备选
框架Next.js App Router-
语言TypeScript-
样式Tailwind v4 + shadcn/ui-
认证Clerk / Better Auth / NextAuthSupabase Auth
数据库PostgreSQL(Neon / Supabase)PlanetScale
ORMDrizzle ORMPrisma
计费StripeLemon Squeezy
邮件ResendSendGrid
文件存储Uploadthing / S3Cloudflare R2
队列Inngest / Trigger.devBullMQ
监控Sentry + Vercel AnalyticsDatadog
部署VercelDocker + AWS

2.2 选型决策因素

因素权重说明
开发速度🔴 高SaaS 需要快速迭代
成本🔴 高早期收入有限
可扩展性🟡 中先做出产品再优化
团队熟悉度🔴 高不要为了新技术拖慢进度
社区支持🟡 中遇到问题能找到答案
锁定风险🟡 中避免过度依赖单一供应商

2.3 认证方案对比

方案类型多租户价格推荐场景
Clerk托管✅ 内置组织免费层 + 按用户快速开发、需要组织功能
Better Auth自托管✅ 插件免费完全控制、自定义需求
NextAuth (Auth.js)自托管⚠️ 需自建免费简单认证场景
Supabase Auth托管⚠️ 需自建随 Supabase使用 Supabase 全家桶

2.4 ORM 选型

ORM类型Edge 支持迁移推荐场景
DrizzleSQL-firstSQL 迁移文件性能敏感、喜欢 SQL
PrismaSchema-first⚠️ 需要 AcceleratePrisma Migrate快速原型、类型安全
KyselyQuery Builder手动极致性能控制

3. 多租户架构

3.1 多租户的三种模型

模型数据隔离成本复杂度适用场景
共享数据库 + 租户列行级隔离最低大部分 SaaS
Schema 隔离Schema 级隔离中型 SaaS
独立数据库完全隔离最高企业级 / 合规要求

3.2 共享数据库 + 租户列(推荐起步方案)

users 表:
  id | email | tenant_id | role
  1  | a@t1  | tenant_1  | admin
  2  | b@t1  | tenant_1  | member
  3  | c@t2  | tenant_2  | admin

projects 表:
  id | name    | tenant_id
  1  | Proj A  | tenant_1
  2  | Proj B  | tenant_2

每个查询都必须带 tenant_id 过滤——这是安全的基础。

风险:如果某个查询忘了加 WHERE tenant_id = ?,会导致数据泄漏。

解决方案

方案说明
ORM 中间件Drizzle/Prisma 的全局过滤器自动注入 tenant_id
数据库 RLSPostgreSQL 行级安全策略(Row Level Security)
代码审查PR Review 检查所有查询是否带 tenant_id
集成测试测试跨租户数据是否隔离

实践:封装租户安全查询——所有数据访问都经过 withTenant() 函数,从根源杜绝遗漏:

// lib/db/tenant.ts
import { eq } from 'drizzle-orm'
import { db } from './index'
import { projects } from './schema'
import { getCurrentTenantId } from '@/lib/auth'
 
// 核心原则:绝不暴露不带 tenant_id 的查询
export async function withTenant<T>(
  queryFn: (tenantId: string) => Promise<T>
): Promise<T> {
  const tenantId = await getCurrentTenantId()
  if (!tenantId) throw new Error('Tenant context required')
  return queryFn(tenantId)
}
 
// 使用示例:所有业务查询都必须走 withTenant
export async function getProjects() {
  return withTenant((tenantId) =>
    db.select().from(projects).where(eq(projects.tenantId, tenantId))
  )
}
 
export async function createProject(name: string) {
  return withTenant((tenantId) =>
    db.insert(projects).values({ name, tenantId }).returning()
  )
}

3.3 PostgreSQL RLS

PostgreSQL 的 RLS(Row Level Security)在数据库层面强制租户隔离——即使应用代码忘了加过滤条件,数据库也会自动过滤。

实践:配置 RLS 策略

-- 1. 启用 RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
 
-- 2. 创建策略:只能访问当前租户的数据
-- current_setting('app.tenant_id') 由应用层在每个请求开始时设置
CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.tenant_id')::uuid);
 
-- 3. 对 INSERT 同样限制,防止插入到其他租户
CREATE POLICY tenant_insert ON projects
  FOR INSERT WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

应用层配合——每个请求开始时设置租户上下文:

// lib/db/rls.ts
import { sql } from 'drizzle-orm'
import { db } from './index'
 
export async function withRLS<T>(tenantId: string, fn: () => Promise<T>) {
  // set_config 第三个参数 true = 仅当前事务有效,请求结束自动清除
  await db.execute(sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`)
  return fn()
}
 
// 在 Server Action 中使用
export async function getProjects() {
  const tenantId = await getCurrentTenantId()
  return withRLS(tenantId, () =>
    db.select().from(projects)  // 不需要 WHERE——RLS 自动过滤!
  )
}

优势:安全性最高——隔离逻辑在数据库层面,应用层无法绕过。 劣势:调试复杂——查询结果可能因 RLS 过滤而"丢失"数据。

3.4 租户标识策略

方式示例优势劣势
子域名tenant.app.com完全隔离感SSL、DNS 配置复杂
路径app.com/tenant/...简单不够专业
自定义域名custom.com白标完美最复杂(SSL、DNS)
请求头 / Cookie隐式简单用户无感知

推荐:子域名(workspace.app.com)——Notion、Slack、Linear 都用这种模式。

实践:用 Middleware 解析子域名租户

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
 
export function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') || ''
  const baseDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN! // 'example.com'
 
  // 提取子域名:workspace.example.com → 'workspace'
  const subdomain = hostname.replace(`.${baseDomain}`, '').split('.')[0]
 
  // 排除主域名和特殊子域名
  const isSubdomain =
    subdomain && subdomain !== 'www' && hostname !== baseDomain
 
  if (isSubdomain) {
    // 将 tenant slug 写入请求头,后续 Server Component 可通过 headers() 读取
    const headers = new Headers(request.headers)
    headers.set('x-tenant-slug', subdomain)
    return NextResponse.next({ request: { headers } })
  }
 
  return NextResponse.next()
}
 
export const config = { matcher: ['/((?!api|_next/static|favicon.ico).*)'] }

Server Component 中读取租户

// lib/auth/tenant.ts
import { headers } from 'next/headers'
import { db } from '@/lib/db'
import { tenants } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { cache } from 'react'
 
// cache() 保证同一个请求内只查一次数据库
export const getCurrentTenant = cache(async () => {
  const headerList = await headers()
  const slug = headerList.get('x-tenant-slug')
  if (!slug) return null
  const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, slug))
  return tenant ?? null
})

4. 目录结构设计

4.1 推荐的 Next.js SaaS 目录结构

app/
├── (marketing)/           ← 营销站(公开页面)
│   ├── page.tsx           ← 首页
│   ├── pricing/page.tsx   ← 定价页
│   ├── blog/              ← 博客
│   └── layout.tsx         ← 营销站 Layout
├── (auth)/                ← 认证相关
│   ├── login/page.tsx
│   ├── signup/page.tsx
│   └── layout.tsx
├── (dashboard)/           ← 已登录用户的主界面
│   ├── layout.tsx         ← Dashboard Layout(侧边栏)
│   ├── page.tsx           ← Dashboard 首页
│   ├── projects/          ← 项目管理
│   ├── settings/          ← 设置
│   └── billing/           ← 计费
├── api/                   ← API Routes
│   ├── webhooks/stripe/   ← Stripe Webhook
│   └── v1/               ← 公开 API
└── layout.tsx             ← 根 Layout

4.2 Route Groups 的作用

Route Group用途Layout
(marketing)公开营销页面导航栏 + Footer
(auth)认证页面居中卡片
(dashboard)已登录用户侧边栏 + 顶栏

Route Groups 不影响 URL——(marketing)/pricing/page.tsx 的 URL 仍然是 /pricing

4.3 业务代码组织

lib/
├── db/
│   ├── schema.ts          ← Drizzle Schema 定义
│   ├── index.ts           ← 数据库连接
│   └── migrations/        ← 迁移文件
├── auth/
│   └── index.ts           ← 认证配置
├── stripe/
│   └── index.ts           ← Stripe 配置
├── email/
│   └── index.ts           ← 邮件发送
└── utils/
    └── index.ts           ← 通用工具函数

components/
├── ui/                    ← shadcn/ui 组件
├── marketing/             ← 营销站组件
├── dashboard/             ← Dashboard 组件
└── shared/                ← 共享组件

5. 数据模型设计

5.1 核心表

说明关键字段
users用户id, email, name
tenants租户/组织id, name, slug, plan
memberships用户-租户关系user_id, tenant_id, role
subscriptions订阅tenant_id, stripe_subscription_id, status
invoices发票tenant_id, amount, status

实践:Drizzle Schema 定义

// lib/db/schema.ts
import { pgTable, uuid, text, timestamp, pgEnum } from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'
 
export const roleEnum = pgEnum('role', ['owner', 'admin', 'member', 'viewer'])
export const planEnum = pgEnum('plan', ['free', 'pro', 'enterprise'])
 
export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  name: text('name'),
  avatarUrl: text('avatar_url'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
 
export const tenants = pgTable('tenants', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  slug: text('slug').notNull().unique(),  // 子域名标识
  plan: planEnum('plan').default('free').notNull(),
  stripeCustomerId: text('stripe_customer_id'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
 
export const memberships = pgTable('memberships', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
  tenantId: uuid('tenant_id').references(() => tenants.id, { onDelete: 'cascade' }).notNull(),
  role: roleEnum('role').default('member').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
 
// 关系定义——让 Drizzle 的 query API 支持 JOIN
export const usersRelations = relations(users, ({ many }) => ({
  memberships: many(memberships),
}))
 
export const tenantsRelations = relations(tenants, ({ many }) => ({
  memberships: many(memberships),
}))
 
export const membershipsRelations = relations(memberships, ({ one }) => ({
  user: one(users, { fields: [memberships.userId], references: [users.id] }),
  tenant: one(tenants, { fields: [memberships.tenantId], references: [tenants.id] }),
}))

5.2 关系模型

User ←──┐
        │ Membership(role: admin/member/viewer)
Tenant ←┘
  │
  ├── Subscription(Stripe 订阅)
  ├── Projects(业务数据)
  └── Settings(租户配置)

关键设计决策:用户和租户是多对多关系——一个用户可以属于多个组织(Slack 模式),通过 memberships 表关联。

6. 部署与基础设施

6.1 早期架构(MVP)

Vercel(Next.js 应用)
    ↓
Neon(PostgreSQL,Serverless)
    ↓
Stripe(计费)+ Resend(邮件)+ Clerk(认证)

成本:0-50 美元/月(足够支撑前 1000 个用户)

6.2 增长期架构

Vercel Pro
├── Next.js 应用
├── Edge Middleware(认证 + 路由)
└── Cron Functions(定时任务)
    ↓
Neon Pro / Supabase
├── PostgreSQL + 连接池
├── Row Level Security
└── 自动备份
    ↓
Inngest / Trigger.dev
├── 后台任务
├── Webhook 处理
└── 定时任务

6.3 成本估算

阶段用户数月成本主要支出
MVP< 100$0-20免费层覆盖
早期100-1K$50-200Vercel Pro + 数据库
增长1K-10K$200-1000数据库 + 认证 + 基础设施
规模化10K+$1000+需要评估自建 vs 托管

7. 快速启动方案

7.1 SaaS Starter Kit 对比

Starter技术栈价格特点
next-saas-starterNext.js + Drizzle + Stripe免费Vercel 官方,轻量
TaxonomyNext.js + Prisma + Stripe免费功能完整
ShipfastNext.js + Supabase + Stripe$199文档好,模板多
MakerkitNext.js + Supabase/Firebase$299多租户内置
SupastarterNext.js/Nuxt + Supabase$299支持多框架

7.2 Build vs Buy 决策

组件自建购买/使用服务建议
认证复杂、安全风险Clerk($0-25/月)
计费Stripe 已经做好了Stripe
邮件送达率难保证Resend($0-20/月)
核心功能你的竞争优势-自建
UI 组件耗时shadcn/ui(免费)
部署运维负担Vercel买(早期)

本章小结

  • SaaS 本质:多租户 + 订阅计费 + 自助服务,技术架构要支撑这三个核心
  • 技术栈:Next.js + TypeScript + Tailwind + shadcn/ui + Drizzle + Neon + Stripe + Clerk
  • 多租户:共享数据库 + 租户列是最佳起步方案,PostgreSQL RLS 保障隔离安全
  • 目录结构:Route Groups 分离营销站 / 认证 / Dashboard 三套 Layout
  • 数据模型:User-Tenant 多对多关系,Membership 表管理角色
  • 部署成本:MVP 阶段 $0-50/月,利用免费层快速验证
  • Build vs Buy:核心功能自建,认证/计费/邮件用服务,不要重复造轮子