Skip to main content
← Back to blog

Cómo construimos la base de datos de un blog AI-first en una tarde

Supabase, Postgres, RLS y multi-tenant por subdominio. El esquema entero cabe en cinco migraciones — y aquí están las decisiones que importan.

El primer commit de Agentikas Blog no fue una página, ni un componente, ni un endpoint. Fue una migración SQL.

Si vas a construir una plataforma multi-tenant donde cualquiera puede crear su blog, y donde cada blog tiene que ser invocable por agentes de IA desde el primer post, la base de datos no es un detalle de implementación — es la decisión que define el resto. Una tarde de tres horas con Postgres, Supabase y un editor abierto.

El modelo: blogs y posts, con un giro

El modelo central es trivial:

create table blogs (
  id        uuid primary key default gen_random_uuid(),
  owner_id  uuid not null references auth.users (id) on delete cascade,
  slug      text not null unique,
  name      text not null,
  brand_config jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now()
);

create table blog_posts (
  id          uuid primary key default gen_random_uuid(),
  blog_id     uuid not null references blogs (id) on delete cascade,
  slug        text not null,
  title       text not null,
  body        text not null default '',
  status      post_status not null default 'draft',
  published_at timestamptz,
  unique (blog_id, slug)
);

El "giro" es el slug del blog. No es un campo cosmético. Es el identificador de tenant. Cuando alguien visita maria.blog.agentikas.ai/post-uno, el middleware lo único que hace es:

  1. Extraer maria del Host header
  2. Buscar blogs donde slug = 'maria'
  3. Pasar el blog_id a las queries del resto del request

No hay tabla de tenants separada, ni middleware de autenticación pesado, ni servicio dedicado. Postgres y Supabase resuelven el aislamiento.

Full-text search en una columna generada

Una decisión que parece menor pero ahorra muchas horas: la búsqueda full-text como columna generada y almacenada, no calculada en cada query.

alter table blog_posts add column fts tsvector
  generated always as (
    setweight(to_tsvector('english', coalesce(title, '')),    'A') ||
    setweight(to_tsvector('english', coalesce(subtitle, '')), 'B') ||
    setweight(to_tsvector('english', coalesce(body, '')),     'C')
  ) stored;

create index blog_posts_fts_idx on blog_posts using gin (fts);

El setweight hace que un match en el título pese más que un match en el cuerpo. Es la diferencia entre "search funciona regular" y "search devuelve lo que esperas". Y como la columna es stored, Postgres la mantiene actualizada automáticamente con cada UPDATE — cero código de aplicación.

RLS: la seguridad vive en la base de datos, no en los handlers

Durante los primeros sprints todas las API routes usaban el service role key de Supabase. Cómodo: un solo cliente, escribe a cualquier tabla, ignora RLS. La validación de "¿puede este usuario tocar este post?" la hacíamos a mano en cada handler.

Hasta que en al menos dos rutas se nos olvidó la validación. No explotó — nadie editó el post equivocado. Pero podrían haberlo hecho. La seguridad vivía "en la cabeza del backend dev", no en la base de datos.

La regla nueva, después del refactor:

  • User client + RLS en todo endpoint que actúa en nombre de un usuario autenticado. La DB decide.
  • Service client solo para trabajos de background (cron, webhooks) y operaciones cross-tenant legítimas (el mirror público que lista posts de todos los blogs).

Las policies se ven así:

create policy "anyone reads published posts"
  on blog_posts for select
  using (status = 'published');

create policy "owners do anything with their posts"
  on blog_posts for all
  using (
    exists (
      select 1 from blogs
      where blogs.id = blog_posts.blog_id
        and blogs.owner_id = auth.uid()
    )
  );

Si tu seguridad vive en if statements, muévela a la DB. El código muta; las policies se auditan.

i18n nativa el día uno

Cada post tiene locale y canonical_id. La traducción no es un campo extra en el mismo registro — es una fila propia con su slug, su body, su meta description en el idioma destino. Las hermanas de la traducción comparten canonical_id apuntando a la fila raíz.

alter table blog_posts
  add column locale       text not null default 'es',
  add column canonical_id uuid references blog_posts(id);

drop index if exists blog_posts_blog_id_slug_key;
create unique index blog_posts_blog_locale_slug_idx
  on blog_posts (blog_id, locale, slug);

Esto permite que /es/blog/webmcp-protocolo y /en/blog/webmcp-protocol coexistan en el mismo blog sin colisionar. Y que un agente que pide el post en en reciba en — no un fallback silencioso a otro idioma, que es lo que hace que las respuestas de la IA queden chapuzas.

i18n añadida en el mes 3 te come dos semanas de migraciones. Puesta en el schema el día uno, no cuesta nada.

El resto vino solo

El resto del esquema cayó por inercia: post_likes, post_comments, blog_follows, post_translations, contributions. Cada uno una migración corta, cada uno con sus policies, cada uno generado tras tener un caso de uso real — nunca antes.

Hoy son 19 migraciones acumuladas, ninguna de más de 80 líneas. Y todas viven en supabase/migrations/, versionadas en git, ejecutables localmente con la CLI de Supabase.

La lección de fondo no es sobre Postgres. Es sobre el orden de las cosas: esquema primero, código después. Cuando el esquema es correcto — RLS estricta, i18n nativa, full-text generated — el resto del backend es un thin layer encima. Cuando el esquema es regular, te pasas seis meses parchando.


Agentikas es open source. El esquema completo está en github.com/agentikas/agentikas-blog/tree/main/supabase/migrations. La política de RLS, la columna fts y el modelo de traducciones son tres ficheros — léelos, fórkalos, mejóralos si ves cómo.

Comentarios

Cargando comentarios…