Crear un SaaS boilerplate con Claude Code: Next.js, auth, billing, tenants y tests
Construye un starter SaaS de pago con Claude Code: Next.js, auth, billing, tenants, auditoría, tests y checklist.
Un SaaS boilerplate es la base reutilizable de un producto web de pago: autenticación, facturación, tenants, roles, correo, dashboards, admin, logs de auditoría, variables de entorno, tests, documentación y checklist de despliegue. Claude Code puede generar esa base rápido, pero un starter listo para vender necesita límites más fuertes que una demo.
El atajo peligroso es tratar “la app funciona” como si fuera el final. Un tenant es una empresa, workspace o cuenta de cliente dentro de la misma aplicación. Si la comprobación de tenant es débil, un cliente puede ver datos de otro. RBAC significa control de acceso basado en roles: owner, admin, billing, member y viewer no deberían tener los mismos permisos. Un audit log registra quién hizo qué y cuándo. Sin eso, soporte, incidentes y ventas enterprise se complican.
Esta guía muestra cómo pedirle a Claude Code y cómo estructurar un starter con Next.js App Router, TypeScript, Prisma, Stripe y Resend que pueda convertirse en producto pagado o plantilla comercial. Usa siempre fuentes oficiales: Claude Code docs, Next.js docs, Auth.js, Prisma schema docs, Stripe webhooks, Resend docs y OWASP Authentication Cheat Sheet.
Para profundizar dentro de ClaudeCodeLab, revisa autenticación segura, RBAC, validación con Zod y desarrollo de APIs con Claude Code.
Diseñar desde el producto pagado
La primera pregunta no es “qué componentes generará Claude Code”. La primera pregunta es “qué podrá lanzar una persona que compre este starter”. Una plantilla con muchas pantallas pero límites débiles entre tenants vale menos que una plantilla más pequeña con tests, documentación y reglas de revisión claras.
| Caso de uso | Base necesaria | Ruta de monetización |
|---|---|---|
| Micro-SaaS individual | Login OAuth, plan personal, Stripe Checkout, dashboard de uso | Plan mensual de baja fricción |
| Herramienta B2B | Tenants, invitaciones, roles, responsable de billing, auditoría | Precio por seat o workspace |
| Portal de contenido o templates | Rol de comprador, historial de descargas, emails, admin | Packs de templates y cursos |
| Herramienta interna de IA | SSO, aprobaciones, política por IP o dominio, logs | Consultoría de implementación |
No prometas que un boilerplate elimina la revisión legal, fiscal, de privacidad o de seguridad. Impuestos de Stripe, reembolsos, términos, política de privacidad, retención de datos, soporte y revisión de permisos siguen necesitando criterio humano. Claude Code acelera la implementación, no cambia la responsabilidad del producto.
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 un contrato CLAUDE.md
Si pides “crea una app SaaS”, Claude Code puede mezclar UI, API, modelos y reglas de negocio en un diff grande. CLAUDE.md convierte el trabajo en un contrato. En lenguaje simple, es el harness: el andamiaje para que el agente trabaje de forma revisable.
# 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.
Estas reglas evitan atajos comunes: confiar en tenantId enviado por el navegador, dejar secretos en el código, permitir que un member cambie billing o modificar estado sin auditoría.
Organizar el starter por responsabilidad
Una demo pequeña puede poner todo en una carpeta de rutas. Un SaaS no. Auth, billing, equipo, email y auditoría se cruzan rápido, así que la estructura debe mostrar los límites.
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
Esto también mejora los prompts. Puedes pedir “actualiza solo el webhook de billing y sus tests” sin abrir toda la aplicación.
Un schema Prisma con tenants en el centro
El siguiente schema es un mínimo práctico. Tenant representa una empresa, workspace o cuenta de cliente. Membership conecta usuario, tenant y rol. Subscription guarda el estado de Stripe. AuditLog registra cambios 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])
}
Un producto real suele añadir facturas, direcciones, contadores de uso, API keys, retención de datos y notas de soporte. No hace falta modelarlo todo al inicio. Sí hace falta que la frontera de tenant no pueda ignorarse.
Validar variables de entorno una vez
Los secretos no deben vivir en código, capturas ni commits. .env.example solo debe listar nombres; los valores reales van en Vercel, Cloudflare, AWS, GitHub Actions u otra plataforma. env.ts falla pronto si falta una clave importante.
// 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);
Comprobar tenant y rol en el servidor
El navegador puede enviar tenantId, pero no puedes confiar en él. Antes de leer o modificar datos de negocio, carga la membership en el 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,
};
}
Un fallo común es meter tenantId en un hidden input y usarlo directamente al actualizar. Un hidden input no es secreto. Cada escritura debe verificar membership del lado servidor.
Tratar Stripe Webhooks como eventos repetibles
Un webhook es una notificación externa. Puede llegar más de una vez o retrasarse. El starter debe verificar firma, exigir metadata.tenantId, usar upsert y escribir auditoría.
// 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 email y auditoría
Invitaciones, pagos fallidos, reset de contraseña y alertas admin usan email. Cambios de billing, roles y configuración de tenant usan audit logs. Los helpers compartidos hacen que el producto sea revisable.
// 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 pulir la UI
Un acceptance test verifica si la funcionalidad es aceptable desde el punto de vista del usuario. En un SaaS pagado, las rutas de rechazo suelen ser tan importantes como el happy path.
// 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();
});
El error típico es probar solo que el usuario entra, crea workspace, paga y ve el dashboard. También hay que probar que otro tenant queda bloqueado, que un member no cambia billing y que un webhook repetido no corrompe la suscripción.
Checklist de lanzamiento
Antes de publicar o vender el starter, revisa al menos esto.
- Metadata, hero image, enlaces oficiales, enlaces internos y CTA están presentes
.env.examplecontiene nombres, no secretos reales- Toda actualización de negocio comprueba membership del tenant en servidor
- OWNER, ADMIN, BILLING, MEMBER y VIEWER tienen tests diferenciados
- Stripe Webhook tiene verificación de firma e idempotencia
- Los fallos de email tienen reintento o reenvío manual
- Admin y audit logs funcionan en producción
- Términos, privacidad, refund policy, impuestos y retención fueron revisados por humanos
- README documenta setup local, seed, tests y despliegue
Empaquetarlo como producto
Si quieres vender un SaaS boilerplate, el código es solo una parte. El comprador necesita documentación de setup, mapa de variables de entorno, configuración de Stripe, seed data, acceptance tests y límites de soporte.
| Paquete | Contenido | Mejor para |
|---|---|---|
| Free checklist | Ejemplo CLAUDE.md, mapa env, checklist de release | Builders que validan la idea |
| Starter template | Next.js, Prisma, Auth.js, Stripe, Resend, tests | Crear un MVP en un fin de semana |
| Pro template | Admin, auditoría, usage billing, invitaciones, docs | Lanzar un SaaS pagado en serio |
| Team rollout | Revisión de repo, training Claude Code, reglas de review | Empresas adoptando Claude Code |
ClaudeCodeLab ofrece una chuleta gratuita, productos y templates de Claude Code y formación o consultoría de implementación. Usa el material gratuito para validar el flujo y pasa a templates o consultoría cuando el starter deba encajar en un repositorio real.
Resultado en la práctica
En un repositorio pequeño de validación, Masa empezó con un prompt demasiado amplio: “crea el dashboard”. La pantalla funcionó, pero la navegación directa a otro tenant no quedó bloqueada y el endpoint de Stripe Webhook no verificaba la firma. Después de mover límites de tenant, roles, auditoría y acceptance tests a CLAUDE.md, Claude Code generó diffs más pequeños y la revisión tuvo una lista clara. La lección es concreta: el valor de un SaaS boilerplate está en sus límites, tests y documentación operativa, no en la lista más larga de funciones.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.