Use Cases (更新: 2026/6/2)

用 Claude Code 构建 SaaS Boilerplate:Next.js 认证、计费、租户与测试

用 Claude Code 构建可收费的 SaaS 基础模板,覆盖 Next.js、认证、计费、租户、审计日志与测试。

用 Claude Code 构建 SaaS Boilerplate:Next.js 认证、计费、租户与测试

SaaS boilerplate 可以理解为可复用的产品地基:登录、计费、租户、角色、邮件、仪表盘、管理后台、审计日志、环境变量、测试、文档和上线检查都已经放在一个起点里。Claude Code 能很快生成这个起点,但如果目标是收费产品,就不能只满足于“页面能跑”。

最危险的捷径,是把演示项目当成生产项目。租户是指同一个应用里不同公司、团队或客户账号的隔离边界。如果租户校验松散,一个客户可能看到另一个客户的数据。RBAC 是基于角色的权限控制,OWNER、ADMIN、BILLING、MEMBER、VIEWER 不应该拥有同样的能力。审计日志记录“谁在什么时候做了什么”,没有它,客服、排障、企业销售都会变难。

本文会用 Next.js App Router、TypeScript、Prisma、Stripe 和 Resend 展示一个可以商品化的 SaaS 基础模板。实现时请以官方文档为准:Claude Code docsNext.js docsAuth.jsPrisma schema docsStripe webhooksResend docsOWASP Authentication Cheat Sheet

站内可以继续阅读 安全认证实现RBAC 实现Zod 校验Claude Code API 开发

先从收费产品倒推

不要先问“让 Claude Code 生成哪些组件”。先问“购买这个模板的人能发布什么产品”。功能很多但租户边界不清楚的模板,价值不如一个范围较小、测试明确、上线文档完整的模板。

用例必备基础收入路径
个人 micro-SaaSOAuth 登录、个人套餐、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 checklistCLAUDE.md 示例、env 清单、上线检查想先评估的个人开发者
Starter templateNext.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 的价值不在功能列表最长,而在边界、测试和运营文档是否可靠。

#Claude Code #SaaS #boilerplate #Next.js #Prisma #Stripe
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。