Use Cases (Actualizado: 2/6/2026)

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.

Crear un SaaS boilerplate con Claude Code: Next.js, auth, billing, tenants y tests

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 usoBase necesariaRuta de monetización
Micro-SaaS individualLogin OAuth, plan personal, Stripe Checkout, dashboard de usoPlan mensual de baja fricción
Herramienta B2BTenants, invitaciones, roles, responsable de billing, auditoríaPrecio por seat o workspace
Portal de contenido o templatesRol de comprador, historial de descargas, emails, adminPacks de templates y cursos
Herramienta interna de IASSO, aprobaciones, política por IP o dominio, logsConsultorí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.example contiene 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.

PaqueteContenidoMejor para
Free checklistEjemplo CLAUDE.md, mapa env, checklist de releaseBuilders que validan la idea
Starter templateNext.js, Prisma, Auth.js, Stripe, Resend, testsCrear un MVP en un fin de semana
Pro templateAdmin, auditoría, usage billing, invitaciones, docsLanzar un SaaS pagado en serio
Team rolloutRevisión de repo, training Claude Code, reglas de reviewEmpresas 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.

#Claude Code #SaaS #boilerplate #Next.js #Prisma #Stripe
Gratis

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.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.