Da un MVP che funziona di martedì a un SaaS che sopravvive al lunedì mattina alle 9
Sei audit, sei fix, in ordine. Auth e RLS strette così che un tenant non legga i dati di un altro. Rate limiting cablato a ogni endpoint che un cliente può colpire. Uno strato Redis che distingue tra un prezzo stale e un token stale. Billing Stripe che gestisce carte rifiutate, proration e trial-to-paid. Osservabilità che fa emergere il prossimo outage prima che l'utente scriva al supporto. Un runbook che la persona di reperibilità legge alle 3 di notte.
Il problema
L'MVP che ha vinto i primi dieci clienti perde i prossimi cento
Il pattern è familiare. Il founder ha spedito veloce. L'auth era una sola tabella Supabase, la RLS era spenta perché tanto la dashboard funzionava, Stripe era un singolo webhook `customer.subscription.created`, la cache era tutto-in-memoria o niente, e ogni endpoint si fidava dell'utente. Il prodotto ha trovato dieci clienti paganti. Poi due cose insieme: qualcuno ha provato una query a forma di SQL injection contro l'API pubblica, e una citazione stampa di venerdì pomeriggio ha portato 4.000 sign-up in un'ora. L'MVP è andato giù per un weekend e il founder ha spedito un hotfix branch che adesso fa girare la produzione. Prendiamo quel codebase, facciamo audit delle sei superfici che decidono se un SaaS scala, le sistemiamo in ordine, e lasciamo al team un runbook che spiega come gestire il prossimo incidente senza chiamare il founder. Il codebase chiude l'ingaggio noioso; è esattamente l'obiettivo.
Come lo affrontiamo
Sei audit, sei fix, in ordine
Auth prima del billing. Billing prima del rate limit. Rate limit prima della cache. Cache prima dell'osservabilità. Osservabilità prima del runbook. Saltare un passo significa che il passo successivo eredita il problema saltato.
- 01
Auth e isolamento dei tenant
Partiamo leggendo ogni tabella Supabase e ogni route API. La RLS viene attivata su ogni tabella tenant-scoped. Ogni route o usa la JWT dell'utente o ha una motivazione documentata per usare `service_role`. Aggiungiamo un test di isolamento tenant che fa login come utente A, prova a leggere i dati dell'utente B e fa fallire la build quando la risposta non è vuota. Il primo cutover esce quando quel test è verde; se altre superfici sono ancora rotte, almeno il resto del sistema non perde dati.
- 02
Maturità del billing
Stripe viene ricablato. Webhook per `invoice.payment_failed`, `customer.subscription.updated`, `invoice.upcoming` e `customer.deleted` (non solo `subscription.created`). Il customer portal diventa un link che il cliente può aprire; la inbox del supporto smette di essere un banco rimborsi. Proration, passaggio trial-to-paid e dunning si testano contro un Stripe test clock, non contro soldi veri dei clienti. Ogni evento billing atterra in una tabella `events` con idempotency key, così un webhook replay non fa partire un doppio addebito.
- 03
Rate limiting e controllo abuso
Ogni endpoint pubblico riceve un rate limit. Il default è per-IP e per-utente, sliding window, su Redis. Gli endpoint di auth ricevono un limite più stretto (5 al minuto, ban da 15). Gli endpoint write ricevono un limite per tenant. Le risposte 429 portano un header `Retry-After` e un errore strutturato che il client riesce a leggere. Documentiamo i limiti nel runbook così la persona di reperibilità sa quale manopola girare durante uno spike.
- 04
Strategia di cache
Le cache ricevono un tier e un TTL scritti nero su bianco. Le letture hot (prezzi, feature flag, catalogo pubblico) vanno in Redis con stale-while-revalidate. Le letture tenant (dashboard, settings) vanno in TanStack Query con una key per utente e uno `staleTime` di cinque minuti. Le mutazioni invalidano esplicitamente; nulla si affida al solo TTL di cache per rinfrescare. Ogni chiave Redis ha una entry tipizzata in config; nessun `redis.keys('*')` da nessuna parte nel codebase.
- 05
Osservabilità
I log passano da `console.log` a JSON strutturato. L'error reporting va su Sentry con release tagging e source map. La latenza viene esportata verso un endpoint di metriche (Vercel Analytics, Datadog o Posthog a seconda dello stack). I quattro golden signal (latency, traffic, errors, saturation) hanno ciascuno una dashboard che il founder mette nei preferiti. Gli alert partono sulla burn rate dello SLO, non sui count grezzi di errore; la persona di reperibilità dorme attraverso i blip transitori.
- 06
Runbook e postura agli incidenti
Scriviamo un runbook che copre i tre incidenti che il team incontrerà nel primo mese: un arretrato di webhook Stripe, l'esaurimento del connection pool di Supabase, e uno spike di 429 indotto dal rate limit durante una spinta marketing. Ognuno prende una voce di una pagina con sintomo, dashboard da aprire, comando da lanciare, rollback. Il founder non è in reperibilità; chi è in reperibilità ha il runbook e gli accessi.
Cosa consegniamo
Audit RLS e remediation
Una revisione tabella per tabella delle policy di Row Level Security, con un piano di remediation per le tabelle che hanno bisogno di policy strette o aggiunte. Il deliverable che decide se un cliente ostile può leggere i dati di un concorrente; esce prima di tutto il resto.
Suite di test di isolamento tenant
Una suite di test che fa login come utente A, prova ad accedere alle risorse dell'utente B su ogni route API, e fallisce su qualsiasi risposta non vuota. Gira in CI su ogni PR. Intercetta il giorno in cui un futuro sviluppatore spedisce un endpoint che si dimentica un `where tenant_id = $1`.
Handler webhook Stripe
Un handler tipizzato per i sette eventi Stripe che contano per un SaaS (`subscription.created/updated/deleted`, `invoice.paid/payment_failed/upcoming`, `customer.deleted`). Idempotente, replay-safe, signed-verification su ogni chiamata. Sostituisce l'handler a evento singolo con cui era partito l'MVP.
Test plan billing
Un test plan contro lo Stripe test clock che passa i sette scenari che un motore di billing SaaS deve gestire (fine trial, decline carta, upgrade piano con proration, downgrade piano, cancellazione manuale, refund, cancellazione cliente). Il piano che il QA passa prima di ogni cambio billing.
Middleware di rate limit
Un middleware Next.js che avvolge ogni route pubblica con un rate limit sliding-window su Redis, configurabile per route. Torna 429 con `Retry-After` e un body strutturato. Logga ogni blocco nella tabella eventi per revisione successiva.
Configurazione delle chiavi Redis
Un file di config tipizzato che enumera ogni chiave Redis, il suo TTL e la sua semantica. La fonte di verità unica per l'invalidazione di cache. Spazza via `redis.keys('*')` e i bug che si porta dietro.
Setup TanStack Query
Una configurazione baseline di TanStack Query con `staleTime`, `gcTime`, retry policy e una query-key factory. Sostituisce i pattern ad hoc di `useEffect` + `fetch` nel codebase. Le mutazioni includono optimistic update dove il risultato è prevedibile.
Stack di osservabilità
Logging strutturato via Pino, error reporting via Sentry, metriche via la piattaforma di scelta. Ogni pezzo configurato con release tagging, source map e filtro PII. La prima dashboard esce con i quattro golden signal; il resto cresce col prodotto.
Definizione SLO e alert burn-rate
Due SLO per partire: latenza API P95 sotto 300 ms su finestra mobile da 7 giorni, e tasso d'errore sotto lo 0,5% sulla stessa finestra. Gli alert burn-rate (veloce e lento) chiamano solo quando il budget è a rischio, non a ogni spike. La possibilità di essere chiamati va guadagnata.
Runbook degli incidenti
Un runbook da 12 pagine che copre i tre incidenti più probabili del primo mese (arretrato webhook, esaurimento pool connessioni, spike di 429) più le voci generiche \"sito lento\" e \"sito giù\". Ogni voce ha sintomo, dashboard, comando, rollback. La persona di reperibilità lo legge; nulla di più.
Procedura di deploy e rollback
Un flusso di deploy documentato con un rollback a un comando. Include i passi di invalidazione cache dopo un deploy che cambia la forma di una chiave Redis, e la checklist di migrazione database per ogni cambio di schema. Sostituisce il modello mentale del founder fatto di `git push e prega`.
Baseline di capacità
Un load test semplice contro staging a tre livelli di traffico (steady state, peak, abuse). Il baseline mostra dove il sistema si rompe e a quale concorrenza. Da ripetere ogni trimestre; il grafico nel tempo dice al founder quando è ora di un altro giro di scale-up.
Cinque file che portano un SaaS da MVP a scala
I cinque file qui sotto compongono la pipeline di scale-up. Il test di isolamento tenant che dimostra che i dati non escono, l'handler webhook Stripe con idempotenza, il middleware di rate limit, il wrapper di cache con stale-while-revalidate, e il bootstrap di osservabilità che cabla log, errori e metriche.
Uno scale-up SaaS sono sei audit e sei fix in un ordine fisso. L'ordine conta perché le superfici si compongono: rate limit sopra una RLS rotta perde comunque dati; cache sopra un billing rotto perde comunque soldi; osservabilità sopra una cache rotta racconta comunque nulla di utile. Il lavoro è meccanico una volta fissato l'ordine; il valore sta nell'ordine.
I cinque file qui sotto sono l'ossatura che l'ingaggio lascia in piedi. Il test di isolamento tenant, l'handler webhook Stripe, il middleware di rate limit, il wrapper di cache, e il bootstrap di osservabilità. Ogni file è piccolo. Ogni file è il posto dove il team mette le mani quando il sistema deve cambiare.
1. Il test di isolamento tenant
Il test fa login come utente A, prova a leggere le risorse dell'utente B su ogni route API, e fa fallire la build su qualsiasi risposta non vuota. Seminiamo due utenti all'inizio del test e li smontiamo alla fine; il test è ermetico. Gira a ogni 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
// Semina un progetto per l'utente 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, `route ${route.path} ha fatto uscire dati tenant`).toEqual([])
}
await supabase.auth.admin.deleteUser(userA.data.user!.id)
await supabase.auth.admin.deleteUser(userB.data.user!.id)
})
2. L'handler webhook Stripe
L'handler è una sola route, fa switch sul tipo di evento, scrive ogni evento in una tabella events con l'ID evento Stripe come idempotency key. Un webhook in replay è un no-op. Un handler che lancia errore viene riprovato da Stripe; la tabella eventi registra il retry. Non modifichiamo mai lo stato cliente senza una riga di 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()
// Idempotenza: l'insert torna conflict se l'evento è stato già processato.
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. Il middleware di rate limit
Un rate limiter sliding-window su Upstash Redis, con bucket per-IP e per-utente. Il middleware gira su ogni route pubblica. I limiti vivono in un file di config; alzarne uno è una code review, non una sessione 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. Il wrapper di cache con stale-while-revalidate
Un helper tipizzato che avvolge ogni lettura cacheable. Torna dati cacheati subito quando fresh, torna dati stale e rinfresca in background quando si è dentro la finestra SWR, e cade attraverso al loader quando entrambe le finestre sono passate. Ogni cache hit e miss viene contato; la metrica va allo stack di osservabilità.
// 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 // secondi
staleWhileRevalidate?: number // secondi extra in cui si serve stale mentre gira la rinfrescata in 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) {
// Serve stale, rinfresca in background; non aspettare.
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. Il bootstrap di osservabilità
Un file solo cabla logging strutturato, error reporting e metriche. Il runtime lo importa una volta in cima a instrumentation.ts. Il team riceve i quattro golden signal il primo giorno; il resto cresce dalle stesse primitive.
// 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) {
// Toglie PII dai breadcrumb prima di inviare.
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. Cosa compone questo
Il test di tenant dimostra che i dati non escono. L'handler webhook dimostra che il billing non perde stato. Il rate limit dimostra che la superficie pubblica non si scioglie sotto abuso. La cache dimostra che il database non viene colpito a ogni page view. Il bootstrap dimostra che il team sa cosa sta succedendo quando qualcosa va storto.
L'MVP non è più un MVP. Il codebase è noioso nel modo specifico in cui è noioso un SaaS che scala: il lavoro succede nei file giusti, le metriche dicono la verità, la persona di reperibilità legge un runbook invece di chiamare il founder, e la prossima volta che la stampa pesca il prodotto, il sito resta su.
Domande frequenti
Come decidete cosa è critico e cosa può aspettare?
Ordiniamo le superfici per blast radius. Tutto ciò che può far uscire i dati di un cliente va per primo (RLS, isolamento tenant). Tutto ciò che può far perdere soldi va secondo (billing). Tutto ciò che può buttare giù il sito va terzo (rate limit, capacità). Tutto ciò che fa dormire il team va quarto (osservabilità, runbook). Tutto il resto è lavoro post-ingaggio che il team può fare da solo.
Si può fare senza mettere il prodotto offline?
Sì, e lo facciamo. Il cutover per ogni superficie passa dietro a un feature flag ed è reversibile. Il rollout RLS usa le policy `set` di Supabase con default permissivi durante la transizione. Il ricablaggio Stripe spedisce il nuovo handler accanto a quello vecchio e passa solo dopo che una settimana di webhook è passata pulita da entrambi. Nessun cliente si accorge del lavoro.
E se il nostro MVP è su uno stack con cui di solito non lavorate?
Le sei superfici sono stack-agnostic. Gli strumenti specifici cambiano (Auth0 al posto di Supabase Auth, Stigg al posto di Stripe Billing, Cloudflare Rate Limiting al posto di Upstash), ma il pattern è lo stesso. Non facciamo questo lavoro su stack di cui non sappiamo leggere il codice; se l'MVP è in Elixir o Ruby e non sappiamo leggere Ruby, lo diciamo.
Quanto ci vuole?
Otto-dodici settimane dal kickoff. Auth e RLS prendono due settimane. Il ricablaggio Stripe prende due settimane (la maggior parte è testing contro il test clock). Rate limit e caching prendono due settimane in totale. L'osservabilità prende una settimana. Il runbook prende una settimana. Le ultime due settimane sono buffer per l'inevitabile sorpresa che l'audit fa emergere.
Dobbiamo scegliere tra RLS e un API gateway?
No. La RLS è il pavimento; l'API gateway è il soffitto. La RLS garantisce che il database rifiuti di restituire i dati di un tenant anche se l'API ha un bug. Il gateway garantisce che la maggior parte dei bug non raggiunga il database. Cintura e bretelle; l'ingaggio spedisce entrambi.
E i background job e le code?
Parte dello scope quando l'MVP ne ha. La maggior parte dei SaaS in fase iniziale usa cron Vercel o una Supabase Edge Function per i job, e di entrambi facciamo audit e hardening. Carichi più pesanti passano a Trigger.dev, Inngest o un BullMQ self-hosted a seconda dei vincoli. Non introduciamo una coda che l'MVP non aveva bisogno; rendiamo affidabile quella che c'è.
Sostituite il nostro backend engineer?
No. Lavoriamo accanto. Il runbook e il codice sono scritti perché l'engineer esistente li gestisca dopo che ce ne andiamo. L'ingaggio chiude con un handover di mezza giornata e un invito calendario per una call di follow-up a 30 giorni. La maggior parte dei team ci serve una volta; chi ci serve due è chi ha saltato il runbook.
Quanto costa se aspettiamo?
L'ingaggio di scale-up più economico parte prima del primo incidente pagato. Il successivo più economico parte dopo il primo incidente. Il più caro parte dopo il primo cliente enterprise che ha cancellato per un outage. La matematica sta nel runbook; la condividiamo nella call di scoping.
Definisci lo scope dello scale-up del tuo SaaS
Una call di scoping, un audit delle sei superfici in settimana uno, uno scope fisso e un numero che teniamo. Otto-dodici settimane dal kickoff a un SaaS che sopravvive al lunedì mattina alle 9.