Construir un SaaS escalable en 2024 es diferente a hace dos años. Next.js 14 con App Router, IA como feature esperado, edge computing como estándar, y un ecosistema que cambia cada tres meses.
He construido y escalado varios SaaS con Next.js. Estos son los patrones que funcionan, las decisiones que importan, y los errores que no debes cometer.
El Stack Base: Lo No Negociable
Antes de hablar de arquitectura, esto es lo que necesitas:
// package.json (lo importante)
{
"dependencies": {
"next": "14.2.0",
"react": "^18",
"typescript": "^5",
"@prisma/client": "^5.0.0",
"@clerk/nextjs": "^4.29.0", // Auth
"@vercel/analytics": "^1.1.0", // Analytics
"@vercel/speed-insights": "^1.0.0", // Performance
"ai": "^3.0.0", // Vercel AI SDK
"@upstash/redis": "^1.28.0", // Rate limiting
"stripe": "^14.0.0", // Pagos
"resend": "^3.0.0" // Emails
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"prisma": "^5.0.0",
"tailwindcss": "^3.4.0"
}
}Estructura del Proyecto: Organización Que Escala
La estructura típica de tutorial no funciona a escala. Esto sí:
my-saas/├── app/│ ├── (auth)/│ │ ├── sign-in/│ │ └── sign-up/│ ├── (dashboard)/│ │ ├── layout.tsx│ │ ├── page.tsx│ │ ├── projects/│ │ └── settings/│ ├── (marketing)/│ │ ├── layout.tsx│ │ ├── page.tsx│ │ ├── pricing/│ │ └── about/│ ├── api/│ │ ├── ai/│ │ │ └── route.ts│ │ ├── webhooks/│ │ │ └── stripe/│ │ └── trpc/│ │ └── [trpc]/│ └── layout.tsx├── src/│ ├── lib/│ │ ├── db.ts│ │ ├── auth.ts│ │ ├── stripe.ts│ │ └── ai.ts│ ├── components/│ │ ├── ui/ # shadcn/ui components│ │ ├── dashboard/│ │ └── marketing/│ ├── server/│ │ ├── api/│ │ │ ├── root.ts│ │ │ └── routers/│ │ └── db.ts│ └── types/│ └── index.ts├── prisma/│ └── schema.prisma└── public/Por qué esta estructura:
- Route groups
(auth),(dashboard),(marketing)para layouts diferentes sin afectar URLs src/para lógica de negocio separada de routingserver/para código que NUNCA debe ir al clientecomponents/organizado por feature, no por tipo
Base de Datos: Prisma + PostgreSQL
No uses MongoDB para un SaaS. Necesitas relaciones, transacciones y consistencia.
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
email String @unique
name String?
clerkId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscription Subscription?
projects Project[]
apiKeys ApiKey[]
usage Usage[]
}
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stripeCustomerId String @unique
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
plan String @default("free") // free, pro, enterprise
status String @default("active") // active, canceled, past_due
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Project {
id String @id @default(cuid())
name String
slug String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
settings Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
aiRequests AiRequest[]
}
model AiRequest {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
prompt String
response String
model String
tokens Int
cost Float
createdAt DateTime @default(now())
}
model Usage {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
date DateTime @default(now())
requests Int @default(0)
tokensUsed Int @default(0)
cost Float @default(0)
@@unique([userId, date])
}
model ApiKey {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
key String @unique
lastUsedAt DateTime?
createdAt DateTime @default(now())
@@index([key])
}Por qué este schema:
cuid()en lugar deuuid()- más cortos, ordenables- Relaciones bien definidas con
onDelete: Cascade Usagetable con unique constraint para agregación diariaApiKeyindexado para lookups rápidos
Autenticación: Clerk vs. NextAuth
He usado ambos. Clerk gana para SaaS comerciales:
// src/lib/auth.ts
import { auth, currentUser } from '@clerk/nextjs/server'
import { db } from './db'
export async function getCurrentUser() {
const { userId } = auth()
if (!userId) return null
const user = await currentUser()
if (!user) return null
// Sincronizar con tu DB
const dbUser = await db.user.upsert({
where: { clerkId: userId },
update: {
email: user.emailAddresses[0].emailAddress,
name: user.firstName + ' ' + user.lastName,
},
create: {
clerkId: userId,
email: user.emailAddresses[0].emailAddress,
name: user.firstName + ' ' + user.lastName,
},
})
return dbUser
}
export async function requireAuth() {
const user = await getCurrentUser()
if (!user) {
throw new Error('Unauthorized')
}
return user
}
// Uso en Server Components
export default async function DashboardPage() {
const user = await requireAuth()
return <div>Welcome {user.name}</div>
}
// Uso en API Routes
export async function POST(req: Request) {
const user = await requireAuth()
// Tu lógica aquí
}Por qué Clerk:
- UI pre-construido que no se ve mal
- Social logins sin configuración
- Webhooks para sincronizar usuarios
- Orgs y teams built-in
- $25/mes para hasta 10k MAU (razonable)
Cuándo usar NextAuth:
- Presupuesto apretado ($0)
- Necesitas control total
- Requisitos de auth muy específicos
Integración de IA: Arquitectura Real
Aquí es donde se pone interesante. La IA no es un feature, es la infraestructura.
1. Abstracción de Modelos
// src/lib/ai.ts
import { openai } from '@ai-sdk/openai'
import { anthropic } from '@ai-sdk/anthropic'
import { generateText, streamText } from 'ai'
export type AIModel = 'gpt-4-turbo' | 'claude-3-5-sonnet' | 'gpt-3.5-turbo'
export const modelConfig = {
'gpt-4-turbo': {
provider: openai('gpt-4-turbo'),
cost: { input: 10, output: 30 }, // $ per 1M tokens
maxTokens: 4096,
},
'claude-3-5-sonnet': {
provider: anthropic('claude-3-5-sonnet-20241022'),
cost: { input: 3, output: 15 },
maxTokens: 4096,
},
'gpt-3.5-turbo': {
provider: openai('gpt-3.5-turbo'),
cost: { input: 0.5, output: 1.5 },
maxTokens: 4096,
},
}
export async function generateAI(
prompt: string,
model: AIModel = 'claude-3-5-sonnet',
systemPrompt?: string
) {
const config = modelConfig[model]
const { text, usage } = await generateText({
model: config.provider,
messages: [
...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
{ role: 'user' as const, content: prompt }
],
maxTokens: config.maxTokens,
})
// Calcular costo
const cost =
(usage.promptTokens / 1_000_000) * config.cost.input +
(usage.completionTokens / 1_000_000) * config.cost.output
return { text, usage, cost }
}
export async function streamAI(
prompt: string,
model: AIModel = 'claude-3-5-sonnet',
systemPrompt?: string
) {
const config = modelConfig[model]
return streamText({
model: config.provider,
messages: [
...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
{ role: 'user' as const, content: prompt }
],
maxTokens: config.maxTokens,
})
}2. API Route con Rate Limiting
// app/api/ai/generate/route.ts
import { NextRequest } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { generateAI } from '@/lib/ai'
import { db } from '@/lib/db'
import { ratelimit } from '@/lib/rate-limit'
export async function POST(req: NextRequest) {
try {
const user = await requireAuth()
// Rate limiting
const { success, remaining } = await ratelimit.limit(user.id)
if (!success) {
return Response.json(
{ error: 'Rate limit exceeded' },
{
status: 429,
headers: { 'X-RateLimit-Remaining': remaining.toString() }
}
)
}
// Check subscription
const subscription = await db.subscription.findUnique({
where: { userId: user.id }
})
if (!subscription || subscription.status !== 'active') {
return Response.json(
{ error: 'Subscription required' },
{ status: 403 }
)
}
// Parsear request
const { prompt, projectId, model = 'claude-3-5-sonnet' } = await req.json()
// Verificar ownership del proyecto
const project = await db.project.findFirst({
where: { id: projectId, userId: user.id }
})
if (!project) {
return Response.json({ error: 'Project not found' }, { status: 404 })
}
// Generar con IA
const { text, usage, cost } = await generateAI(prompt, model)
// Registrar en DB
await db.$transaction([
// Guardar request
db.aiRequest.create({
data: {
projectId,
prompt,
response: text,
model,
tokens: usage.totalTokens,
cost,
}
}),
// Actualizar usage del día
db.usage.upsert({
where: {
userId_date: {
userId: user.id,
date: new Date(new Date().setHours(0, 0, 0, 0))
}
},
update: {
requests: { increment: 1 },
tokensUsed: { increment: usage.totalTokens },
cost: { increment: cost }
},
create: {
userId: user.id,
date: new Date(new Date().setHours(0, 0, 0, 0)),
requests: 1,
tokensUsed: usage.totalTokens,
cost
}
})
])
return Response.json({
text,
usage: {
tokens: usage.totalTokens,
cost: cost.toFixed(4)
}
})
} catch (error) {
console.error('AI generation error:', error)
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}3. Rate Limiting con Upstash Redis
// src/lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
// Crear cliente Redis
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
// Rate limiter por usuario
export const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests por minuto
analytics: true,
prefix: 'ratelimit:user',
})
// Rate limiter por IP (para endpoints públicos)
export const ratelimitByIP = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 requests por minuto
analytics: true,
prefix: 'ratelimit:ip',
})Pagos con Stripe: La Implementación Correcta
// src/lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
typescript: true,
})
export const plans = {
free: {
name: 'Free',
price: 0,
limits: {
requests: 10,
projects: 1,
}
},
pro: {
name: 'Pro',
price: 29,
priceId: process.env.STRIPE_PRO_PRICE_ID!,
limits: {
requests: 1000,
projects: 10,
}
},
enterprise: {
name: 'Enterprise',
price: 99,
priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID!,
limits: {
requests: 10000,
projects: -1, // unlimited
}
}
}
// Crear checkout session
export async function createCheckoutSession(
userId: string,
priceId: string,
customerId?: string
) {
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'subscription',
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
metadata: {
userId,
},
})
return session
}
// Crear portal session (para cancelar subscripción)
export async function createPortalSession(customerId: string) {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings`,
})
return session
}Webhook de Stripe
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
import Stripe from 'stripe'
export async function POST(req: Request) {
const body = await req.text()
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (error) {
return new Response('Webhook signature verification failed', { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
await db.subscription.update({
where: { userId: session.metadata!.userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
stripePriceId: session.line_items?.data[0]?.price?.id,
stripeCurrentPeriodEnd: new Date(session.expires_at * 1000),
status: 'active',
plan: getPlanFromPriceId(session.line_items?.data[0]?.price?.id!),
},
})
break
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice
await db.subscription.update({
where: { stripeSubscriptionId: invoice.subscription as string },
data: {
stripeCurrentPeriodEnd: new Date(invoice.period_end * 1000),
status: 'active',
},
})
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await db.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: {
status: 'canceled',
},
})
break
}
}
return new Response(null, { status: 200 })
}
function getPlanFromPriceId(priceId: string): string {
if (priceId === process.env.STRIPE_PRO_PRICE_ID) return 'pro'
if (priceId === process.env.STRIPE_ENTERPRISE_PRICE_ID) return 'enterprise'
return 'free'
}Monitoreo y Analytics
No puedes optimizar lo que no mides:
// src/lib/analytics.ts
import { track } from '@vercel/analytics'
export function trackEvent(name: string, properties?: Record<string, any>) {
track(name, properties)
}
// Wrapper para eventos importantes
export const analytics = {
signup: (userId: string) => {
trackEvent('user_signup', { userId })
},
subscription: (userId: string, plan: string) => {
trackEvent('subscription_created', { userId, plan })
},
aiRequest: (userId: string, model: string, tokens: number) => {
trackEvent('ai_request', { userId, model, tokens })
},
error: (error: Error, context?: Record<string, any>) => {
console.error(error)
trackEvent('error', {
message: error.message,
stack: error.stack,
...context
})
}
}Deployment: Vercel vs. Self-Hosted
Vercel (recomendado para empezar):
# Instala Vercel CLI
npm i -g vercel
# Deploy
vercel --prod
# Variables de entorno
vercel env add DATABASE_URL production
vercel env add STRIPE_SECRET_KEY production
# etc...Costo real con tráfico decente:
- Vercel Pro: $20/mes
- Neon Postgres: $20/mes
- Upstash Redis: $10/mes
- Clerk: $25/mes
- Total: ~$75/mes para empezar
Self-hosted (cuando escales):
# Dockerfile
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]Conclusión: El Stack Completo
// El stack que realmente uso en producción
const productionStack = {
// Frontend
framework: 'Next.js 14 (App Router)',
styling: 'Tailwind + shadcn/ui',
state: 'React Server Components + Zustand (client)',
// Backend
api: 'Next.js API Routes + tRPC',
database: 'Neon PostgreSQL',
orm: 'Prisma',
cache: 'Upstash Redis',
// Features
auth: 'Clerk',
payments: 'Stripe',
emails: 'Resend',
ai: 'Vercel AI SDK + Claude/GPT-4',
// Infra
hosting: 'Vercel',
monitoring: 'Vercel Analytics + Speed Insights',
errors: 'Sentry',
// Total cost: ~$100-200/mes dependiendo del tráfico
}La decisión más importante: empieza simple, escala cuando necesites. No over-engineer desde el día uno. Este stack te lleva de 0 a 10,000 usuarios sin cambios mayores.
Construye, lanza, itera. La arquitectura perfecta no existe, la arquitectura que envías sí.