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

typescript
// Productos con precio exacto de 99.99
const { data } = await supabase
  .from('productos')
  .select('*')
  .eq('precio', 99.99)

neq -- distinto de

typescript
// 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

typescript
// 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

typescript
// 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:

typescript
// 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):

typescript
// 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:

typescript
// 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

typescript
// Pedidos con estado "pendiente" o "procesando"
const { data } = await supabase
  .from('pedidos')
  .select('*')
  .in('estado', ['pendiente', 'procesando'])

is -- comparar con null o booleanos

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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():

typescript
// 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

typescript
// (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

typescript
// 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():

typescript
// 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:

typescript
const { data } = await supabase
  .from('productos')
  .select('*')
  .textSearch('descripcion', 'computadora portatil', {
    config: 'spanish',
  })

Tipos de búsqueda

typescript
// 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:

sql
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():

typescript
// 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

typescript
// 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

typescript
// 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:

typescript
// 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áginas

Con limit y offset

Alternativa más simple pero menos eficiente en tablas grandes:

typescript
const { data } = await supabase
  .from('productos')
  .select('*')
  .order('created_at', { ascending: false })
  .limit(10)        // cuantos traer
  // offset no existe directamente, usa range
Performance 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)

typescript
// 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"):

typescript
// 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

typescript
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

OperadorSDKDescripció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