Iniciador SaaS de Next.js
Un stack iniciador SaaS completo — Next.js 15 + React 19 + Auth.js + Prisma + Stripe + Tailwind + shadcn/ui — la configuración completa lista para producción en la que puedes construir un producto real.
Receta
Tarjeta de receta de referencia rápida — lista para copiar y pegar.
# 1. Scaffold Next.js 15 con Tailwind y TypeScript
npx create-next-app@latest my-saas --typescript --tailwind --app --turbopack
cd my-saas
# 2. Agrega shadcn/ui
npx shadcn@latest init
npx shadcn@latest add button card input form dialog
# 3. Instala el resto del stack
npm install next-auth@beta @auth/prisma-adapter
npm install @prisma/client
npm install -D prisma
npm install stripe @stripe/stripe-js
npm install zod react-hook-form @hookform/resolvers
# 4. Inicializa Prisma (Postgres)
npx prisma init --datasource-provider postgresql
# 5. Genera el secreto de Auth.js
npx auth secret// package.json — el conjunto de dependencias listo para producción
\{
"dependencies": \{
"@auth/prisma-adapter": "^2.7.0",
"@hookform/resolvers": "^3.9.0",
"@prisma/client": "^6.0.0",
"@stripe/stripe-js": "^5.0.0",
"next": "15.1.0",
"next-auth": "5.0.0-beta.25",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "^7.54.0",
"stripe": "^17.4.0",
"zod": "^3.24.0"
\},
"devDependencies": \{
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"prisma": "^6.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.3"
\}
\}Cuándo usarlo: Cuando estés iniciando un producto SaaS real y quieras el stack canónico — base de datos, auth, pagos, kit de UI y validación — conectados correctamente desde el día uno.
Ejemplo en Funcionamiento
my-saas/
app/
(auth)/
login/page.tsx
register/page.tsx
(dashboard)/
layout.tsx
dashboard/page.tsx
billing/page.tsx
api/
auth/[...nextauth]/route.ts
webhooks/
stripe/route.ts
lib/
prisma.ts
stripe.ts
auth.ts
validations.ts
prisma/
schema.prisma
auth.ts
middleware.ts
.env.local
// prisma/schema.prisma
generator client \{
provider = "prisma-client-js"
\}
datasource db \{
provider = "postgresql"
url = env("DATABASE_URL")
\}
model User \{
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
stripeCustomerId String? @unique
accounts Account[]
sessions Session[]
subscription Subscription?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
\}
model Account \{
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
\}
model Session \{
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
\}
model Subscription \{
id String @id @default(cuid())
userId String @unique
stripeSubscriptionId String @unique
stripePriceId String
status String // active, trialing, past_due, canceled, etc.
currentPeriodEnd DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
\}// lib/prisma.ts — singleton para sobrevivir hot reload
import \{ PrismaClient \} from "@prisma/client";
const globalForPrisma = globalThis as unknown as \{
prisma: PrismaClient | undefined;
\};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient(\{
log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
\});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, \{
apiVersion: "2024-12-18.acacia",
typescript: true,
\});// auth.ts — configuración de Auth.js v5 (beta)
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import \{ PrismaAdapter \} from "@auth/prisma-adapter";
import \{ prisma \} from "@/lib/prisma";
import \{ stripe \} from "@/lib/stripe";
export const \{ handlers, auth, signIn, signOut \} = NextAuth(\{
adapter: PrismaAdapter(prisma),
session: \{ strategy: "database" \},
providers: [GitHub],
events: \{
async createUser(\{ user \}) \{
// Crea un cliente de Stripe en el momento en que el usuario se registra
const customer = await stripe.customers.create(\{
email: user.email ?? undefined,
name: user.name ?? undefined,
metadata: \{ userId: user.id! \},
\});
await prisma.user.update(\{
where: \{ id: user.id \},
data: \{ stripeCustomerId: customer.id \},
\});
\},
\},
callbacks: \{
async session(\{ session, user \}) \{
const sub = await prisma.subscription.findUnique(\{ where: \{ userId: user.id \} \});
session.user.id = user.id;
session.user.stripeCustomerId = (user as any).stripeCustomerId ?? null;
session.user.subscriptionStatus = sub?.status ?? "none";
return session;
\},
\},
\});// app/api/auth/[...nextauth]/route.ts
export \{ GET, POST \} from "@/auth";// lib/validations.ts
import \{ z \} from "zod";
export const checkoutSchema = z.object(\{
priceId: z.string().startsWith("price_"),
\});
export type CheckoutInput = z.infer<typeof checkoutSchema>;// app/(dashboard)/billing/actions.ts — Server Action: inicia una Sesión de Checkout
"use server";
import \{ redirect \} from "next/navigation";
import \{ auth \} from "@/auth";
import \{ stripe \} from "@/lib/stripe";
import \{ checkoutSchema \} from "@/lib/validations";
export async function createCheckoutSession(formData: FormData) \{
const session = await auth();
if (!session?.user?.stripeCustomerId) throw new Error("Not authenticated");
const parsed = checkoutSchema.parse(\{ priceId: formData.get("priceId") \});
const checkout = await stripe.checkout.sessions.create(\{
customer: session.user.stripeCustomerId,
mode: "subscription",
line_items: [\{ price: parsed.priceId, quantity: 1 \}],
success_url: `$\{process.env.APP_URL\}/dashboard?checkout=success`,
cancel_url: `$\{process.env.APP_URL\}/billing?checkout=cancelled`,
metadata: \{ userId: session.user.id \},
\});
if (!checkout.url) throw new Error("No checkout URL");
redirect(checkout.url);
\}// app/api/webhooks/stripe/route.ts — Runtime Node, NO edge
import \{ NextRequest, NextResponse \} from "next/server";
import Stripe from "stripe";
import \{ stripe \} from "@/lib/stripe";
import \{ prisma \} from "@/lib/prisma";
export const runtime = "nodejs"; // crítico — webhooks necesitan crypto de Node
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) \{
const body = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) return new NextResponse("No signature", \{ status: 400 \});
let event: Stripe.Event;
try \{
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
\} catch (err) \{
return new NextResponse(`Webhook error: $\{(err as Error).message\}`, \{ status: 400 \});
\}
switch (event.type) \{
case "checkout.session.completed":
case "customer.subscription.updated":
case "customer.subscription.created": \{
const sub = event.data.object as Stripe.Subscription;
const userId = (sub.metadata?.userId as string) ?? null;
if (!userId) break;
await prisma.subscription.upsert(\{
where: \{ userId \},
create: \{
userId,
stripeSubscriptionId: sub.id,
stripePriceId: sub.items.data[0]!.price.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
\},
update: \{
stripeSubscriptionId: sub.id,
stripePriceId: sub.items.data[0]!.price.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
\},
\});
break;
\}
case "customer.subscription.deleted": \{
const sub = event.data.object as Stripe.Subscription;
await prisma.subscription.updateMany(\{
where: \{ stripeSubscriptionId: sub.id \},
data: \{ status: "canceled" \},
\});
break;
\}
\}
return NextResponse.json(\{ received: true \});
\}// middleware.ts — protege el dashboard
import \{ auth \} from "@/auth";
export default auth((req) => \{
if (!req.auth && req.nextUrl.pathname.startsWith("/dashboard")) \{
const url = new URL("/login", req.url);
return Response.redirect(url);
\}
\});
export const config = \{ matcher: ["/dashboard/:path*", "/billing/:path*"] \};# .env.local — variables de entorno requeridas
DATABASE_URL="postgresql://user:pass@localhost:5432/mysaas"
AUTH_SECRET="generated-by-npx-auth-secret"
AUTH_GITHUB_ID="..."
AUTH_GITHUB_SECRET="..."
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
APP_URL="http://localhost:3000"Lo que esto demuestra:
- Server Action llama a la API de Stripe con la clave del servidor, luego
redirect()a Checkout - El endpoint del webhook se ejecuta en runtime Node, verifica la firma y escribe el estado de suscripción en Postgres
events.createUserde Auth.js crea un cliente de Stripe en el registro para questripeCustomerIdsiempre esté presente- El callback de sesión aumenta
session.usercon información de facturación para puertas de UI - El singleton de Prisma evita tormentas de conexión durante hot reload
- El esquema de Zod valida entrada de formulario antes de golpear Stripe
Análisis Profundo
Cómo Funciona
- Auth.js v5 expone
auth(),handlers,signIn,signOutdesde un archivo de configuración único.handlersse re-exporta por una ruta catch-all enapp/api/auth/[...nextauth]/route.ts. - El PrismaAdapter le dice a Auth.js que almacene usuarios, sesiones y cuentas en tu base de datos. Emparejarlo con
session: \{ strategy: "database" \}significa que las sesiones viven en Postgres, no en JWTs. - En el registro, el hook
events.createUsercrea un cliente de Stripe y escribestripeCustomerIdde vuelta aUser. Ahora cada checkout futuro usa el mismo registro de cliente, que es crítico para la gestión de suscripciones. - Una Server Action construye una Sesión de Stripe Checkout en el servidor (usando la clave secreta) y devuelve una URL.
redirect()envía el navegador a Stripe. - Después del pago, Stripe POSTs a
/api/webhooks/stripe. El manejador verifica la firma usando el body crudo, analiza el evento y hace upsert a la filaSubscription. - El callback de sesión lee la fila de suscripción en cada solicitud, por lo que
session.user.subscriptionStatusrefleja el estado de facturación más reciente sin obtenciones de cliente adicionales.
Variaciones
Usa un iniciador existente en lugar de construir desde cero:
- Iniciador SaaS de Next.js de Vercel —
pnpm create next-app --example next-saas-starter - Taxonomy por shadcn — aplicación de referencia de código abierto con Auth.js + Prisma
- Shipixen — generador de código pagado para el mismo stack
- T3 Stack (
create-t3-app) — Next.js + tRPC + Prisma + NextAuth, agrega Stripe manualmente
Soporte de equipo / organización:
model Organization \{
id String @id @default(cuid())
name String
members Member[]
subscription Subscription?
\}
model Member \{
id String @id @default(cuid())
userId String
organizationId String
role String // owner | admin | member
organization Organization @relation(fields: [organizationId], references: [id])
@@unique([userId, organizationId])
\}Mueve stripeCustomerId y Subscription a Organization para que la facturación sea por espacio de trabajo.
Seguridad a nivel de fila con Supabase: usa @supabase/ssr + políticas de RLS en lugar de Prisma para datos que quieres proteger a nivel de base de datos.
Patrones de multi-arrendamiento: enrutamiento de subdominio ([tenant].app.com) vía reescritura de middleware, o basado en rutas (/org/[slug]). Almacena tenantId en cada fila.
Correo electrónico con Resend:
import \{ Resend \} from "resend";
const resend = new Resend(process.env.RESEND_API_KEY!);
await resend.emails.send(\{ from: "hi@app.com", to: user.email, subject: "Welcome", react: <Welcome /> \});Trabajos en segundo plano con Inngest: descarga efectos secundarios del webhook (envío de correos de bienvenida, aprovisionamiento de recursos) del manejador del webhook para que Stripe obtenga un 200 rápidamente.
Notas de TypeScript
// types/next-auth.d.ts — aumenta el tipo de Session
import \{ DefaultSession \} from "next-auth";
declare module "next-auth" \{
interface Session \{
user: \{
id: string;
stripeCustomerId: string | null;
subscriptionStatus: "active" | "trialing" | "past_due" | "canceled" | "none";
\} & DefaultSession["user"];
\}
\}// Estrechamiento de eventos de webhook de Stripe tipado
import type Stripe from "stripe";
function isSubscriptionEvent(
event: Stripe.Event,
): event is Stripe.Event & \{ data: \{ object: Stripe.Subscription \} \} \{
return event.type.startsWith("customer.subscription.");
\}// Esquema de Zod → tipo de entrada de formulario en una línea
import \{ z \} from "zod";
const pricingFormSchema = z.object(\{ priceId: z.string().startsWith("price_") \});
type PricingFormValues = z.infer<typeof pricingFormSchema>;Trampas
-
Las firmas de webhook de Stripe deben ser verificadas — llamar a
JSON.parse(await req.text())sinconstructEventsignifica que cualquiera que adivine tu URL puede mutar tu base de datos. Solución: siempre llama astripe.webhooks.constructEvent(rawBody, signature, secret)y pasa la cadena del body crudo, no un objeto analizado. -
El endpoint del webhook no puede usar el runtime de edge — el SDK de Node de Stripe usa módulos
cryptoque no existen en edge. La verificación de firma lanzarácrypto.createHmac is not a function. Solución: estableceexport const runtime = "nodejs"en la ruta del webhook. -
El cliente de Prisma debe ser un singleton en dev — sin él, cada hot reload genera un nuevo cliente y Postgres eventualmente rechaza conexiones con "too many clients". Solución: usa el patrón singleton de
globalThisenlib/prisma.ts. -
La sincronización de cliente de Stripe + Auth.js debe suceder en el registro, no en checkout — si creas el cliente de Stripe de manera perezosa en el primer checkout, golpeas condiciones de carrera (haz doble clic en el botón de actualización, obtén dos clientes). Solución: usa
events.createUserpara aprovisionar el cliente atómicamente cuando se crea el usuario. -
Variables de entorno para claves de Stripe dev vs prod — comprometer claves de prueba a
.env.production(u olvidar intercambiar el secreto del webhook) silenciosamente fallará en la verificación de firma en producción. Solución: usa cuentas / modos de Stripe separados, endpoints de webhook separados y variables de entorno separadas para cada entorno. Nunca reutilicesSTRIPE_WEBHOOK_SECRETen entornos. -
Los cambios de estado de suscripción son asincronos — un usuario paga, obtiene redireccionado a
success_url, y tu aplicación felizmente muestra "Características Pro" — pero el webhook no ha llegado aún, por lo que la BD aún dicestatus: "none". Solución: ya sea (a) esperar al webhook antes de otorgar acceso (encuesta en la página de éxito), o (b) confía en el resultado de la Sesión de Checkout de manera optimista y reconcilia vía el webhook en segundos. -
Las Server Actions deben protegerse contra quienes llaman no autenticados —
"use server"no comprueba auth para ti. Cada acción debe llamar aauth()y lanzar si falta la sesión. Solución: envuelve acciones en un ayudanterequireUser(). -
Middleware y
auth()juntos — llamar aauth()dentro de middleware en edge funciona, pero llamar a Prisma desde middleware no (Prisma no es compatible con edge). Solución: mantén middleware solo para comprobaciones de sesión; haz búsquedas de BD en manejadores de página / ruta.
Alternativas
| Alternativa | Úsalo Cuando | No Lo Uses Cuando |
|---|---|---|
| Shipixen | Quieres un iniciador pulido y opinado generado desde un asistente | Quieres ser dueño de cada línea de código |
| Makerkit (pagado) | Quieres una plantilla SaaS de grado de producción con equipos, facturación y blog pre-construido | Limitaciones de presupuesto o requisito totalmente de código abierto |
| Vercel Commerce | Estás construyendo una tienda de comercio electrónico, no un SaaS de suscripción | Necesitas auth + suscripciones por usuario |
| Iniciador SaaS de Next.js de Vercel | Quieres el referente canónico de Vercel — Next.js + Postgres + Stripe + Drizzle | Prefieres Prisma sobre Drizzle |
| Plantilla de Next.js de Railway | Quieres Postgres alojado en Railway conectado automáticamente | Estás desplegando a Vercel o Cloudflare |
| T3 Stack + Stripe manual | Quieres tRPC y seguridad de tipos fuerte en el límite cliente / servidor | Prefieres REST o Server Actions sobre tRPC |
Preguntas Frecuentes
¿Por qué la ruta del webhook de Stripe establece runtime = "nodejs"?
Porque stripe.webhooks.constructEvent se basa en el módulo crypto de Node para verificar firmas HMAC, que el runtime de edge no proporciona. Ejecutarse en edge lanzará en el primer webhook.
¿Por qué crear el cliente de Stripe en events.createUser en lugar de en checkout?
Garantiza que cada usuario tenga exactamente un stripeCustomerId desde el momento en que se registren, eliminando condiciones de carrera y registros de cliente duplicados cuando los usuarios hacen clic en "Actualizar" varias veces o actualizan a mitad del checkout.
¿Por qué envolver PrismaClient en un singleton de globalThis?
El hot reload de Next.js re-evalúa módulos en cada cambio de archivo en dev. Sin el singleton, cada recarga crea un nuevo PrismaClient, cada uno abriendo su propio pool. Postgres rápidamente golpea su límite de conexión y lanza "too many clients."
¿Cómo pruebo webhooks de Stripe localmente?
Instala la CLI de Stripe, ejecuta stripe listen --forward-to localhost:3000/api/webhooks/stripe, copia el whsec_... impreso a STRIPE_WEBHOOK_SECRET, luego dispara eventos con stripe trigger checkout.session.completed.
¿Cuál es la diferencia entre session: \{ strategy: "database" \} y "jwt"?
database almacena sesiones en tu BD vía el adaptador — puedes revocar una sesión única desde el servidor. jwt almacena todo en una cookie firmada — sin viaje de BD, pero la revocación requiere cambiar AUTH_SECRET, lo que anula cada sesión.
¿Por qué session.user.subscriptionStatus se computa en el callback de sesión?
Para que cada página y Server Action que llame a auth() vea automáticamente el estado de facturación más reciente sin tener que consultar Prisma por separado. El trade-off es una consulta adicional por solicitud; almacénala en caché si es necesario.
Trampa: mi webhook se ejecuta dos veces para el mismo evento — ¿por qué?
Stripe reintentos de webhooks que no devuelven 2xx dentro de 30 segundos. Haz que el manejador sea idempotente: usa upsert con clave en stripeSubscriptionId y devuelve 200 inmediatamente, descargando trabajo lento a un trabajo en segundo plano.
Trampa: otorgué acceso Pro en la página de success_url pero el usuario no es Pro. ¿Qué pasó?
El webhook no había aterrado cuando el redireccionamiento golpeó, por lo que subscriptionStatus aún era none. Ya sea encuesta en la página de éxito hasta que la BD se actualice, u obtén la Sesión de Checkout directamente con stripe.checkout.sessions.retrieve(id) y confía en el payment_status.
TypeScript: ¿cómo agrego stripeCustomerId a session.user?
Crea types/next-auth.d.ts y usa declare module "next-auth" para aumentar la interfaz Session. Incluye el archivo en tu tsconfig.json vía "include". Auth.js v5 lee aumentación de módulo en tiempo de construcción.
TypeScript: ¿cómo reduzco un evento de webhook de Stripe a un evento de Subscription?
Escribe un guardia de tipo definido por el usuario:
function isSubscriptionEvent(
e: Stripe.Event,
): e is Stripe.Event & \{ data: \{ object: Stripe.Subscription \} \} \{
return e.type.startsWith("customer.subscription.");
\}Luego if (isSubscriptionEvent(event)) \{ ... \} te da un event.data.object tipado dentro del bloque.
¿Debo poner stripeCustomerId en User u Organization?
En User si la facturación es por usuario (planes personales). En Organization si la facturación es por espacio de trabajo (planes de equipo) — que es el modelo SaaS normal. Puedes empezar en User y migrar después, pero es más fácil diseñar para organizaciones desde el principio.
¿Puedo usar Drizzle o Kysely en lugar de Prisma?
Sí. Auth.js tiene @auth/drizzle-adapter y un adaptador de Kysely. El resto de esta receta (Stripe, Server Actions, manejo de webhook) es agnóstico de ORM. Drizzle es más ligero en el edge; Prisma tiene mejor DX y un ecosistema más rico.
Relacionado
- Environment Variables — gestionando secretos de dev y prod de forma segura
- Authentication — patrones de Auth.js y protección de rutas
- Server Actions — llamando Stripe desde Server Actions
- Prisma — diseño de esquema y patrones de consultas
- Stripe Setup — fundamentos de configuración del SDK de Stripe
- Turborepo Monorepo — escala este iniciador a múltiples aplicaciones