Relaciones entre Tablas en Supabase
Las bases de datos relacionales se llaman así porque te permiten relacionar tablas entre sí. En vez de duplicar información, almacenas cada cosa una sola vez y las conectas con referencias.
Por ejemplo, en vez de repetir el nombre de la categoría en cada producto, creas una tabla categorías y cada producto apunta a su categoría con una referencia. Si cambias el nombre de la categoría, se actualiza en todos los productos automáticamente.
Foreign keys
Una foreign key (clave foránea) es una columna que apunta al ID de otra tabla. Es el mecanismo que PostgreSQL usa para establecer relaciones.
CREATE TABLE categorias (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
nombre text UNIQUE NOT NULL,
slug text UNIQUE NOT NULL,
descripcion text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE productos (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
nombre text NOT NULL,
precio numeric(10, 2) NOT NULL,
categoria_id uuid NOT NULL REFERENCES categorias(id),
created_at timestamptz NOT NULL DEFAULT now()
);La línea categoria_id uuid NOT NULL REFERENCES categorias(id) hace tres cosas:
- Crea una columna
categoria_idde tipo UUID - Establece que debe apuntar a un
idválido de la tablacategorias - PostgreSQL rechaza cualquier INSERT o UPDATE que intente usar un
categoria_idque no exista
-- Esto funciona: la categoria existe
INSERT INTO categorias (id, nombre, slug)
VALUES ('11111111-1111-1111-1111-111111111111', 'Electronica', 'electronica');
INSERT INTO productos (nombre, precio, categoria_id)
VALUES ('Monitor 4K', 8500, '11111111-1111-1111-1111-111111111111');
-- Esto falla: la categoria no existe
INSERT INTO productos (nombre, precio, categoria_id)
VALUES ('Monitor 4K', 8500, '99999999-9999-9999-9999-999999999999');
-- ERROR: insert or update on table "productos" violates foreign key constraintRelaciones uno a muchos
La relación uno a muchos (one-to-many) es la más común. Una categoría tiene muchos productos. Un autor tiene muchos posts. Un usuario tiene muchos pedidos.
Ejemplo: blog con posts y autores
CREATE TABLE autores (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
nombre text NOT NULL,
email text UNIQUE NOT NULL,
avatar_url text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE posts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
titulo text NOT NULL,
slug text UNIQUE NOT NULL,
contenido text NOT NULL,
publicado bool NOT NULL DEFAULT false,
autor_id uuid NOT NULL REFERENCES autores(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);Un autor puede tener muchos posts, pero cada post tiene un solo autor. La foreign key autor_id vive en la tabla del lado "muchos" (posts).
Consultar con SQL (JOIN)
Para obtener posts con los datos de su autor, usas JOIN:
-- INNER JOIN: solo posts que tengan autor
SELECT
posts.titulo,
posts.slug,
posts.created_at,
autores.nombre AS autor_nombre,
autores.avatar_url AS autor_avatar
FROM posts
INNER JOIN autores ON posts.autor_id = autores.id
WHERE posts.publicado = true
ORDER BY posts.created_at DESC;
-- LEFT JOIN: todos los posts, aunque no tengan autor
SELECT
posts.titulo,
autores.nombre AS autor_nombre
FROM posts
LEFT JOIN autores ON posts.autor_id = autores.id;INNER JOIN solo devuelve filas donde la relación existe en ambas tablas. LEFT JOIN devuelve todas las filas de la tabla izquierda (posts), con NULL en las columnas de la tabla derecha (autores) si no hay match.
Consultar con el SDK
El SDK de Supabase tiene una sintaxis para queries anidados (nested queries) que reemplaza los JOINs de SQL. Es más legible y se integra con el type system de TypeScript.
// Obtener posts con datos del autor
const { data: posts, error } = await supabase
.from('posts')
.select(`
id,
titulo,
slug,
created_at,
autor:autores (
nombre,
avatar_url
)
`)
.eq('publicado', true)
.order('created_at', { ascending: false })
// Resultado:
// [
// {
// id: "abc-123",
// titulo: "Mi primer post",
// slug: "mi-primer-post",
// created_at: "2026-03-01T10:00:00Z",
// autor: {
// nombre: "Ana Lopez",
// avatar_url: "https://..."
// }
// }
// ]La sintaxis autor:autores (nombre, avatar_url) le dice al SDK: "trae los datos de la tabla autores relacionada y nómbralos como autor". Supabase detecta la relación automáticamente a través de la foreign key.
// Consulta inversa: obtener un autor con todos sus posts
const { data: autor, error } = await supabase
.from('autores')
.select(`
id,
nombre,
email,
posts (
id,
titulo,
slug,
publicado
)
`)
.eq('id', autorId)
.single()
// Resultado:
// {
// id: "xyz-789",
// nombre: "Ana Lopez",
// email: "ana@ejemplo.com",
// posts: [
// { id: "abc-123", titulo: "Mi primer post", slug: "mi-primer-post", publicado: true },
// { id: "def-456", titulo: "Segundo post", slug: "segundo-post", publicado: false }
// ]
// }Las relaciones se detectan automáticamente
No necesitas configurar nada extra para que el SDK detecte las relaciones. Si tus tablas tienen foreign keys definidas, Supabase las reconoce y te permite hacer queries anidados directamente.
Relaciones muchos a muchos
Una relación muchos a muchos (many-to-many) ocurre cuando un registro de una tabla puede estar asociado a múltiples registros de otra tabla, y viceversa. Ejemplo clásico: posts y tags. Un post puede tener muchos tags, y un tag puede pertenecer a muchos posts.
PostgreSQL no soporta relaciones muchos a muchos directamente. Se resuelven con una tabla intermedia (junction table o tabla pivote) que conecta ambas tablas.
Ejemplo: posts con categorías y tags
-- Tabla de categorias
CREATE TABLE categorias (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
nombre text UNIQUE NOT NULL,
slug text UNIQUE NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Tabla de tags
CREATE TABLE tags (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
nombre text UNIQUE NOT NULL,
slug text UNIQUE NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Tabla de posts (uno a muchos con categorias)
CREATE TABLE posts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
titulo text NOT NULL,
slug text UNIQUE NOT NULL,
contenido text NOT NULL,
publicado bool NOT NULL DEFAULT false,
categoria_id uuid NOT NULL REFERENCES categorias(id),
autor_id uuid NOT NULL REFERENCES autores(id),
created_at timestamptz NOT NULL DEFAULT now()
);
-- Tabla intermedia: posts <-> tags (muchos a muchos)
CREATE TABLE posts_tags (
post_id uuid NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);La tabla posts_tags solo tiene dos columnas: post_id y tag_id. Su primary key es la combinación de ambas, lo que impide que se duplique la misma relación.
ON DELETE CASCADE significa que si eliminas un post, automáticamente se eliminan sus registros en posts_tags. Lo mismo si eliminas un tag.
Insertar datos relacionados
-- Crear categorias
INSERT INTO categorias (id, nombre, slug) VALUES
('cat-1', 'Desarrollo Web', 'desarrollo-web'),
('cat-2', 'DevOps', 'devops');
-- Crear tags
INSERT INTO tags (id, nombre, slug) VALUES
('tag-1', 'JavaScript', 'javascript'),
('tag-2', 'TypeScript', 'typescript'),
('tag-3', 'PostgreSQL', 'postgresql'),
('tag-4', 'Docker', 'docker');
-- Crear un post
INSERT INTO posts (id, titulo, slug, contenido, publicado, categoria_id, autor_id)
VALUES (
'post-1',
'Como usar Supabase con NextJS',
'supabase-con-nextjs',
'Contenido del post...',
true,
'cat-1',
'autor-1'
);
-- Asignar tags al post
INSERT INTO posts_tags (post_id, tag_id) VALUES
('post-1', 'tag-1'),
('post-1', 'tag-2'),
('post-1', 'tag-3');Consultar con SQL (JOINs múltiples)
-- Obtener posts con su categoria y sus tags
SELECT
posts.titulo,
categorias.nombre AS categoria,
array_agg(tags.nombre) AS tags
FROM posts
INNER JOIN categorias ON posts.categoria_id = categorias.id
LEFT JOIN posts_tags ON posts.id = posts_tags.post_id
LEFT JOIN tags ON posts_tags.tag_id = tags.id
WHERE posts.publicado = true
GROUP BY posts.id, posts.titulo, categorias.nombre
ORDER BY posts.created_at DESC;
-- Resultado:
-- titulo | categoria | tags
-- Como usar Supabase con NextJS | Desarrollo Web | {JavaScript,TypeScript,PostgreSQL}array_agg agrupa todos los nombres de tags en un array. GROUP BY es necesario porque estamos combinando múltiples filas (tags) en una sola fila por post.
Consultar con el SDK
// Posts con categoria, autor y tags
const { data: posts, error } = await supabase
.from('posts')
.select(`
id,
titulo,
slug,
created_at,
categoria:categorias (
nombre,
slug
),
autor:autores (
nombre,
avatar_url
),
posts_tags (
tag:tags (
nombre,
slug
)
)
`)
.eq('publicado', true)
.order('created_at', { ascending: false })
// Resultado:
// [
// {
// id: "post-1",
// titulo: "Como usar Supabase con NextJS",
// slug: "supabase-con-nextjs",
// created_at: "2026-03-10T10:00:00Z",
// categoria: { nombre: "Desarrollo Web", slug: "desarrollo-web" },
// autor: { nombre: "Ana Lopez", avatar_url: "https://..." },
// posts_tags: [
// { tag: { nombre: "JavaScript", slug: "javascript" } },
// { tag: { nombre: "TypeScript", slug: "typescript" } },
// { tag: { nombre: "PostgreSQL", slug: "postgresql" } }
// ]
// }
// ]Para aplanar los tags y que sea más fácil de trabajar en tu aplicación:
const postsFormateados = posts?.map(post => ({
...post,
tags: post.posts_tags.map(pt => pt.tag),
posts_tags: undefined
}))
// Resultado limpio:
// {
// titulo: "Como usar Supabase con NextJS",
// categoria: { nombre: "Desarrollo Web" },
// tags: [
// { nombre: "JavaScript", slug: "javascript" },
// { nombre: "TypeScript", slug: "typescript" }
// ]
// }Buscar posts por tag
// Encontrar todos los posts que tengan el tag "typescript"
const { data: postsConTS, error } = await supabase
.from('posts')
.select(`
id,
titulo,
slug,
posts_tags!inner (
tag:tags!inner (
nombre,
slug
)
)
`)
.eq('posts_tags.tags.slug', 'typescript')
.eq('publicado', true)El modificador !inner convierte el LEFT JOIN implícito en un INNER JOIN, lo que filtra los posts que no tienen match con el tag buscado.
ON DELETE: qué pasa al eliminar
Cuando defines una foreign key, puedes especificar qué sucede si el registro referenciado se elimina.
-- CASCADE: elimina los registros dependientes automáticamente
autor_id uuid REFERENCES autores(id) ON DELETE CASCADE,
-- SET NULL: pone NULL en la columna (la columna debe ser nullable)
autor_id uuid REFERENCES autores(id) ON DELETE SET NULL,
-- RESTRICT: impide eliminar si hay registros que dependen de el (default)
autor_id uuid NOT NULL REFERENCES autores(id) ON DELETE RESTRICT,| Comportamiento | Qué hace | Cuándo usarlo |
|---|---|---|
| CASCADE | Elimina hijos automáticamente | Tabla intermedia de many-to-many, datos que no tienen sentido sin el padre |
| SET NULL | Pone NULL en la referencia | Cuando el hijo puede existir sin padre (post sin autor) |
| RESTRICT | Impide la eliminación | Cuando necesitas proteger la integridad (no puedes eliminar una categoría si tiene productos) |
Recomendación práctica
Usa CASCADE en tablas intermedias (como posts_tags). Usa RESTRICT en relaciones donde eliminar el padre sería peligroso (como eliminar un usuario que tiene pedidos activos). Usa SET NULL cuando el hijo puede seguir existiendo sin la referencia.
Relaciones uno a uno
Una relación uno a uno (one-to-one) es cuando un registro de una tabla tiene exactamente un registro correspondiente en otra tabla.
CREATE TABLE usuarios (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE perfiles (
id uuid PRIMARY KEY REFERENCES usuarios(id) ON DELETE CASCADE,
nombre_completo text NOT NULL,
bio text,
avatar_url text,
sitio_web text,
updated_at timestamptz NOT NULL DEFAULT now()
);En este caso, la primary key de perfiles es también la foreign key hacia usuarios. Esto garantiza que cada usuario tenga como máximo un perfil.
// Obtener usuario con su perfil
const { data: usuario, error } = await supabase
.from('usuarios')
.select(`
id,
email,
perfil:perfiles (
nombre_completo,
bio,
avatar_url
)
`)
.eq('id', userId)
.single()Diseño de relaciones: checklist
Antes de crear tus tablas, hazte estas preguntas:
- Un A puede tener muchos B? -- Relación uno a muchos (foreign key en B)
- Un B puede tener muchos A? -- Si ambas son sí, es muchos a muchos (tabla intermedia)
- Un A tiene exactamente un B? -- Relación uno a uno (primary key compartida)
- Qué pasa si elimino A? -- Define ON DELETE (CASCADE, SET NULL o RESTRICT)
- La relación es obligatoria? -- NOT NULL en la foreign key si es obligatoria
Siguiente paso
Ahora que sabes cómo estructurar y relacionar tus tablas, el siguiente paso es aprender a consultarlas de forma eficiente usando el SDK de Supabase -- con filtros, paginación, ordenamiento y manejo de errores.