A SaaS scale-up is six audits and six fixes in a fixed order. The order matters because the surfaces compound: rate limiting on top of broken RLS still leaks data; caching on top of broken billing still loses money; observability on top of broken caching still tells you nothing useful. The work is mechanical once the order is set; the value is in the order.
The five files below are the scaffolding the engagement leaves behind. The tenant-isolation test, the Stripe webhook handler, the rate-limit middleware, the cache wrapper, and the observability bootstrap. Each file is small. Each file is the one place the team edits when the system has to change.
1. The tenant-isolation test
The test signs in as user A, attempts to read user B's resources across every API route, and fails the build on any non-empty response. We seed two users at the start of the test and tear them down at the end; the test is hermetic. Runs on every 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
// Seed a project for user 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} leaked tenant data`).toEqual([])
}
await supabase.auth.admin.deleteUser(userA.data.user!.id)
await supabase.auth.admin.deleteUser(userB.data.user!.id)
})
2. The Stripe webhook handler
The handler is one route, switches on event type, writes every event to an events table with the Stripe event ID as the idempotency key. A replayed webhook is a no-op. A handler that throws gets retried by Stripe; the events table records the retry. We never modify customer state without an event row.
// 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()
// Idempotency: insert returns conflict if event was processed already.
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. The rate-limit middleware
A sliding-window rate limiter on Upstash Redis, with per-IP and per-user buckets. The middleware runs on every public route. The limits live in a config file; raising one is a code review, not a redis-cli session.
// 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. The cache wrapper with stale-while-revalidate
A typed helper that wraps every cacheable read. Returns cached data immediately when fresh, returns stale data and refreshes in the background when within the SWR window, and falls through to the loader when both windows have passed. Every cache hit and miss is counted; the metric goes to the observability stack.
// 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 // seconds
staleWhileRevalidate?: number // additional seconds where stale data is served while background refresh runs
}
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, refresh in background; do not await.
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. The observability bootstrap
One file wires structured logging, error reporting, and metrics. The runtime imports it once at the top of instrumentation.ts. The team gets the four golden signals on day one; the rest grows from the same primitives.
// 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) {
// Strip PII from breadcrumbs before reporting.
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. What this composes
The tenant test proves the data does not leak. The webhook handler proves the billing does not lose state. The rate limit proves the public surface does not melt under abuse. The cache proves the database does not get hit on every page view. The bootstrap proves the team knows what is happening when something goes wrong.
The MVP is no longer an MVP. The codebase is boring in the specific way a SaaS that scales is boring: the work happens in the right files, the metrics tell the truth, the on-call person reads a runbook instead of paging the founder, and the next time the press picks the product up, the site stays up.