Un scale-up SaaS son seis audits y seis arreglos en un orden fijo. El orden importa porque las superficies se componen: rate limiting encima de una RLS rota sigue dejando escapar datos; caching encima de un billing roto sigue perdiendo dinero; observabilidad encima de un caching roto sigue sin contar nada útil. El trabajo es mecánico una vez fijado el orden; el valor está en el orden.
Los cinco archivos de abajo son la armadura que deja el encargo. El test de aislamiento de tenants, el handler de webhooks de Stripe, el middleware de rate limit, el wrapper de caché, y el bootstrap de observabilidad. Cada archivo es pequeño. Cada archivo es el único sitio donde el equipo mete las manos cuando el sistema tiene que cambiar.
1. El test de aislamiento de tenants
El test hace login como usuario A, intenta leer los recursos del usuario B en cada ruta API, y hace fallar la build en cualquier respuesta no vacía. Sembramos dos usuarios al inicio del test y los desmontamos al final; el test es hermético. Corre en cada PR.
// tests/integration/tenant-isolation.spec.ts
import { test, expect } from '@playwright/test'
import { createClient } from '@supabase/supabase-js'
const ROUTES_THAT_RETURN_TENANT_DATA = [
{ method: 'GET', path: '/api/projects' },
{ method: 'GET', path: '/api/projects/:id' },
{ method: 'GET', path: '/api/invoices' },
{ method: 'GET', path: '/api/team' },
]
test('user A cannot read user B data', async ({ request }) => {
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE!)
const userA = await supabase.auth.admin.createUser({ email: 'a@test', password: 'a' })
const userB = await supabase.auth.admin.createUser({ email: 'b@test', password: 'b' })
const { data: signIn } = await supabase.auth.signInWithPassword({ email: 'a@test', password: 'a' })
const tokenA = signIn!.session!.access_token
// Siembra un proyecto para el usuario B
await supabase.from('projects').insert({ owner: userB.data.user!.id, name: 'B project' })
for (const route of ROUTES_THAT_RETURN_TENANT_DATA) {
const res = await request.fetch(route.path, {
method: route.method,
headers: { Authorization: `Bearer ${tokenA}` },
})
const body = await res.json()
expect(body.data ?? body, `la ruta ${route.path} dejó escapar datos de tenant`).toEqual([])
}
await supabase.auth.admin.deleteUser(userA.data.user!.id)
await supabase.auth.admin.deleteUser(userB.data.user!.id)
})
2. El handler de webhooks de Stripe
El handler es una sola ruta, hace switch sobre el tipo de evento, escribe cada evento a una tabla events con el ID de evento de Stripe como idempotency key. Un webhook reenviado es un no-op. Un handler que tira error lo reintenta Stripe; la tabla de eventos registra el reintento. Nunca modificamos estado del cliente sin una fila de evento.
// app/api/stripe/webhook/route.ts
import { headers } from 'next/headers'
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request): Promise<Response> {
const signature = (await headers()).get('stripe-signature') ?? ''
const body = await req.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return new Response(`signature invalid: ${(err as Error).message}`, { status: 400 })
}
const supabase = createClient()
// Idempotencia: el insert devuelve conflict si el evento ya estaba procesado.
const { error: idempErr } = await supabase
.from('stripe_events')
.insert({ id: event.id, type: event.type, created: event.created })
if (idempErr?.code === '23505') {
return new Response('already processed', { status: 200 })
}
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionChange(supabase, event.data.object)
break
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(supabase, event.data.object)
break
case 'invoice.payment_failed':
await handlePaymentFailed(supabase, event.data.object)
break
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(supabase, event.data.object)
break
case 'customer.deleted':
await handleCustomerDeleted(supabase, event.data.object)
break
}
return new Response('ok', { status: 200 })
}
3. El middleware de rate limit
Un rate limiter sliding-window sobre Upstash Redis, con buckets por IP y por usuario. El middleware corre en cada ruta pública. Los límites viven en un archivo de config; subir uno es una code review, no una sesión de redis-cli.
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const redis = Redis.fromEnv()
const RATE_LIMITS = {
default: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(60, '1 m'), prefix: 'rl:default' }),
auth: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, '1 m'), prefix: 'rl:auth' }),
write: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(20, '1 m'), prefix: 'rl:write' }),
}
function pickLimit(path: string): keyof typeof RATE_LIMITS {
if (path.startsWith('/api/auth')) return 'auth'
if (path.startsWith('/api/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes('POST')) return 'write'
return 'default'
}
export async function middleware(req: NextRequest): Promise<NextResponse> {
if (!req.nextUrl.pathname.startsWith('/api/')) return NextResponse.next()
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const limit = RATE_LIMITS[pickLimit(req.nextUrl.pathname)]
const result = await limit.limit(ip)
if (!result.success) {
return new NextResponse(
JSON.stringify({ success: false, error: 'rate_limited' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(Math.ceil((result.reset - Date.now()) / 1000)),
},
},
)
}
return NextResponse.next()
}
export const config = { matcher: '/api/:path*' }
4. El wrapper de caché con stale-while-revalidate
Un helper tipado que envuelve cada lectura cacheable. Devuelve datos cacheados al momento cuando están fresh, devuelve datos stale y refresca en background cuando se está dentro de la ventana SWR, y cae al loader cuando han pasado las dos ventanas. Cada hit y miss de caché se cuenta; la métrica va al stack de observabilidad.
// lib/cache/cachedApiCall.ts
import { Redis } from '@upstash/redis'
const redis = Redis.fromEnv()
interface CacheEntry<T> {
value: T
fresh: number
stale: number
}
interface CacheOptions {
ttl: number // segundos
staleWhileRevalidate?: number // segundos extra en los que se sirve stale mientras corre el refresh en background
}
export async function cachedApiCall<T>(
key: string,
loader: () => Promise<T>,
options: CacheOptions,
): Promise<T> {
const now = Date.now()
const cached = await redis.get<CacheEntry<T>>(key)
if (cached && cached.fresh > now) {
return cached.value
}
if (cached && cached.stale > now) {
// Sirve stale, refresca en background; no esperes.
void refreshAndStore(key, loader, options).catch(() => undefined)
return cached.value
}
const fresh = await loader()
await refreshAndStore(key, async () => fresh, options)
return fresh
}
async function refreshAndStore<T>(
key: string,
loader: () => Promise<T>,
options: CacheOptions,
): Promise<void> {
const value = await loader()
const now = Date.now()
const entry: CacheEntry<T> = {
value,
fresh: now + options.ttl * 1000,
stale: now + (options.ttl + (options.staleWhileRevalidate ?? 0)) * 1000,
}
await redis.set(key, entry, { ex: options.ttl + (options.staleWhileRevalidate ?? 0) })
}
5. El bootstrap de observabilidad
Un solo archivo conecta logging estructurado, error reporting y métricas. El runtime lo importa una vez en lo alto de instrumentation.ts. El equipo recibe las cuatro señales doradas el primer día; el resto crece a partir de las mismas primitivas.
// instrumentation.ts
import * as Sentry from '@sentry/nextjs'
import pino from 'pino'
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: ['*.password', '*.token', 'authorization'],
formatters: {
level: (label) => ({ level: label }),
},
})
export async function register(): Promise<void> {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.VERCEL_GIT_COMMIT_SHA,
tracesSampleRate: 0.1,
beforeSend(event) {
// Quita PII de los breadcrumbs antes de reportar.
if (event.request?.headers) {
delete event.request.headers.authorization
delete event.request.headers.cookie
}
return event
},
})
logger.info({ release: process.env.VERCEL_GIT_COMMIT_SHA }, 'instrumentation ready')
}
6. Lo que esto compone
El test de tenant demuestra que los datos no escapan. El handler de webhooks demuestra que el billing no pierde estado. El rate limit demuestra que la superficie pública no se derrite bajo abuso. La caché demuestra que la base de datos no se golpea en cada page view. El bootstrap demuestra que el equipo sabe qué está pasando cuando algo va mal.
El MVP ya no es un MVP. El codebase es aburrido en el modo concreto en que es aburrido un SaaS que escala: el trabajo ocurre en los archivos que toca, las métricas dicen la verdad, la persona de guardia lee un runbook en vez de llamar al founder, y la próxima vez que la prensa pesque el producto, el sitio aguanta.