Arquitectura de un SaaS escalable con Next.js, IA ¿Cuál usar y por qué?

18 de noviembre de 2024
12 min lectura
By Sly & AI

Tabla de Contenidos

Secciones de esta publicación.
Haz clic en cualquiera de ellas para ir directamente a esa sección.

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 routing
  • server/ para código que NUNCA debe ir al cliente
  • components/ 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 de uuid() - más cortos, ordenables
  • Relaciones bien definidas con onDelete: Cascade
  • Usage table con unique constraint para agregación diaria
  • ApiKey indexado 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í.