Construimos sobre Supabase porque el modelo de seguridad es real
Postgres con row-level security, Auth que entra en producción en una semana, Storage con signed URLs, Realtime cuando aporta. Sin lock-in, porque la base de datos es simplemente Postgres.
Por qué este stack
Row-level security como capa de autorización real
La mayoría de las apps pone la autorización en el código de la aplicación y luego escribe un documento aparte explicando por qué el cliente A no puede ver los datos del cliente B. Con RLS la respuesta es una policy SQL que falla en cerrado, corre en cada consulta, y de esa policy puedes meter un pantallazo en el security review.
Postgres, no una base de datos propietaria
El día que el proyecto sale de Supabase, la base de datos sale con el proyecto. Lo hemos hecho. Sin lock-in sorpresa, sin lenguaje de consulta propietario, sin precio por fila que se rompe a escala. Solo Postgres.
Auth sin un servicio aparte
Social providers, magic links, OTP, MFA, SSO, cookies en servidor, todo en el mismo proyecto que los datos. Sin webhook desde Auth0 a tu base de datos para mantener alineadas dos tablas de usuarios.
Type safety generada desde el schema vivo
Generamos los tipos TypeScript desde el schema desplegado. Una columna renombrada rompe la build antes de llegar a producción, no el informe de un cliente que paga.
Storage, Realtime, Edge Functions en el mismo sitio
Un solo proyecto gestiona adjuntos con signed URLs, actualizaciones en tiempo real por WebSocket e ingesta de webhooks en edge. Tres servicios separados colapsan en uno y el equipo aprende un solo modelo mental.
Qué construimos con esta tecnología
Policies de row-level security, auditadas y versionadas
Las policies viven en migrations en source control, no en la UI de la base de datos. Cada cambio pasa por review y tiene un test automático.
Modelo de datos multi-tenant
Tabla members con enum de rol (owner / admin / member / billing), policies que acotan cada lectura y escritura al tenant activo.
Auth con social providers y MFA
Google, GitHub, Apple, magic links, SSO vía SAML cuando el buyer lo pide. Enrolment, recovery codes, gestión de dispositivos.
Storage con signed URLs y policies de bucket
Buckets por tenant, signed URLs que expiran, transformaciones de imagen en edge, RLS sobre los objetos de storage.
Suscripciones Realtime donde aportan
Actualizaciones live por WebSocket para inboxes, dashboards, indicadores de presencia. Filtradas por RLS, así un client solo ve las filas que puede leer.
Edge Functions para ingesta de webhooks
Stripe, Resend, webhooks de terceros aterrizan en edge, validan firma y escriben vía service-role client.
Migrations y seeds versionadas
Migrations en source control con up/down, scripts de seed que sobreviven al reset de la base, CI que las corre en cada PR.
Pipeline de tipos TypeScript generados
Un script npm regenera los tipos de la base de datos después de cada migration. La build falla si los tipos están desfasados.
Backup, point-in-time recovery, prueba de restore
PITR activo, cadencia de dump off-site, una prueba de restore documentada que puedes correr antes del día en que la necesitas.
Blueprint para self-hosting
Cuando el entorno de procurement exige on-premise, entregamos un docker-compose que refleja el stack cloud y un runbook que el siguiente equipo puede operar.
Performance: índices, planes, presupuestos de query
Cada query caliente tiene un índice, un plan medido y un presupuesto. El log de queries lentas va al dashboard, no a un inbox que nadie abre.
Migración desde Firebase, PlanetScale, Postgres a pelo
Datos existentes movidos, foreign keys reconstruidas, usuarios auth llevados con migración de hashed password donde se permite.
Autorización multi-tenant con row-level security, en código
La tabla members es la única fuente de verdad sobre quién pertenece a qué tenant. Todas las demás tablas delegan a ella vía RLS. Un usuario que cambia de tenant cambia el resultado de la policy sin un deploy de código.
La mayoría de los tutoriales "SaaS multi-tenant sobre Supabase" te enseñan a añadir una columna tenant_id y a fingir que eso resuelve la autorización. No la resuelve. El patrón de abajo es el que pasamos a producción: una tabla de join members que tiene la relación tenant-usuario, y policies de row-level security que delegan cada chequeo de autorización en ella.
1. La forma de las tablas
Un usuario pertenece a varios tenants a través de una fila en members. Cada fila de members lleva el rol. Las demás tablas que necesitan acotación por tenant tienen un tenant_id y confían en que la policy lo aplique.
-- supabase/migrations/0001_tenants.sql
create table tenants (
id uuid primary key default gen_random_uuid(),
name text not null,
stripe_customer_id text unique,
created_at timestamptz not null default now()
);
create type member_role as enum ('owner', 'admin', 'member', 'billing');
create table members (
tenant_id uuid not null references tenants(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role member_role not null,
joined_at timestamptz not null default now(),
primary key (tenant_id, user_id)
);
create index members_user_idx on members(user_id);
create table tickets (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references tenants(id) on delete cascade,
title text not null,
body text,
status text not null default 'open',
assignee_id uuid references auth.users(id),
created_at timestamptz not null default now()
);
create index tickets_tenant_idx on tickets(tenant_id, status, created_at desc);
2. Las policies de row-level security
Las policies activan RLS sobre la tabla y definen qué se puede leer, escribir y actualizar. Cada policy delega en una subquery contra members, así una sola fuente de verdad controla cada decisión.
-- supabase/migrations/0002_tickets_rls.sql
alter table tickets enable row level security;
create policy "tickets: tenant members can read"
on tickets for select
using (
tenant_id in (select tenant_id from members where user_id = auth.uid())
);
create policy "tickets: tenant members can insert"
on tickets for insert
with check (
tenant_id in (select tenant_id from members where user_id = auth.uid())
);
create policy "tickets: admins can update"
on tickets for update
using (
tenant_id in (
select tenant_id from members
where user_id = auth.uid() and role in ('owner', 'admin')
)
);
create policy "tickets: only owners can delete"
on tickets for delete
using (
tenant_id in (
select tenant_id from members
where user_id = auth.uid() and role = 'owner'
)
);
3. El Server Component de Next.js que lee los datos
El código de la aplicación deja de cargar con la autorización. El client (aquí un Server Component de Next.js) lee tickets como si el usuario pudiera verlo todo; la verdad la dice la base de datos.
// app/[lang]/(app)/tickets/page.tsx
import { createServerClient } from '@/lib/supabase/server'
export default async function TicketsPage() {
const supabase = await createServerClient()
const { data: tickets, error } = await supabase
.from('tickets')
.select('id, title, status, created_at')
.eq('status', 'open')
.order('created_at', { ascending: false })
if (error) throw error
if (!tickets) return null
return (
<ul>
{tickets.map((t) => (
<li key={t.id}>
<a href={`/tickets/${t.id}`}>{t.title}</a>
</li>
))}
</ul>
)
}
4. Los tipos generados
Corremos supabase gen types typescript --linked > src/types/db.ts después de cada migration. El archivo generado queda committeado; la CI falla si va en drift respecto al schema desplegado. La fila de tickets se convierte en un tipo Tables<'tickets'> que la aplicación usa en todas partes.
// src/lib/supabase/server.ts
import { cookies } from 'next/headers'
import { createServerClient as createSupabaseServerClient } from '@supabase/ssr'
import type { Database } from '@/types/db'
export async function createServerClient() {
const cookieStore = await cookies()
return createSupabaseServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (toSet) => {
for (const { name, value, options } of toSet) {
cookieStore.set(name, value, options)
}
},
},
},
)
}
5. Qué te compra esto
Una columna nueva en tickets, un rol nuevo, una policy nueva: cada una es una sola migration SQL que pasa por review. El documento de autorización que te pedirá el siguiente buyer enterprise es un pantallazo del archivo de policies. El siguiente equipo que abra el codebase lee el mismo SQL que leemos nosotros, no una capa de abstracción custom.
La seguridad multi-tenant deja de ser una feature que envías; pasa a ser una propiedad de la base de datos.
Preguntas frecuentes
¿Por qué Supabase en vez de Postgres a pelo sobre RDS?
El Postgres es el mismo. Lo que te ahorras son los meses construyendo Auth, Storage, Realtime y la capa operativa alrededor. Si tienes un equipo DBA interno y un equipo Devex, Postgres a pelo está bien. La mayoría de los equipos no los tiene.
¿Self-hosted o Supabase Cloud?
Cloud por defecto. Self-host cuando el buyer exige on-premise (sector público, finanzas reguladas, ciertos casos de healthcare). El código de la aplicación no cambia entre los dos, solo cambia la connection string.
¿Migráis desde Firebase o PlanetScale?
Sí. Firebase es la petición más común. Usuarios, custom claims, documentos Firestore y objetos Storage mapean todos a Postgres + RLS + buckets de Storage. La migración se planifica como un scoping de pago; el coste depende de la forma de los datos y del modelo de auth.
¿Cómo rinde row-level security a escala?
Una policy bien indexada añade uno o dos milisegundos por query. Una policy mal escrita escanea la tabla entera. Medimos cada policy con `explain analyze` antes de pasarla a producción y añadimos índices que coinciden con los predicados de la policy.
¿Backup y recuperación ante desastre?
Point-in-time recovery a cualquier segundo de los últimos siete días en Cloud, ventanas más largas en planes enterprise. Dumps off-site a un bucket que tú posees. Una prueba de restore documentada que hacemos contigo una vez antes del lanzamiento.
¿Cómo gestionáis los tipos generados cuando cambia el schema?
Un script npm regenera los tipos desde el schema desplegado. La CI lo corre en cada PR; si los tipos generados divergen de los committeados, la build falla. Sin mentiras silenciosas sobre los tipos.
¿Podemos usar Supabase Auth con nuestro proveedor de identidad?
Sí. SAML SSO para buyers enterprise, OIDC para identidad federada, JWT custom para sistemas legacy. Los usuarios existentes migran vía admin API conservando sus IDs originales.
Cuéntanos qué estás construyendo sobre Supabase
Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. Apps RLS-first en tres a seis semanas.