Use Cases (Atualizado: 02/06/2026)

Criar um boilerplate SaaS com Claude Code: Next.js, auth, billing, tenants e testes

Use Claude Code para criar um starter SaaS pago com Next.js, auth, billing, tenants, auditoria, testes e checklist.

Criar um boilerplate SaaS com Claude Code: Next.js, auth, billing, tenants e testes

Um boilerplate SaaS é a base reutilizável de um produto web pago: autenticação, cobrança, tenants, papéis, e-mail, dashboard, área admin, logs de auditoria, variáveis de ambiente, testes, documentação e checklist de deploy. Claude Code consegue gerar essa base rapidamente, mas um starter pronto para vender precisa de limites mais fortes do que uma demo.

O atalho perigoso é tratar “a aplicação roda” como conclusão. Um tenant é uma empresa, workspace ou conta de cliente dentro da mesma aplicação. Se a verificação de tenant for fraca, um cliente pode ver dados de outro. RBAC significa controle de acesso baseado em papéis: owner, admin, billing, member e viewer não devem ter os mesmos poderes. Um audit log registra quem fez o quê e quando. Sem isso, suporte, incidentes e vendas enterprise ficam mais difíceis.

Este guia mostra como orientar Claude Code e estruturar um starter com Next.js App Router, TypeScript, Prisma, Stripe e Resend que pode virar produto pago ou template comercial. Use sempre fontes oficiais: Claude Code docs, Next.js docs, Auth.js, Prisma schema docs, Stripe webhooks, Resend docs e OWASP Authentication Cheat Sheet.

Para aprofundar no ClaudeCodeLab, veja também autenticação segura, RBAC, validação com Zod e desenvolvimento de APIs com Claude Code.

Projetar a partir do produto pago

A primeira pergunta não é “quais componentes Claude Code deve gerar”. A primeira pergunta é “o que a pessoa que comprar este starter poderá lançar”. Um template com muitas telas, mas limites fracos entre tenants, vale menos do que um template menor com testes, documentação e critérios de revisão.

Caso de usoBase necessáriaCaminho de monetização
Micro-SaaS soloLogin OAuth, plano individual, Stripe Checkout, dashboard de usoPlano mensal simples
Ferramenta B2BTenants, convites, papéis, responsável de billing, auditoriaPreço por usuário ou workspace
Portal de conteúdo ou templatesPapel de comprador, histórico de downloads, e-mails, adminPacks pagos e cursos
Ferramenta interna de IASSO, aprovações, política por IP ou domínio, logsConsultoria de implementação

Não diga que um boilerplate elimina revisão jurídica, fiscal, de privacidade ou segurança. Impostos no Stripe, reembolsos, termos de uso, política de privacidade, retenção de dados, suporte e revisão de permissões continuam exigindo avaliação humana. Claude Code acelera a implementação, mas não transfere a responsabilidade do produto.

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"]

Dar a Claude Code um contrato CLAUDE.md

Se você pedir apenas “crie uma aplicação SaaS”, Claude Code pode misturar UI, rotas API, modelos e regras de negócio em um diff grande. CLAUDE.md transforma a tarefa em contrato. Em linguagem simples, é o harness: a estrutura de trabalho que mantém as mudanças do agente revisáveis.

# 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.

Essas regras bloqueiam atalhos comuns: confiar em tenantId vindo do navegador, deixar segredos no código, permitir billing para um member ou mudar estado sem auditoria.

Organizar por responsabilidade

Uma demo pequena pode manter tudo em uma pasta de rota. Um SaaS não. Auth, billing, equipe, e-mail e auditoria se cruzam rápido, então a estrutura precisa mostrar os limites.

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

Isso também ajuda nos prompts. Você pode pedir “altere apenas o webhook de billing e os testes relacionados” sem deixar o agente tocar a aplicação inteira.

Usar um schema Prisma com tenant no centro

O schema abaixo é um mínimo prático. Tenant representa empresa, workspace ou conta de cliente. Membership conecta usuário, tenant e papel. Subscription guarda o estado do Stripe. AuditLog registra mudanças importantes.

// 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])
}

Produtos reais normalmente adicionam faturas, endereços, contadores de uso, API keys, retenção de dados e notas de suporte. Não é preciso modelar tudo no primeiro dia. O essencial é tornar a fronteira do tenant impossível de ignorar.

Validar variáveis de ambiente

Segredos não devem ficar em código, prints ou commits. .env.example deve listar apenas nomes; os valores reais ficam em Vercel, Cloudflare, AWS, GitHub Actions ou outra plataforma. env.ts falha cedo quando uma chave importante está faltando.

// 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);

Verificar tenant e papel no servidor

O navegador pode enviar tenantId, mas o servidor não deve confiar nele. Antes de ler ou modificar dados de negócio, carregue a membership no servidor.

// 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,
  };
}

Um erro comum é colocar tenantId em um hidden input e usar direto no update. Hidden input não é segredo. Toda escrita deve verificar a membership no servidor.

Tratar Stripe Webhooks como eventos repetíveis

Webhook é uma notificação externa. Pode chegar mais de uma vez ou atrasado. Um starter sério verifica assinatura, exige metadata.tenantId, usa upsert e grava audit log.

// 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 });
}

Centralizar e-mail e auditoria

Convites, falhas de pagamento, reset de senha e alertas admin usam e-mail. Mudanças de billing, papéis e configurações de tenant usam audit logs. Helpers compartilhados tornam o produto mais fácil de revisar.

// 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,
    },
  });
}

Definir acceptance tests antes de polir a UI

Acceptance test verifica se a funcionalidade é aceitável do ponto de vista do usuário. Em um SaaS pago, os caminhos de recusa costumam ser tão importantes quanto o caminho feliz.

// 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();
});

O erro comum é testar apenas login, criação de workspace, pagamento e dashboard. Teste também bloqueio de outro tenant, member sem acesso a billing e webhook repetido sem corromper a assinatura.

Checklist de lançamento

Antes de publicar ou vender o starter, revise pelo menos estes pontos.

  • Metadata, hero image, links oficiais, links internos e CTA estão presentes
  • .env.example contém nomes, não segredos reais
  • Toda atualização de negócio verifica membership do tenant no servidor
  • OWNER, ADMIN, BILLING, MEMBER e VIEWER têm testes distintos
  • Stripe Webhook tem verificação de assinatura e idempotência
  • Falhas de e-mail têm retry ou reenvio manual
  • Admin e audit logs funcionam em produção
  • Termos, privacidade, reembolso, impostos e retenção foram revisados por humanos
  • README documenta setup local, seed, testes e deploy

Empacotar como produto

Para vender um SaaS boilerplate, código é só parte do valor. Compradores precisam de docs de setup, mapa de variáveis de ambiente, configuração do Stripe, seed data, acceptance tests e limites claros de suporte.

PacoteConteúdoMelhor para
Free checklistExemplo CLAUDE.md, mapa env, checklist de releaseBuilders validando a ideia
Starter templateNext.js, Prisma, Auth.js, Stripe, Resend, testesMVP em um fim de semana
Pro templateAdmin, audit logs, usage billing, convites, docsLançar SaaS pago com seriedade
Team rolloutRevisão de repo, treinamento Claude Code, regras de reviewEmpresas adotando Claude Code

ClaudeCodeLab oferece uma cola gratuita, produtos e templates Claude Code e treinamento ou consultoria de implementação. Use o material gratuito para validar o fluxo e passe para templates ou consultoria quando o starter precisar encaixar em um repositório real.

Resultado na prática

Em um repositório pequeno de validação, Masa começou com um prompt amplo demais: “crie o dashboard”. A tela funcionou, mas a navegação direta para outro tenant não foi bloqueada e o endpoint Stripe Webhook não verificava assinatura. Depois de colocar limites de tenant, papéis, audit logs e acceptance tests em CLAUDE.md, Claude Code gerou diffs menores e a revisão ficou objetiva. A lição é prática: o valor de um SaaS boilerplate está nos limites, testes e documentação operacional, não na lista mais longa de funcionalidades.

#Claude Code #SaaS #boilerplate #Next.js #Prisma #Stripe
Grátis

PDF grátis: cheatsheet do Claude Code

Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.

Cuidamos dos seus dados e não enviamos spam.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.