Filtros Avanzados y Paginación en la API de Supabase
La API de Supabase (via PostgREST) soporta un conjunto completo de operadores de filtrado que te permiten hacer queries precisas directamente desde el SDK. En esta guía vas a aprender todos los operadores disponibles, como combinarlos y como implementar paginación eficiente.
Operadores de comparación
eq -- igual a
// Productos con precio exacto de 99.99
const { data } = await supabase
.from('productos')
.select('*')
.eq('precio', 99.99)neq -- distinto de
// Todos los pedidos que NO están cancelados
const { data } = await supabase
.from('pedidos')
.select('*')
.neq('estado', 'cancelado')gt, gte -- mayor que, mayor o igual que
// Productos con precio mayor a 50
const { data } = await supabase
.from('productos')
.select('*')
.gt('precio', 50)
// Productos con stock mayor o igual a 10
const { data } = await supabase
.from('productos')
.select('*')
.gte('stock', 10)lt, lte -- menor que, menor o igual que
// Pedidos de menos de $100
const { data } = await supabase
.from('pedidos')
.select('*')
.lt('total', 100)
// Usuarios creados antes de o en una fecha
const { data } = await supabase
.from('usuarios')
.select('*')
.lte('created_at', '2025-01-01')Combinar comparaciones
Los filtros se encadenan con AND implícito:
// Productos con precio entre 50 y 200 Y con stock disponible
const { data } = await supabase
.from('productos')
.select('*')
.gte('precio', 50)
.lte('precio', 200)
.gt('stock', 0)Operadores de texto
like -- patrón con comodines (case-sensitive)
Usa % como comodin (coincide con cualquier secuencia de caracteres):
// Productos que empiezan con "Laptop"
const { data } = await supabase
.from('productos')
.select('*')
.like('nombre', 'Laptop%')
// Productos que contienen "pro" en el nombre
const { data } = await supabase
.from('productos')
.select('*')
.like('nombre', '%pro%')ilike -- patrón con comodines (case-insensitive)
Igual que like pero ignora mayúsculas y minúsculas:
// Buscar "laptop", "LAPTOP", "Laptop" -- todos coinciden
const { data } = await supabase
.from('productos')
.select('*')
.ilike('nombre', '%laptop%')Siempre usa ilike para búsquedas de usuario
Cuando el filtro viene de un input del usuario, usa ilike en vez de like. Los usuarios no esperan que la búsqueda sea sensible a mayúsculas.
Operadores de conjunto
in -- valor está en una lista
// Pedidos con estado "pendiente" o "procesando"
const { data } = await supabase
.from('pedidos')
.select('*')
.in('estado', ['pendiente', 'procesando'])is -- comparar con null o booleanos
// Productos sin categoria asignada
const { data } = await supabase
.from('productos')
.select('*')
.is('categoria_id', null)
// Productos activos
const { data } = await supabase
.from('productos')
.select('*')
.is('activo', true)null y eq no mezclan
No uses .eq('columna', null) para buscar valores nulos. Usa .is('columna', null). Es como en SQL: usas IS NULL, no = NULL.
Operadores de arrays y JSON
contains -- el array contiene estos valores
// Productos que tienen las etiquetas "oferta" Y "nuevo"
const { data } = await supabase
.from('productos')
.select('*')
.contains('etiquetas', ['oferta', 'nuevo'])containedBy -- los valores del array están contenidos en la lista
// Productos cuyas etiquetas están todas dentro de esta lista
const { data } = await supabase
.from('productos')
.select('*')
.containedBy('etiquetas', ['oferta', 'nuevo', 'popular', 'destacado'])overlaps -- el array comparte al menos un valor
// Productos que tienen al menos una de estas etiquetas
const { data } = await supabase
.from('productos')
.select('*')
.overlaps('etiquetas', ['oferta', 'destacado'])Filtros con OR
Por defecto, múltiples filtros se combinan con AND. Para usar OR, usa el método .or():
// Productos baratos O en oferta
const { data } = await supabase
.from('productos')
.select('*')
.or('precio.lt.50,en_oferta.eq.true')La sintaxis dentro de .or() usa el formato de PostgREST: columna.operador.valor separados por comas.
OR complejo con AND
// (precio < 50 AND stock > 0) OR (en_oferta = true)
const { data } = await supabase
.from('productos')
.select('*')
.or('and(precio.lt.50,stock.gt.0),en_oferta.eq.true')NOT
// Productos que NO están en la categoria "descontinuado"
const { data } = await supabase
.from('productos')
.select('*')
.not('categoria', 'eq', 'descontinuado')Búsqueda de texto completo
Para búsquedas más sofisticadas que ilike, PostgreSQL ofrece Full Text Search (búsqueda de texto completo que entiende idiomas, ignora palabras comunes y soporta relevancia). Supabase lo expone via .textSearch():
// Buscar productos que contengan "laptop gaming"
const { data } = await supabase
.from('productos')
.select('*')
.textSearch('nombre', 'laptop gaming')Configuración de idioma
Por defecto usa la configuración english. Para español:
const { data } = await supabase
.from('productos')
.select('*')
.textSearch('descripcion', 'computadora portatil', {
config: 'spanish',
})Tipos de búsqueda
// Plain: busca todas las palabras (AND implícito)
const { data } = await supabase
.from('productos')
.select('*')
.textSearch('nombre', 'laptop gaming', {
type: 'plain',
config: 'spanish',
})
// Phrase: busca la frase exacta
const { data } = await supabase
.from('productos')
.select('*')
.textSearch('nombre', 'laptop gaming', {
type: 'phrase',
config: 'spanish',
})
// Websearch: sintaxis similar a Google (soporta OR, -, comillas)
const { data } = await supabase
.from('productos')
.select('*')
.textSearch('nombre', '"laptop gaming" -usada', {
type: 'websearch',
config: 'spanish',
})Indice para text search
Para que la búsqueda de texto sea rápida, crea un índice GIN en la columna. Sin índice, PostgreSQL escanea toda la tabla en cada búsqueda:
CREATE INDEX idx_productos_nombre_search
ON productos USING GIN (to_tsvector('spanish', nombre));Seleccionar columnas específicas
No traigas datos que no necesitas. Especifica las columnas en .select():
// Solo id, nombre y precio
const { data } = await supabase
.from('productos')
.select('id, nombre, precio')
// Columnas de tablas relacionadas (JOIN automatico)
const { data } = await supabase
.from('pedidos')
.select(`
id,
total,
created_at,
usuario:usuarios(nombre, email),
items:pedido_items(
cantidad,
producto:productos(nombre, precio)
)
`)El SDK hace JOINs automáticamente cuando usas la sintaxis tabla(columnas). Esto funciona gracias a las foreign keys definidas en tu esquema.
Ordenar resultados
// Ordenar por precio ascendente
const { data } = await supabase
.from('productos')
.select('*')
.order('precio', { ascending: true })
// Ordenar por fecha, más recientes primero
const { data } = await supabase
.from('productos')
.select('*')
.order('created_at', { ascending: false })
// Ordenamiento múltiple: primero por categoria, luego por precio
const { data } = await supabase
.from('productos')
.select('*')
.order('categoria', { ascending: true })
.order('precio', { ascending: false })Nulls first / last
// Productos con categoria primero, sin categoria al final
const { data } = await supabase
.from('productos')
.select('*')
.order('categoria', { ascending: true, nullsFirst: false })Paginación
Con range (recomendado)
.range(from, to) trae un rango específico de filas. Los índices empiezan en 0:
// Página 1: filas 0 a 9 (10 resultados)
const { data: pagina1 } = await supabase
.from('productos')
.select('*')
.order('created_at', { ascending: false })
.range(0, 9)
// Página 2: filas 10 a 19
const { data: pagina2 } = await supabase
.from('productos')
.select('*')
.order('created_at', { ascending: false })
.range(10, 19)
// Función genérica para paginar
function getPagina(pagina: number, porPagina: number = 10) {
const desde = (pagina - 1) * porPagina
const hasta = desde + porPagina - 1
return supabase
.from('productos')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(desde, hasta)
}
// Uso
const { data, count } = await getPagina(1)
// count tiene el total de filas para calcular las páginasCon limit y offset
Alternativa más simple pero menos eficiente en tablas grandes:
const { data } = await supabase
.from('productos')
.select('*')
.order('created_at', { ascending: false })
.limit(10) // cuantos traer
// offset no existe directamente, usa rangePerformance en tablas grandes
La paginación basada en offset (como .range() con números altos) se vuelve lenta en tablas con millones de filas porque PostgreSQL tiene que contar todas las filas anteriores. Para tablas muy grandes, considera paginación por cursor (filtrar por el último id o created_at visto).
Paginación por cursor (para tablas grandes)
// Primera página
const { data: pagina1 } = await supabase
.from('productos')
.select('*')
.order('id', { ascending: true })
.limit(10)
// Paginas siguientes: usar el ultimo ID como cursor
const ultimoId = pagina1[pagina1.length - 1].id
const { data: pagina2 } = await supabase
.from('productos')
.select('*')
.gt('id', ultimoId)
.order('id', { ascending: true })
.limit(10)Conteo de resultados
Para saber cuántas filas hay en total (útil para mostrar "Página 1 de 50"):
// Conteo exacto (puede ser lento en tablas grandes)
const { data, count } = await supabase
.from('productos')
.select('*', { count: 'exact' })
.eq('activo', true)
.range(0, 9)
console.log(`${count} productos en total`)
// Conteo estimado (rapido, usa estadisticas de PostgreSQL)
const { data, count } = await supabase
.from('productos')
.select('*', { count: 'planned' })
.range(0, 9)
// Solo conteo, sin datos
const { count } = await supabase
.from('productos')
.select('*', { count: 'exact', head: true })Ejemplo completo: buscador con filtros
interface FiltroProductos {
busqueda?: string
categorias?: string[]
precioMin?: number
precioMax?: number
enOferta?: boolean
ordenarPor?: 'precio' | 'nombre' | 'fecha'
orden?: 'asc' | 'desc'
pagina?: number
porPagina?: number
}
async function buscarProductos(filtros: FiltroProductos) {
const {
busqueda,
categorias,
precioMin,
precioMax,
enOferta,
ordenarPor = 'fecha',
orden = 'desc',
pagina = 1,
porPagina = 12,
} = filtros
let query = supabase
.from('productos')
.select('id, nombre, precio, imagen_url, categoria, en_oferta', {
count: 'exact',
})
.eq('activo', true)
// Busqueda por texto
if (busqueda) {
query = query.ilike('nombre', `%${busqueda}%`)
}
// Filtrar por categorias
if (categorias && categorias.length > 0) {
query = query.in('categoria', categorias)
}
// Rango de precio
if (precioMin !== undefined) {
query = query.gte('precio', precioMin)
}
if (precioMax !== undefined) {
query = query.lte('precio', precioMax)
}
// Solo ofertas
if (enOferta) {
query = query.is('en_oferta', true)
}
// Ordenamiento
const columnaOrden = {
precio: 'precio',
nombre: 'nombre',
fecha: 'created_at',
}[ordenarPor]
query = query.order(columnaOrden, { ascending: orden === 'asc' })
// Paginacion
const desde = (pagina - 1) * porPagina
const hasta = desde + porPagina - 1
query = query.range(desde, hasta)
const { data, error, count } = await query
if (error) {
throw new Error(`Error al buscar productos: ${error.message}`)
}
return {
productos: data,
total: count ?? 0,
paginas: Math.ceil((count ?? 0) / porPagina),
paginaActual: pagina,
}
}Resumen de operadores
| Operador | SDK | Descripción |
|---|---|---|
eq | .eq('col', valor) | Igual a |
neq | .neq('col', valor) | Distinto de |
gt | .gt('col', valor) | Mayor que |
gte | .gte('col', valor) | Mayor o igual |
lt | .lt('col', valor) | Menor que |
lte | .lte('col', valor) | Menor o igual |
like | .like('col', '%patrón%') | Patrón (case-sensitive) |
ilike | .ilike('col', '%patrón%') | Patrón (case-insensitive) |
in | .in('col', [valores]) | Valor en lista |
is | .is('col', null/true/false) | Comparar con null o booleano |
contains | .contains('col', [valores]) | Array contiene valores |
containedBy | .containedBy('col', [valores]) | Valores contenidos en lista |
or | .or('filtro1,filtro2') | Combinar con OR |
textSearch | .textSearch('col', 'términos') | Búsqueda de texto completo |