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:

typescript
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

typescript
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

typescript
const { data, error } = await supabase
  .from('productos')
  .select('*')
 
// data: Producto[] | null
// error: PostgrestError | null

Seleccionar columnas específicas

typescript
const { data, error } = await supabase
  .from('productos')
  .select('id, nombre, precio')
 
// Solo trae id, nombre y precio -- mas eficiente que traer todo
Siempre 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

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

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

INSERT: crear datos

.from('tabla').insert() es el equivalente a INSERT INTO en SQL.

Insertar un registro

typescript
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

typescript
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

typescript
const { data, error } = await supabase
  .from('productos')
  .update({ precio: 7800, updated_at: new Date().toISOString() })
  .eq('id', productoId)
  .select()

Actualizar múltiples registros

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

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

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

typescript
// WHERE categoria = 'electronica'
.eq('categoria', 'electronica')
 
// WHERE categoria != 'electronica'
.neq('categoria', 'electronica')

Comparaciones numéricas

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

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

typescript
// WHERE categoria IN ('electronica', 'perifericos', 'audio')
.in('categoria', ['electronica', 'perifericos', 'audio'])

Null checks

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

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

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

typescript
// 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étodoSQL equivalenteEjemplo
.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.

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

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

typescript
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

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

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

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

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

Patrón con tipo de retorno explícito

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

typescript
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ódigoSignificadoSolución
PGRST116No se encontró el registro (.single())Usa .maybeSingle() o verifica que el registro exista
23505Violación de UNIQUE constraintEl registro ya existe, usa .upsert()
23503Violación de foreign keyEl registro referenciado no existe
42501Permiso denegado (RLS)Revisa tus políticas de Row Level Security
23502Violación de NOT NULLFalta un campo obligatorio

Ejemplo completo: CRUD de productos

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