Consultas con el SDK de Supabase
El SDK de Supabase (@supabase/supabase-js) te da una API fluida para hacer operaciones CRUD contra tu base de datos PostgreSQL. En vez de escribir SQL a mano, encadenas métodos que se traducen internamente a queries SQL optimizados.
En esta sección vamos a cubrir todas las operaciones que necesitas para trabajar con datos: lectura, escritura, actualización, eliminación, filtros, ordenamiento y paginación.
Setup del cliente
Antes de hacer cualquier query, necesitas crear una instancia del cliente de Supabase:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)Types generados
Supabase puede generar tipos de TypeScript basados en tu schema de base de datos. Esto te da autocompletado y validación de tipos en cada query. Lo veremos en detalle más adelante, pero por ahora asumimos que trabajamos con tipos definidos manualmente.
Definir tipos manualmente
interface Producto {
id: string
nombre: string
precio: number
stock: number
disponible: boolean
categoria: string
tags: string[]
created_at: string
updated_at: string
}
// Tipo para inserciones (sin campos auto-generados)
type ProductoInsert = Omit<Producto, 'id' | 'created_at' | 'updated_at'>
// Tipo para actualizaciones (todo opcional)
type ProductoUpdate = Partial<ProductoInsert>SELECT: leer datos
.from('tabla').select() es el equivalente a SELECT en SQL.
Seleccionar todas las columnas
const { data, error } = await supabase
.from('productos')
.select('*')
// data: Producto[] | null
// error: PostgrestError | nullSeleccionar columnas específicas
const { data, error } = await supabase
.from('productos')
.select('id, nombre, precio')
// Solo trae id, nombre y precio -- mas eficiente que traer todoSiempre selecciona solo lo que necesitas
Usa select('*') solo cuando realmente necesites todas las columnas. Seleccionar columnas específicas reduce el tamaño de la respuesta y hace tus queries más rápidos, especialmente en tablas con muchas columnas o con columnas de tipo jsonb grandes.
Seleccionar con relaciones
// Producto con su categoria (relacion uno a muchos)
const { data, error } = await supabase
.from('productos')
.select(`
id,
nombre,
precio,
categoria:categorias (
id,
nombre,
slug
)
`)Contar registros sin traer datos
// Solo contar cuantos productos hay
const { count, error } = await supabase
.from('productos')
.select('*', { count: 'exact', head: true })
// count: number | null (ejemplo: 42)
// No trae datos, solo el conteoINSERT: crear datos
.from('tabla').insert() es el equivalente a INSERT INTO en SQL.
Insertar un registro
const { data, error } = await supabase
.from('productos')
.insert({
nombre: 'Monitor 4K 27"',
precio: 8500,
stock: 15,
disponible: true,
categoria: 'monitores',
tags: ['electronica', '4k', 'monitor']
})
.select()
// .select() al final devuelve el registro creado
// Sin .select(), data es null (el INSERT se ejecuta pero no devuelve nada)No olvides .select() después de insert
Por defecto, .insert() no devuelve los datos insertados. Si necesitas el registro creado (por ejemplo, para obtener el id generado), agrega .select() al final de la cadena.
Insertar múltiples registros
const { data, error } = await supabase
.from('productos')
.insert([
{
nombre: 'Teclado Mecanico',
precio: 2200,
stock: 30,
disponible: true,
categoria: 'perifericos',
tags: ['electronica', 'teclado']
},
{
nombre: 'Mouse Inalambrico',
precio: 950,
stock: 50,
disponible: true,
categoria: 'perifericos',
tags: ['electronica', 'mouse']
}
])
.select()UPDATE: actualizar datos
.from('tabla').update() es el equivalente a UPDATE ... SET en SQL.
Actualizar un registro
const { data, error } = await supabase
.from('productos')
.update({ precio: 7800, updated_at: new Date().toISOString() })
.eq('id', productoId)
.select()Actualizar múltiples registros
// Desactivar todos los productos sin stock
const { data, error } = await supabase
.from('productos')
.update({ disponible: false })
.eq('stock', 0)
.select()Siempre usa filtros con update
Si ejecutas .update() sin ningún filtro (.eq(), .gt(), etc.), Supabase actualiza TODOS los registros de la tabla. Siempre especifica qué registros quieres modificar.
DELETE: eliminar datos
.from('tabla').delete() es el equivalente a DELETE FROM en SQL.
// Eliminar un producto por ID
const { error } = await supabase
.from('productos')
.delete()
.eq('id', productoId)
// Eliminar productos descontinuados
const { error: deleteError } = await supabase
.from('productos')
.delete()
.eq('disponible', false)
.eq('stock', 0)DELETE sin filtro es peligroso
Igual que con UPDATE: .delete() sin filtros elimina TODO. Supabase tiene una protección por defecto que rechaza deletes sin filtros, pero no dependas de eso. Siempre filtra explícitamente.
UPSERT: insertar o actualizar
.upsert() combina INSERT y UPDATE. Si el registro existe (basado en la primary key o un constraint UNIQUE), lo actualiza. Si no existe, lo inserta.
// Si ya existe un producto con ese ID, actualiza. Si no, crea uno nuevo.
const { data, error } = await supabase
.from('productos')
.upsert({
id: productoId, // Si este ID existe, se actualiza
nombre: 'Monitor 4K 27"',
precio: 7800,
stock: 20,
disponible: true,
categoria: 'monitores',
tags: ['electronica', '4k']
})
.select()
// Upsert basado en un campo UNIQUE
const { data: data2, error: error2 } = await supabase
.from('productos')
.upsert(
{
slug: 'monitor-4k-27', // slug es UNIQUE
nombre: 'Monitor 4K 27"',
precio: 7800,
stock: 20,
disponible: true,
categoria: 'monitores'
},
{ onConflict: 'slug' } // Especifica que columna UNIQUE usar para detectar conflictos
)
.select()Upsert es útil para sincronizar datos externos, importaciones y casos donde no sabes si el registro ya existe.
Filtros
Los filtros son el equivalente a WHERE en SQL. Los encadenas después de .select(), .update() o .delete().
Igualdad y desigualdad
// WHERE categoria = 'electronica'
.eq('categoria', 'electronica')
// WHERE categoria != 'electronica'
.neq('categoria', 'electronica')Comparaciones numéricas
// WHERE precio > 1000
.gt('precio', 1000)
// WHERE precio >= 1000
.gte('precio', 1000)
// WHERE precio < 5000
.lt('precio', 5000)
// WHERE precio <= 5000
.lte('precio', 5000)Búsqueda de texto
// WHERE nombre LIKE '%Monitor%' (case sensitive)
.like('nombre', '%Monitor%')
// WHERE nombre ILIKE '%monitor%' (case insensitive)
.ilike('nombre', '%monitor%')El % es un comodín que matchea cualquier secuencia de caracteres. %monitor% encuentra "Monitor 4K", "monitor barato", "El mejor monitor del mercado", etc.
Listas de valores
// WHERE categoria IN ('electronica', 'perifericos', 'audio')
.in('categoria', ['electronica', 'perifericos', 'audio'])Null checks
// WHERE descripcion IS NULL
.is('descripcion', null)
// WHERE descripcion IS NOT NULL
.not('descripcion', 'is', null)Combinar filtros
Todos los filtros se combinan con AND por defecto:
// WHERE categoria = 'electronica' AND precio > 1000 AND disponible = true
const { data, error } = await supabase
.from('productos')
.select('*')
.eq('categoria', 'electronica')
.gt('precio', 1000)
.eq('disponible', true)Para usar OR, usa el método .or():
// WHERE categoria = 'electronica' OR categoria = 'perifericos'
const { data, error } = await supabase
.from('productos')
.select('*')
.or('categoria.eq.electronica,categoria.eq.perifericos')
// WHERE (precio > 5000) OR (stock = 0)
const { data: data2, error: error2 } = await supabase
.from('productos')
.select('*')
.or('precio.gt.5000,stock.eq.0')Filtros en columnas de relaciones
// Filtrar por un campo de la tabla relacionada
const { data, error } = await supabase
.from('productos')
.select(`
id,
nombre,
categoria:categorias!inner (
nombre,
slug
)
`)
.eq('categorias.slug', 'electronica')El modificador !inner es necesario para que el filtro funcione -- convierte la consulta de LEFT JOIN a INNER JOIN.
Tabla de referencia rápida de filtros
| Método | SQL equivalente | Ejemplo |
|---|---|---|
.eq() | = valor | .eq('estado', 'activo') |
.neq() | != valor | .neq('estado', 'cancelado') |
.gt() | > valor | .gt('precio', 100) |
.gte() | >= valor | .gte('stock', 1) |
.lt() | < valor | .lt('precio', 5000) |
.lte() | <= valor | .lte('stock', 10) |
.like() | LIKE pattern | .like('nombre', '%monitor%') |
.ilike() | ILIKE pattern | .ilike('nombre', '%Monitor%') |
.in() | IN (lista) | .in('categoria', ['a', 'b']) |
.is() | IS valor | .is('descripcion', null) |
.or() | OR | .or('precio.gt.100,stock.eq.0') |
Ordenamiento
.order() es el equivalente a ORDER BY en SQL.
// ORDER BY precio ASC
const { data, error } = await supabase
.from('productos')
.select('*')
.order('precio', { ascending: true })
// ORDER BY created_at DESC
const { data: recientes } = await supabase
.from('productos')
.select('*')
.order('created_at', { ascending: false })
// ORDER BY multiples columnas: primero por categoria, luego por precio
const { data: ordenados } = await supabase
.from('productos')
.select('*')
.order('categoria', { ascending: true })
.order('precio', { ascending: false })Nulls first / last
// Poner los NULL al final
const { data } = await supabase
.from('productos')
.select('*')
.order('descripcion', { ascending: true, nullsFirst: false })Paginación
Para paginar resultados usas .range(), que es el equivalente a OFFSET ... LIMIT en SQL.
Paginación por rango
const ITEMS_POR_PAGINA = 10
// Página 1: registros 0-9
const { data: pagina1 } = await supabase
.from('productos')
.select('*')
.range(0, 9)
.order('created_at', { ascending: false })
// Página 2: registros 10-19
const { data: pagina2 } = await supabase
.from('productos')
.select('*')
.range(10, 19)
.order('created_at', { ascending: false })
// Función genérica para paginar
async function obtenerProductosPaginados(pagina: number) {
const desde = (pagina - 1) * ITEMS_POR_PAGINA
const hasta = desde + ITEMS_POR_PAGINA - 1
const { data, error, count } = await supabase
.from('productos')
.select('*', { count: 'exact' })
.range(desde, hasta)
.order('created_at', { ascending: false })
return {
productos: data,
totalRegistros: count,
totalPaginas: count ? Math.ceil(count / ITEMS_POR_PAGINA) : 0,
paginaActual: pagina,
error
}
}
// Uso
const resultado = await obtenerProductosPaginados(1)
// { productos: [...], totalRegistros: 42, totalPaginas: 5, paginaActual: 1 }Limitar resultados
// Solo los primeros 5 resultados (equivalente a LIMIT 5)
const { data } = await supabase
.from('productos')
.select('*')
.limit(5)
.order('precio', { ascending: false })Registro individual: single y maybeSingle
Cuando esperas exactamente un registro, usa .single() o .maybeSingle().
.single()
Devuelve un objeto (no un array). Lanza error si no encuentra resultados o si encuentra más de uno.
// Buscar un producto por ID
const { data: producto, error } = await supabase
.from('productos')
.select('*')
.eq('id', productoId)
.single()
// data: Producto (un objeto, no un array)
// error si: no existe o hay multiples resultados
if (error) {
console.error('Producto no encontrado:', error.message)
return
}
console.log(producto.nombre) // Acceso directo, sin data[0].maybeSingle()
Igual que .single() pero no lanza error si no encuentra resultados -- devuelve null en data.
// Buscar un producto por slug (puede no existir)
const { data: producto, error } = await supabase
.from('productos')
.select('*')
.eq('slug', 'monitor-4k-27')
.maybeSingle()
// data: Producto | null
if (!producto) {
console.log('Producto no encontrado')
} else {
console.log(producto.nombre)
}Cuándo usar cada uno
Usa .single() cuando sabes que el registro existe (por ejemplo, después de un INSERT). Usa .maybeSingle() cuando el registro puede no existir (por ejemplo, buscando por slug en una página dinámica).
Manejo de errores
Toda operación con el SDK devuelve { data, error }. Nunca lanza excepciones -- tú decides cómo manejar el error.
Patrón básico
const { data, error } = await supabase
.from('productos')
.select('*')
.eq('disponible', true)
if (error) {
console.error('Error en la consulta:', error.message)
console.error('Codigo:', error.code)
console.error('Detalles:', error.details)
return []
}
// En este punto, data esta garantizado como no-null
return dataPatrón con tipo de retorno explícito
async function obtenerProductos(): Promise<Producto[]> {
const { data, error } = await supabase
.from('productos')
.select('id, nombre, precio, stock, disponible, categoria')
.eq('disponible', true)
.order('created_at', { ascending: false })
if (error) {
console.error('Error al obtener productos:', error.message)
return []
}
return data as Producto[]
}Patrón con throw para Server Components
En NextJS Server Components, a veces quieres que el error se propague para que error.tsx lo maneje:
async function obtenerProducto(slug: string): Promise<Producto> {
const { data, error } = await supabase
.from('productos')
.select('*')
.eq('slug', slug)
.single()
if (error) {
throw new Error(`Producto no encontrado: ${error.message}`)
}
return data as Producto
}Errores comunes
| Código | Significado | Solución |
|---|---|---|
PGRST116 | No se encontró el registro (.single()) | Usa .maybeSingle() o verifica que el registro exista |
23505 | Violación de UNIQUE constraint | El registro ya existe, usa .upsert() |
23503 | Violación de foreign key | El registro referenciado no existe |
42501 | Permiso denegado (RLS) | Revisa tus políticas de Row Level Security |
23502 | Violación de NOT NULL | Falta un campo obligatorio |
Ejemplo completo: CRUD de productos
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
interface Producto {
id: string
nombre: string
slug: string
precio: number
stock: number
disponible: boolean
categoria: string
tags: string[]
created_at: string
}
// CREAR
async function crearProducto(producto: Omit<Producto, 'id' | 'created_at'>) {
const { data, error } = await supabase
.from('productos')
.insert(producto)
.select()
.single()
if (error) throw new Error(`Error al crear producto: ${error.message}`)
return data as Producto
}
// LEER (lista paginada)
async function listarProductos(pagina = 1, porPagina = 10) {
const desde = (pagina - 1) * porPagina
const hasta = desde + porPagina - 1
const { data, error, count } = await supabase
.from('productos')
.select('*', { count: 'exact' })
.eq('disponible', true)
.order('created_at', { ascending: false })
.range(desde, hasta)
if (error) throw new Error(`Error al listar productos: ${error.message}`)
return {
productos: data as Producto[],
total: count ?? 0,
paginas: count ? Math.ceil(count / porPagina) : 0
}
}
// LEER (uno por slug)
async function obtenerProducto(slug: string) {
const { data, error } = await supabase
.from('productos')
.select('*')
.eq('slug', slug)
.maybeSingle()
if (error) throw new Error(`Error al obtener producto: ${error.message}`)
return data as Producto | null
}
// ACTUALIZAR
async function actualizarProducto(id: string, cambios: Partial<Producto>) {
const { data, error } = await supabase
.from('productos')
.update(cambios)
.eq('id', id)
.select()
.single()
if (error) throw new Error(`Error al actualizar producto: ${error.message}`)
return data as Producto
}
// ELIMINAR
async function eliminarProducto(id: string) {
const { error } = await supabase
.from('productos')
.delete()
.eq('id', id)
if (error) throw new Error(`Error al eliminar producto: ${error.message}`)
}Este patrón lo puedes adaptar a cualquier tabla de tu proyecto. La estructura siempre es la misma: operación, filtros, manejo de error.
Siguiente paso
Hasta aquí cubrimos las operaciones estándar del SDK. Pero hay casos donde necesitas lógica más compleja que no se puede expresar con los métodos del SDK: búsquedas full-text, agregaciones, operaciones en batch. Para eso existen las funciones SQL y RPC, que veremos a continuación.