La arquitectura SaaS multi-tenant se apoya en dos patrones que el código aplicativo se lleva consigo: el contexto del tenant activo y el flujo de invitación. Los dos archivos de abajo son los que enviamos; junto con las policies de row-level security de la página stack de Supabase, cubren la superficie multi-tenant que la aplicación toca de verdad.
1. La cookie del tenant activo y el middleware
Una cookie firmada lleva el ID del tenant activo. El middleware la lee en cada petición, valida que el usuario sea miembro de ese tenant y expone x-tenant-id como header que el código aplicativo (y el client Supabase) leen.
// src/middleware.ts (extracto)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyTenantCookie } from '@/lib/auth/active-tenant'
export async function middleware(request: NextRequest) {
const session = request.cookies.get('session')?.value
const tenantCookie = request.cookies.get('active_tenant')?.value
if (!session) return NextResponse.next()
const activeTenantId = await verifyTenantCookie({
session,
tenantCookie,
})
const requestHeaders = new Headers(request.headers)
if (activeTenantId) {
requestHeaders.set('x-tenant-id', activeTenantId)
}
return NextResponse.next({ request: { headers: requestHeaders } })
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
// src/lib/auth/active-tenant.ts
import { jwtVerify, SignJWT } from 'jose'
import { adminClient } from '@/lib/supabase/admin'
const TENANT_COOKIE_SECRET = new TextEncoder().encode(
process.env.TENANT_COOKIE_SECRET!,
)
export async function verifyTenantCookie(opts: {
session: string
tenantCookie: string | undefined
}): Promise<string | null> {
if (!opts.tenantCookie) return null
// Verifica que la cookie esté firmada por nosotros y lleve un vínculo
// user-tenant.
let payload
try {
const verified = await jwtVerify(opts.tenantCookie, TENANT_COOKIE_SECRET)
payload = verified.payload as { userId: string; tenantId: string }
} catch {
return null
}
// Verifica que el usuario siga siendo miembro del tenant.
const { data } = await adminClient
.from('members')
.select('tenant_id')
.eq('user_id', payload.userId)
.eq('tenant_id', payload.tenantId)
.eq('status', 'active')
.maybeSingle()
return data?.tenant_id ?? null
}
export async function signTenantCookie(opts: {
userId: string
tenantId: string
}): Promise<string> {
return new SignJWT({ userId: opts.userId, tenantId: opts.tenantId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('30d')
.sign(TENANT_COOKIE_SECRET)
}
2. La Server Action para cambiar tenant
Una Server Action valida la membership y escribe la cookie firmada. El dropdown de la UI la llama cuando el usuario elige otro tenant; la siguiente petición opera dentro del nuevo contexto de tenant.
// app/[lang]/(app)/actions/switch-tenant.ts
'use server'
import { z } from 'zod'
import { cookies } from 'next/headers'
import { revalidatePath } from 'next/cache'
import { adminClient } from '@/lib/supabase/admin'
import { signTenantCookie } from '@/lib/auth/active-tenant'
import { getServerSession } from '@/lib/auth/server'
const Input = z.object({
tenantId: z.string().uuid(),
})
export async function switchActiveTenant(
raw: unknown,
): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
// Verifica que el usuario sea miembro del tenant destino.
const { data: membership } = await adminClient
.from('members')
.select('tenant_id')
.eq('user_id', session.userId)
.eq('tenant_id', parsed.data.tenantId)
.eq('status', 'active')
.maybeSingle()
if (!membership) return { ok: false, error: 'not a member' }
const cookie = await signTenantCookie({
userId: session.userId,
tenantId: parsed.data.tenantId,
})
const cookieStore = await cookies()
cookieStore.set('active_tenant', cookie, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
})
revalidatePath('/', 'layout')
return { ok: true }
}
3. El flujo de invitación
Invitar a un miembro es una Server Action: crea una fila pendiente, manda el email, devuelve success. El token de invitación es el ID de la fila pendiente, firmado con TTL corto.
// app/[lang]/(app)/team/invite/actions.ts
'use server'
import { z } from 'zod'
import { SignJWT } from 'jose'
import { Resend } from 'resend'
import { revalidatePath } from 'next/cache'
import { adminClient } from '@/lib/supabase/admin'
import { getServerSession } from '@/lib/auth/server'
import { getActiveTenantId } from '@/lib/auth/server'
const Input = z.object({
email: z.string().email(),
role: z.enum(['owner', 'admin', 'member', 'billing']),
})
const INVITE_SECRET = new TextEncoder().encode(process.env.INVITE_SECRET!)
const resend = new Resend(process.env.RESEND_API_KEY!)
export async function inviteMember(
raw: unknown,
): Promise<{ ok: true; inviteId: string } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const tenantId = await getActiveTenantId()
if (!tenantId) return { ok: false, error: 'no active tenant' }
// Solo owners y admins pueden invitar.
const { data: actor } = await adminClient
.from('members')
.select('role')
.eq('user_id', session.userId)
.eq('tenant_id', tenantId)
.single()
if (!actor || !['owner', 'admin'].includes(actor.role)) {
return { ok: false, error: 'permission denied' }
}
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
// Crea la fila pending member.
const { data: invite, error: insertError } = await adminClient
.from('members')
.insert({
tenant_id: tenantId,
email: parsed.data.email,
role: parsed.data.role,
status: 'pending',
invited_by: session.userId,
invited_at: new Date().toISOString(),
})
.select('id')
.single()
if (insertError) return { ok: false, error: insertError.message }
// Firma un token de corta duración que referencia el invite ID.
const token = await new SignJWT({ inviteId: invite.id })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(INVITE_SECRET)
// Registra el evento de auditoría.
await adminClient.from('audit_events').insert({
tenant_id: tenantId,
actor_user_id: session.userId,
action: 'member.invited',
resource_id: invite.id,
metadata: { email: parsed.data.email, role: parsed.data.role },
})
// Manda el email.
await resend.emails.send({
from: 'team@adamarant.com',
to: parsed.data.email,
subject: 'You are invited',
html: renderInviteEmail({
acceptUrl: `${process.env.SITE_URL}/invite/accept?token=${token}`,
}),
})
revalidatePath('/team')
return { ok: true, inviteId: invite.id }
}
function renderInviteEmail(opts: { acceptUrl: string }): string {
return `<p>You have been invited to join a team.</p>
<p><a href="${opts.acceptUrl}">Accept invitation</a></p>
<p>The link expires in seven days.</p>`
}
4. El handler de aceptación de la invitación
Cuando el usuario invitado clica el enlace del email, el handler verifica el token, vincula al usuario existente (o pide el signup), cambia la fila pendiente a active y escribe el evento de auditoría.
// app/[lang]/(public)/invite/accept/actions.ts
'use server'
import { jwtVerify } from 'jose'
import { redirect } from 'next/navigation'
import { adminClient } from '@/lib/supabase/admin'
import { getServerSession } from '@/lib/auth/server'
const INVITE_SECRET = new TextEncoder().encode(process.env.INVITE_SECRET!)
export async function acceptInvite(token: string): Promise<void> {
let payload
try {
const verified = await jwtVerify(token, INVITE_SECRET)
payload = verified.payload as { inviteId: string }
} catch {
redirect('/invite/expired')
}
const session = await getServerSession()
if (!session) {
redirect(`/login?return=/invite/accept?token=${token}`)
}
// Cambia la fila pending member a active, vinculando el user ID.
const { data: invite, error } = await adminClient
.from('members')
.update({
user_id: session.userId,
status: 'active',
joined_at: new Date().toISOString(),
})
.eq('id', payload.inviteId)
.eq('status', 'pending')
.select('tenant_id, role')
.single()
if (error || !invite) redirect('/invite/expired')
await adminClient.from('audit_events').insert({
tenant_id: invite.tenant_id,
actor_user_id: session.userId,
action: 'member.joined',
resource_id: payload.inviteId,
metadata: { role: invite.role },
})
redirect('/dashboard')
}
5. Cómo encaja todo esto
La cookie del tenant activo acota cada petición. El middleware la expone como header. El client Supabase lee el header y acota sus queries. El flujo de invitación usa la misma tabla members en la que se apoyan las policies RLS, así que un nuevo miembro que acepta la invitación queda inmediatamente autorizado a leer los datos del tenant sin ningún update en el código aplicativo.
La arquitectura tiene tres componentes: el join de membership, las policies de row-level security, el contexto del tenant activo. Una vez que esos tres viven en source control, el código aplicativo deja de preguntarse "¿este usuario está autorizado a ver esta fila?" y empieza a preguntarse "¿cuál es la consulta más útil que puedo escribir hoy?". Y esa es la ganancia de productividad que se obtiene poniendo la autorización dentro de la base de datos.