用 Claude Code 构建 SaaS Boilerplate:Next.js 认证、计费、租户与测试
用 Claude Code 构建可收费的 SaaS 基础模板,覆盖 Next.js、认证、计费、租户、审计日志与测试。
SaaS boilerplate 可以理解为可复用的产品地基:登录、计费、租户、角色、邮件、仪表盘、管理后台、审计日志、环境变量、测试、文档和上线检查都已经放在一个起点里。Claude Code 能很快生成这个起点,但如果目标是收费产品,就不能只满足于“页面能跑”。
最危险的捷径,是把演示项目当成生产项目。租户是指同一个应用里不同公司、团队或客户账号的隔离边界。如果租户校验松散,一个客户可能看到另一个客户的数据。RBAC 是基于角色的权限控制,OWNER、ADMIN、BILLING、MEMBER、VIEWER 不应该拥有同样的能力。审计日志记录“谁在什么时候做了什么”,没有它,客服、排障、企业销售都会变难。
本文会用 Next.js App Router、TypeScript、Prisma、Stripe 和 Resend 展示一个可以商品化的 SaaS 基础模板。实现时请以官方文档为准:Claude Code docs、Next.js docs、Auth.js、Prisma schema docs、Stripe webhooks、Resend docs 和 OWASP Authentication Cheat Sheet。
站内可以继续阅读 安全认证实现、RBAC 实现、Zod 校验 和 Claude Code API 开发。
先从收费产品倒推
不要先问“让 Claude Code 生成哪些组件”。先问“购买这个模板的人能发布什么产品”。功能很多但租户边界不清楚的模板,价值不如一个范围较小、测试明确、上线文档完整的模板。
| 用例 | 必备基础 | 收入路径 |
|---|---|---|
| 个人 micro-SaaS | OAuth 登录、个人套餐、Stripe Checkout、使用量仪表盘 | 低门槛月付套餐 |
| B2B 团队工具 | 租户、邀请、角色、计费负责人、审计日志 | 按席位或工作区收费 |
| 会员内容或模板门户 | 购买者权限、下载记录、邮件通知、管理后台 | 付费模板包和课程 |
| 内部 AI 工作流工具 | SSO、审批、IP 或域名策略、操作日志 | 实施咨询和运维支持 |
请不要宣称 boilerplate 可以替代法律、税务、隐私或安全审查。Stripe 税务设置、退款、服务条款、隐私政策、数据保留、客服流程和权限盘点仍然需要人工确认。Claude Code 加快实现,不承担业务责任。
flowchart LR
A["Marketing site"] --> B["Auth"]
B --> C["Tenant and roles"]
C --> D["Dashboard"]
C --> E["Billing"]
C --> F["Admin"]
D --> G["Audit logs"]
E --> G
F --> G
G --> H["Tests and release checklist"]
给 Claude Code 一个 CLAUDE.md 合同
如果只说“做一个 SaaS 应用”,Claude Code 往往会把 UI、API、数据库模型和业务规则混在一个大差异里。CLAUDE.md 可以把任务变成可审查的合同。这里的 harness 可以理解为“让智能体安全工作的脚手架”。
# CLAUDE.md
## Product goal
Build a paid SaaS starter that can be reused for real products.
Do not claim the starter removes legal, tax, privacy, or security review.
## Stack
- Next.js App Router with TypeScript
- Prisma and PostgreSQL
- Auth.js for OAuth/session integration
- Stripe Checkout and billing webhooks
- Resend for transactional email
- Vitest and Playwright for acceptance tests
## Required boundaries
- Every business record belongs to a tenantId.
- Never trust tenantId from the browser without checking membership.
- Roles are OWNER, ADMIN, BILLING, MEMBER, VIEWER.
- Billing routes require OWNER or BILLING.
- Admin routes require OWNER or ADMIN.
- State-changing routes write an audit log.
- Secrets must be read through src/lib/env.ts and never hardcoded.
- Include tests for forbidden tenant access and webhook idempotency.
## Review output
After edits, list changed files, commands run, risks, and manual checks.
这些规则会直接影响生成结果。它告诉 Claude Code:不要信任浏览器传来的 tenantId,不要硬编码密钥,不要让普通成员访问计费,不要让状态变更绕过审计日志。
按职责组织目录
小 demo 可以把所有东西都放进一个路由目录。SaaS 不行。认证、计费、团队、邮件和审计很快会互相影响,所以目录要让边界清晰。
src/
app/
(marketing)/
(auth)/
(dashboard)/dashboard/page.tsx
(admin)/admin/page.tsx
api/
billing/checkout/route.ts
billing/webhook/route.ts
tenants/invite/route.ts
components/
dashboard/
pricing/
ui/
lib/
auth.ts
env.ts
prisma.ts
tenant.ts
audit.ts
email.ts
stripe.ts
tests/
acceptance/saas.spec.ts
prisma/
schema.prisma
这样也方便继续提示 Claude Code:“只修改 billing webhook 和相关测试”,而不是让它触碰整个项目。
用 Prisma 模型固定租户边界
下面是收费 SaaS 的实用最小模型。Tenant 表示公司、工作区或客户账号;Membership 表示用户在某个租户里的角色;Subscription 保存 Stripe 状态;AuditLog 保存关键操作。
// prisma/schema.prisma
enum Role {
OWNER
ADMIN
BILLING
MEMBER
VIEWER
}
enum Plan {
FREE
STARTER
PRO
}
enum SubscriptionStatus {
TRIALING
ACTIVE
PAST_DUE
CANCELED
}
model User {
id String @id @default(cuid())
email String @unique
name String?
memberships Membership[]
auditLogs AuditLog[]
createdAt DateTime @default(now())
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique
plan Plan @default(FREE)
memberships Membership[]
subscription Subscription?
auditLogs AuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Membership {
id String @id @default(cuid())
userId String
tenantId String
role Role @default(MEMBER)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, tenantId])
@@index([tenantId, role])
}
model Subscription {
id String @id @default(cuid())
tenantId String @unique
stripeCustomerId String @unique
stripeSubscriptionId String? @unique
status SubscriptionStatus @default(TRIALING)
priceId String?
currentPeriodEnd DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
updatedAt DateTime @updatedAt
}
model AuditLog {
id String @id @default(cuid())
tenantId String
actorId String?
action String
metadata Json?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
actor User? @relation(fields: [actorId], references: [id])
createdAt DateTime @default(now())
@@index([tenantId, createdAt])
}
真实产品通常还会加入发票、地址、用量计数、API key、数据保留策略和客服备注。第一版不需要建完所有模型,但必须让租户边界从一开始就不可忽略。
用 env.ts 校验密钥
不要把真实密钥写进代码、截图或提交记录。.env.example 只放变量名,真实值放到 Vercel、Cloudflare、AWS、GitHub Actions 或其他秘密管理系统里。下面的 env.ts 会在启动时发现缺失配置。
// src/lib/env.ts
import { z } from "zod";
export const env = z
.object({
DATABASE_URL: z.string().url(),
AUTH_SECRET: z.string().min(32),
NEXT_PUBLIC_APP_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
STRIPE_PRICE_STARTER: z.string().min(1),
RESEND_API_KEY: z.string().startsWith("re_"),
EMAIL_FROM: z.string().email(),
})
.parse(process.env);
在服务器校验租户和角色
浏览器可以传 tenantId,但服务器不能相信它。读取或修改业务数据前,必须在服务器端查询 Membership。
// src/lib/tenant.ts
import { Role } from "@prisma/client";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const roleRank: Record<Role, number> = {
VIEWER: 1,
MEMBER: 2,
BILLING: 3,
ADMIN: 4,
OWNER: 5,
};
export async function requireTenant(tenantId: string, minimumRole: Role = "MEMBER") {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const membership = await prisma.membership.findUnique({
where: {
userId_tenantId: {
userId: session.user.id,
tenantId,
},
},
include: { tenant: true },
});
if (!membership || roleRank[membership.role] < roleRank[minimumRole]) {
throw new Error("Forbidden tenant access");
}
return {
userId: session.user.id,
tenant: membership.tenant,
role: membership.role,
};
}
常见失败是把 tenantId 放进 hidden input,然后直接用它更新数据库。hidden input 不是秘密,任何人都可以改。每次写入都要重新查 Membership。
Stripe Webhook 要按重复事件处理
Webhook 是外部通知,可能重复到达,也可能延迟。产品模板至少要做签名验证、检查 metadata.tenantId、使用 upsert,并写入审计日志。
// src/app/api/billing/webhook/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { SubscriptionStatus } from "@prisma/client";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
function toStatus(status: Stripe.Subscription.Status): SubscriptionStatus {
if (status === "active") return "ACTIVE";
if (status === "past_due") return "PAST_DUE";
if (status === "canceled") return "CANCELED";
return "TRIALING";
}
export async function POST(request: Request) {
const body = await request.text();
const signature = (await headers()).get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, env.STRIPE_WEBHOOK_SECRET);
} catch {
return NextResponse.json({ error: "Invalid Stripe signature" }, { status: 400 });
}
if (
event.type === "customer.subscription.created" ||
event.type === "customer.subscription.updated" ||
event.type === "customer.subscription.deleted"
) {
const subscription = event.data.object as Stripe.Subscription;
const tenantId = subscription.metadata.tenantId;
if (!tenantId || typeof subscription.customer !== "string") {
return NextResponse.json({ error: "Missing tenant metadata" }, { status: 400 });
}
await prisma.subscription.upsert({
where: { tenantId },
create: {
tenantId,
stripeCustomerId: subscription.customer,
stripeSubscriptionId: subscription.id,
status: toStatus(subscription.status),
priceId: subscription.items.data[0]?.price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
update: {
stripeSubscriptionId: subscription.id,
status: toStatus(subscription.status),
priceId: subscription.items.data[0]?.price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
await prisma.auditLog.create({
data: {
tenantId,
action: `stripe.${event.type}`,
metadata: { eventId: event.id, subscriptionId: subscription.id },
},
});
}
return NextResponse.json({ received: true });
}
邮件和审计日志做成共用函数
邀请、支付失败、密码重置、管理员提醒都会用到邮件。计费变更、角色变更、租户设置变更都会用到审计日志。让 Claude Code 复用 helper,后续调整格式时更容易。
// src/lib/email.ts
import { Resend } from "resend";
import { env } from "@/lib/env";
const resend = new Resend(env.RESEND_API_KEY);
export async function sendTenantInviteEmail(input: {
to: string;
tenantName: string;
inviteUrl: string;
}) {
return resend.emails.send({
from: env.EMAIL_FROM,
to: input.to,
subject: `${input.tenantName} invited you`,
html: `<p>You were invited to ${input.tenantName}.</p><p><a href="${input.inviteUrl}">Accept invite</a></p>`,
});
}
// src/lib/audit.ts
import { prisma } from "@/lib/prisma";
export async function writeAuditLog(input: {
tenantId: string;
actorId?: string;
action: string;
metadata?: Record<string, unknown>;
}) {
return prisma.auditLog.create({
data: {
tenantId: input.tenantId,
actorId: input.actorId,
action: input.action,
metadata: input.metadata,
},
});
}
先写验收测试
Acceptance test 是从用户视角判断功能能不能被接受的测试。收费 SaaS 模板里,拒绝路径往往比成功路径更重要。
// tests/acceptance/saas.spec.ts
import { test, expect } from "@playwright/test";
test("member cannot open billing settings", async ({ page }) => {
await page.goto("/test-login?role=MEMBER");
await page.goto("/dashboard/acme/billing");
await expect(page.getByText("Forbidden")).toBeVisible();
});
test("billing user can open billing settings", async ({ page }) => {
await page.goto("/test-login?role=BILLING");
await page.goto("/dashboard/acme/billing");
await expect(page.getByRole("heading", { name: "Billing" })).toBeVisible();
});
test("tenant switch does not leak another tenant data", async ({ page }) => {
await page.goto("/test-login?tenant=acme");
await page.goto("/dashboard/other-team/settings");
await expect(page.getByText("Forbidden")).toBeVisible();
});
常见遗漏是只测用户能登录、能创建工作区、能付款、能看到仪表盘。还要测试另一个租户打不开,普通成员不能改账单,重复 webhook 不会破坏订阅状态。
上线前检查
发布或销售模板前,至少检查这些项目。
- metadata、hero image、官方链接、内部链接和 CTA 已设置
.env.example只有变量名,没有真实密钥- 每个业务写入都在服务器端检查租户 Membership
- OWNER、ADMIN、BILLING、MEMBER、VIEWER 的差异有测试
- Stripe Webhook 有签名验证和幂等处理
- 邮件失败有重试或手动重发路径
- 管理后台和审计日志在生产环境可用
- 服务条款、隐私政策、退款政策、税务设置和数据保留已人工审查
- README 写明本地启动、seed、测试和部署步骤
作为产品销售时要分层
如果要卖 SaaS boilerplate,代码只是价值的一部分。买家还需要设置文档、环境变量地图、Stripe 配置说明、seed 数据、验收测试和清晰的支持边界。
| 套餐 | 内容 | 适合人群 |
|---|---|---|
| Free checklist | CLAUDE.md 示例、env 清单、上线检查 | 想先评估的个人开发者 |
| Starter template | Next.js、Prisma、Auth.js、Stripe、Resend、测试 | 周末做 MVP 的开发者 |
| Pro template | 管理后台、审计日志、用量计费、邀请、文档 | 准备认真发布收费 SaaS 的人 |
| Team rollout | 仓库审查、Claude Code 培训、评审规则 | 团队导入 Claude Code 的公司 |
ClaudeCodeLab 提供免费速查表、Claude Code 产品和模板以及培训与实施咨询。先用免费材料验证流程,再在真实仓库需要落地时使用模板或咨询。
实际验证后的发现
Masa 在一个小型验证仓库里先尝试了很模糊的提示:“做一个 dashboard”。结果页面能跑,但直接访问另一个租户 URL 没有被拦住,Stripe Webhook 也漏掉了签名验证。后来把租户边界、角色、审计日志和验收测试写进 CLAUDE.md,Claude Code 生成的差异变小,审查清单也更清楚。结论很直接:SaaS boilerplate 的价值不在功能列表最长,而在边界、测试和运营文档是否可靠。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。