De un MVP que funciona en martes a un SaaS que sobrevive al lunes a las 9 de la mañana
Seis audits, seis arreglos, en orden. Auth y RLS apretadas para que un tenant no pueda leer datos de otro. Rate limiting cableado a cada endpoint que un cliente puede tocar. Una capa Redis que distingue entre un precio stale y un token stale. Billing de Stripe que gestiona tarjetas rechazadas, prorrateos y trial-to-paid. Observabilidad que saca a la luz el siguiente outage antes de que el usuario escriba a soporte. Un runbook que la persona de guardia lee a las 3 de la mañana.
El problema
El MVP que ganó los primeros diez clientes va a perder los siguientes cien
El patrón es conocido. El founder entregó rápido. El auth era una sola tabla de Supabase, la RLS estaba apagada porque el dashboard ya funcionaba, Stripe era un único webhook `customer.subscription.created`, la caché era todo-en-memoria o nada, y cada endpoint se fiaba del usuario. El producto encontró diez clientes que pagan. Luego dos cosas a la vez: alguien probó una query con forma de SQL injection contra la API pública, y una mención de prensa de viernes por la tarde trajo 4.000 sign-ups en una hora. El MVP se cayó un fin de semana y el founder entregó un hotfix branch que ahora corre en producción. Cogemos ese codebase, auditamos las seis superficies que deciden si un SaaS escala, las arreglamos en orden, y le dejamos al equipo un runbook que explica cómo gestionar el siguiente incidente sin llamar al founder. El codebase cierra el encargo aburrido; ese es el objetivo.
Cómo lo abordamos
Seis audits, seis arreglos, en orden
Auth antes que billing. Billing antes que rate limiting. Rate limiting antes que caché. Caché antes que observabilidad. Observabilidad antes que runbook. Saltarse un paso significa que el paso siguiente hereda el problema que te saltaste.
- 01
Auth y aislamiento de tenants
Empezamos leyendo cada tabla de Supabase y cada ruta API. Activamos RLS en cada tabla con scope de tenant. Cada ruta o usa la JWT del usuario o tiene una razón documentada para usar `service_role`. Añadimos un test de aislamiento de tenant que hace login como usuario A, intenta leer datos del usuario B y hace fallar la build cuando la respuesta no es vacía. El primer cutover sale cuando ese test está en verde; si otras superficies siguen rotas, al menos el resto del sistema no escapa datos.
- 02
Madurez del billing
Recableamos Stripe. Webhooks para `invoice.payment_failed`, `customer.subscription.updated`, `invoice.upcoming` y `customer.deleted` (no solo `subscription.created`). El customer portal se convierte en un enlace que el cliente puede abrir; la bandeja de soporte deja de ser un mostrador de reembolsos. Prorrateo, transición trial-to-paid y dunning se prueban contra un Stripe test clock, no contra dinero real de clientes. Cada evento de billing aterriza en una tabla `events` con idempotency key, así un webhook reenviado no dispara un cobro doble.
- 03
Rate limiting y control de abuso
Cada endpoint público recibe un rate limit. El default es por IP y por usuario, sliding window, sobre Redis. Los endpoints de auth reciben un límite más estricto (5 por minuto, ban de 15). Los endpoints de write reciben un límite por tenant. Las respuestas 429 llevan una cabecera `Retry-After` y un error estructurado que el cliente puede leer. Documentamos los límites en el runbook para que la persona de guardia sepa qué pomo girar durante un pico.
- 04
Estrategia de caché
Las cachés reciben un nivel y un TTL escritos por escrito. Las lecturas hot (precios, feature flags, catálogo público) van a Redis con stale-while-revalidate. Las lecturas tenant (dashboard, settings) van a TanStack Query con clave por usuario y `staleTime` de cinco minutos. Las mutaciones invalidan explícitamente; nada se fía solo del TTL de caché para refrescar. Cada clave Redis tiene una entrada tipada en config; ningún `redis.keys('*')` en ningún sitio del codebase.
- 05
Observabilidad
Los logs pasan de `console.log` a JSON estructurado. El error reporting va a Sentry con release tagging y source maps. La latencia se exporta a un endpoint de métricas (Vercel Analytics, Datadog o Posthog según el stack). Las cuatro señales doradas (latency, traffic, errors, saturation) tienen cada una su dashboard que el founder marca como favorito. Las alertas saltan sobre la burn rate del SLO, no sobre conteos de error en bruto; la persona de guardia duerme a través de los picos transitorios.
- 06
Runbook y postura ante incidentes
Escribimos un runbook que cubre los tres incidentes que el equipo se va a encontrar en el primer mes: un atasco de webhooks de Stripe, un agotamiento del connection pool de Supabase, y un pico de 429 inducido por el rate limit durante un empujón de marketing. Cada uno se lleva una entrada de una página con síntoma, dashboard a abrir, comando a lanzar, rollback. El founder no está de guardia; quien está de guardia tiene el runbook y los accesos.
Qué entregamos
Audit de RLS y remediación
Una revisión tabla por tabla de las policies de Row Level Security, con un plan de remediación para las tablas que necesitan policies apretadas o añadidas. El deliverable que decide si un cliente hostil puede leer los datos de un competidor; sale antes que el resto.
Suite de tests de aislamiento de tenants
Una suite que hace login como usuario A, intenta acceder a los recursos del usuario B en cada ruta API, y falla en cualquier respuesta no vacía. Corre en CI en cada PR. Atrapa el día que un futuro desarrollador entrega un endpoint que se olvida un `where tenant_id = $1`.
Handler de webhooks de Stripe
Un handler tipado para los siete eventos de Stripe que importan a un SaaS (`subscription.created/updated/deleted`, `invoice.paid/payment_failed/upcoming`, `customer.deleted`). Idempotente, replay-safe, signed-verification en cada llamada. Sustituye al handler de un solo evento con el que arrancó el MVP.
Test plan de billing
Un test plan contra el Stripe test clock que recorre los siete escenarios que un motor de billing SaaS tiene que gestionar (fin de trial, decline de tarjeta, upgrade de plan con prorrateo, downgrade, cancelación manual, refund, eliminación de cliente). El plan que pasa el QA antes de cualquier cambio en billing.
Middleware de rate limit
Un middleware Next.js que envuelve cada ruta pública con un rate limit sliding-window sobre Redis, configurable por ruta. Devuelve 429 con `Retry-After` y un body estructurado. Loguea cada bloqueo a la tabla de eventos para revisión posterior.
Configuración de claves Redis
Un archivo de config tipado que enumera cada clave Redis, su TTL y su semántica. La única fuente de verdad para la invalidación de caché. Elimina `redis.keys('*')` y los bugs que arrastra.
Setup de TanStack Query
Una configuración baseline de TanStack Query con `staleTime`, `gcTime`, política de retry y una query-key factory. Sustituye a los patrones ad hoc de `useEffect` + `fetch` por todo el codebase. Las mutaciones incluyen optimistic update donde el resultado es predecible.
Stack de observabilidad
Logging estructurado vía Pino, error reporting vía Sentry, métricas vía la plataforma que toque. Cada pieza configurada con release tagging, source maps y filtro PII. El primer dashboard sale con las cuatro señales doradas; el resto crece con el producto.
Definición de SLOs y alertas de burn-rate
Dos SLOs para arrancar: latencia API P95 por debajo de 300 ms en una ventana móvil de 7 días, y tasa de errores por debajo del 0,5% en la misma ventana. Las alertas de burn-rate (rápida y lenta) avisan solo cuando el presupuesto está en riesgo, no en cada pico. La interrupción se gana.
Runbook de incidentes
Un runbook de 12 páginas que cubre los tres incidentes más probables del primer mes (atasco de webhooks, agotamiento de pool de conexiones, pico de 429) más las entradas genéricas \"el sitio va lento\" y \"el sitio está caído\". Cada entrada tiene síntoma, dashboard, comando, rollback. La persona de guardia lo lee; nada más.
Procedimiento de deploy y rollback
Un flujo de deploy documentado con un rollback de un comando. Incluye los pasos de invalidación de caché tras un deploy que cambia la forma de una clave Redis, y la checklist de migración de base de datos para cualquier cambio de esquema. Sustituye al modelo mental del founder de `git push y reza`.
Baseline de capacidad
Un load test sencillo contra staging a tres niveles de tráfico (steady state, pico, abuso). El baseline muestra dónde se rompe el sistema y a qué concurrencia. Se vuelve a correr cada trimestre; el gráfico a lo largo del tiempo le dice al founder cuándo toca otra vuelta de scale-up.
Cinco archivos que llevan un SaaS de MVP a escala
Los cinco archivos de abajo componen la pipeline de scale-up. El test de aislamiento de tenant que demuestra que los datos no escapan, el handler de webhooks de Stripe con idempotencia, el middleware de rate limit, el wrapper de caché con stale-while-revalidate, y el bootstrap de observabilidad que conecta logs, errores y métricas.
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.
Preguntas frecuentes
¿Cómo decidís qué es crítico y qué puede esperar?
Ordenamos las superficies por blast radius. Todo lo que puede dejar escapar datos de un cliente va primero (RLS, aislamiento de tenants). Todo lo que puede hacer perder dinero va segundo (billing). Todo lo que puede tumbar el sitio va tercero (rate limiting, capacidad). Todo lo que deja dormir al equipo va cuarto (observabilidad, runbook). Cualquier otra cosa es trabajo post-encargo que el equipo puede hacer por su cuenta.
¿Se puede hacer sin tirar abajo el producto?
Sí, y así lo hacemos. El cutover de cada superficie ocurre detrás de un feature flag y es reversible. El rollout de RLS usa las policies `set` de Supabase con defaults permisivos durante la transición. El recableado de Stripe entrega el handler nuevo al lado del viejo y cambia solo cuando una semana de webhooks ha pasado limpia por los dos. Ningún cliente se entera del trabajo.
¿Y si nuestro MVP está en un stack con el que no soléis trabajar?
Las seis superficies son stack-agnostic. Las herramientas concretas cambian (Auth0 en lugar de Supabase Auth, Stigg en lugar de Stripe Billing, Cloudflare Rate Limiting en lugar de Upstash), pero el patrón es el mismo. No hacemos este trabajo en stacks cuyo código no sepamos leer; si el MVP está en Elixir o Ruby y no sabemos leer Ruby, lo decimos.
¿Cuánto tarda?
De ocho a doce semanas desde el kickoff. Auth y RLS llevan dos semanas. El recableado de Stripe lleva dos semanas (la mayor parte es testing contra el test clock). Rate limiting y caching llevan dos semanas en total. La observabilidad lleva una semana. El runbook lleva una semana. Las últimas dos semanas son colchón para la sorpresa inevitable que saca el audit.
¿Hay que elegir entre RLS y un API gateway?
No. La RLS es el suelo; el API gateway es el techo. La RLS garantiza que la base de datos se niegue a devolver datos de un tenant aunque la API tenga un bug. El gateway garantiza que la mayoría de los bugs no lleguen a la base de datos. Cinturón y tirantes; el encargo entrega los dos.
¿Qué pasa con los background jobs y las colas?
Parte del alcance cuando el MVP los tiene. La mayoría de SaaS en fase temprana usa un cron de Vercel o una Supabase Edge Function para los jobs, y los dos los auditamos y endurecemos. Cargas más pesadas pasan a Trigger.dev, Inngest o un BullMQ self-hosted según las restricciones. No introducimos una cola que el MVP no necesitaba; hacemos fiable la que ya está.
¿Reemplazáis a nuestro backend engineer?
No. Trabajamos a su lado. El runbook y el código están escritos para que el engineer que ya está los lleve cuando nos vamos. El encargo termina con un handover de media jornada y un evento en el calendario para una llamada de seguimiento a 30 días. La mayoría de los equipos nos necesita una vez; los que nos necesitan dos veces son los que se saltaron el runbook.
¿Cuánto cuesta si esperamos?
El encargo de scale-up más barato arranca antes del primer incidente pagado. El siguiente más barato arranca después del primer incidente. El más caro arranca después del primer cliente enterprise que se fue por una caída. La cuenta está en el runbook; la compartimos en la llamada de scoping.
Define el alcance del scale-up de tu SaaS
Una llamada de scoping, un audit de las seis superficies en la semana uno, un alcance fijo y un número que mantenemos. De ocho a doce semanas desde el kickoff hasta un SaaS que sobrevive al lunes a las 9 de la mañana.