Suscripciones en Tiempo Real

Postgres Changes te permite suscribirte a cambios (INSERT, UPDATE, DELETE) en tus tablas de base de datos. Cuando alguien modifica datos, todos los clientes suscritos reciben una notificación al instante con los datos nuevos.

Suscribirse a cambios en una tabla

La sintaxis básica:

typescript
const canal = supabase
  .channel('cambios-mensajes')
  .on(
    'postgres_changes',
    {
      event: '*',        // '*', 'INSERT', 'UPDATE', 'DELETE'
      schema: 'public',  // casi siempre 'public'
      table: 'mensajes'  // nombre de la tabla
    },
    (payload) => {
      console.log('Cambio detectado:', payload)
    }
  )
  .subscribe()

El payload contiene toda la información del cambio:

typescript
// Para INSERT:
{
  eventType: 'INSERT',
  new: { id: 1, texto: 'Hola', user_id: '...' },  // la fila nueva
  old: {},                                            // vacío en INSERT
  schema: 'public',
  table: 'mensajes'
}
 
// Para UPDATE:
{
  eventType: 'UPDATE',
  new: { id: 1, texto: 'Hola editado', user_id: '...' },  // la fila actualizada
  old: { id: 1 },                                            // la fila anterior (solo id por default)
  schema: 'public',
  table: 'mensajes'
}
 
// Para DELETE:
{
  eventType: 'DELETE',
  new: {},                                              // vacío en DELETE
  old: { id: 1 },                                      // la fila eliminada (solo id por default)
  schema: 'public',
  table: 'mensajes'
}
old solo contiene el id por default

Por seguridad, el objeto old solo incluye la clave primaria. Si necesitas todos los campos de la fila anterior (por ejemplo, para mostrar qué cambió), debes configurar REPLICA IDENTITY FULL en la tabla. Lo cubrimos más adelante.

Filtrar por tipo de evento

Solo INSERT (nuevos registros)

typescript
const canal = supabase
  .channel('nuevos-mensajes')
  .on(
    'postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'mensajes' },
    (payload) => {
      console.log('Nuevo mensaje:', payload.new)
    }
  )
  .subscribe()

Solo UPDATE (registros modificados)

typescript
const canal = supabase
  .channel('mensajes-editados')
  .on(
    'postgres_changes',
    { event: 'UPDATE', schema: 'public', table: 'mensajes' },
    (payload) => {
      console.log('Mensaje editado:', payload.new)
    }
  )
  .subscribe()

Solo DELETE (registros eliminados)

typescript
const canal = supabase
  .channel('mensajes-eliminados')
  .on(
    'postgres_changes',
    { event: 'DELETE', schema: 'public', table: 'mensajes' },
    (payload) => {
      console.log('Mensaje eliminado, id:', payload.old.id)
    }
  )
  .subscribe()

Escuchar múltiples eventos en el mismo canal

typescript
const canal = supabase
  .channel('cambios-mensajes')
  .on(
    'postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'mensajes' },
    (payload) => {
      console.log('Nuevo:', payload.new)
    }
  )
  .on(
    'postgres_changes',
    { event: 'DELETE', schema: 'public', table: 'mensajes' },
    (payload) => {
      console.log('Eliminado:', payload.old)
    }
  )
  .subscribe()

Filtrar por columna

Puedes filtrar para recibir solo cambios que cumplan una condición en una columna específica. Esto reduce el tráfico y la carga en el cliente.

typescript
// Solo recibir mensajes de la sala 'general'
const canal = supabase
  .channel('mensajes-general')
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'mensajes',
      filter: 'sala_id=eq.general'
    },
    (payload) => {
      console.log('Nuevo mensaje en general:', payload.new)
    }
  )
  .subscribe()

Sintaxis de filtros

Los filtros usan la misma sintaxis que los filtros de la API de Supabase:

typescript
// Igualdad
filter: 'columna=eq.valor'
 
// En una lista
filter: 'columna=in.(valor1,valor2,valor3)'
 
// Mayor que
filter: 'columna=gt.100'
 
// Menor que
filter: 'columna=lt.50'
Limitaciones de filtros

Los filtros de Realtime son más limitados que los de la API REST. Solo soportan comparaciones simples en una columna. No puedes hacer JOINs, funciones, ni condiciones complejas dentro del filtro. Si necesitas lógica avanzada, filtra en el callback del cliente.

Obtener la fila completa anterior (REPLICA IDENTITY)

Por default, cuando una fila se actualiza o elimina, el objeto old solo contiene la clave primaria. Si necesitas todos los campos anteriores:

sql
-- Configura REPLICA IDENTITY FULL en la tabla
ALTER TABLE mensajes REPLICA IDENTITY FULL;

Ahora el payload de UPDATE y DELETE incluye la fila completa:

typescript
// Con REPLICA IDENTITY FULL, UPDATE incluye todos los campos en old:
{
  eventType: 'UPDATE',
  new: { id: 1, texto: 'Hola editado', user_id: '...', created_at: '...' },
  old: { id: 1, texto: 'Hola', user_id: '...', created_at: '...' },
  schema: 'public',
  table: 'mensajes'
}
Cuando usar REPLICA IDENTITY FULL

Úsalo cuando necesites comparar el valor anterior con el nuevo (por ejemplo, para mostrar un historial de cambios) o cuando necesites datos de la fila eliminada. Tiene un costo de rendimiento mínimo en tablas pequeñas, pero considera el impacto en tablas con millones de filas y muchas actualizaciones.

Desuscribirse

Siempre desuscríbete cuando el componente se desmonte o cuando ya no necesites escuchar cambios. Si no lo haces, acumulas conexiones WebSocket innecesarias.

typescript
// Desuscribirse de un canal especifico
await supabase.removeChannel(canal)
 
// Desuscribirse de todos los canales
await supabase.removeAllChannels()

En un componente React, usa el cleanup del useEffect:

typescript
useEffect(() => {
  const canal = supabase
    .channel('mi-canal')
    .on('postgres_changes', { event: '*', schema: 'public', table: 'mensajes' }, handler)
    .subscribe()
 
  return () => {
    supabase.removeChannel(canal)
  }
}, [])

Manejar reconexiones

Las conexiones WebSocket pueden cerrarse por diversas razones: el usuario pierde internet, el servidor reinicia, o simplemente pasa demasiado tiempo inactivo. Supabase Realtime maneja la reconexión automáticamente, pero puedes monitorear el estado:

typescript
const canal = supabase
  .channel('mi-canal')
  .on('postgres_changes', { event: '*', schema: 'public', table: 'mensajes' }, handler)
  .subscribe((status, err) => {
    switch (status) {
      case 'SUBSCRIBED':
        console.log('Conectado')
        break
      case 'TIMED_OUT':
        console.log('Timeout -- reintentando...')
        break
      case 'CLOSED':
        console.log('Conexion cerrada')
        break
      case 'CHANNEL_ERROR':
        console.error('Error en el canal:', err)
        break
    }
  })

Recargar datos después de reconexión

Cuando el cliente se reconecta, puede haber perdido cambios que ocurrieron mientras estaba desconectado. Un patrón común es recargar los datos completos al reconectarse:

typescript
const canal = supabase
  .channel('mi-canal')
  .on('postgres_changes', { event: '*', schema: 'public', table: 'mensajes' }, handler)
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      // Recargar datos completos al (re)conectarse
      const { data } = await supabase
        .from('mensajes')
        .select('*')
        .order('created_at', { ascending: true })
 
      setMensajes(data ?? [])
    }
  })

Hook de React para suscripciones Realtime

Un hook reutilizable que encapsula la lógica de suscripción:

typescript
// hooks/useRealtimeTable.ts
'use client'
 
import { useEffect, useState, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { RealtimePostgresChangesPayload } from '@supabase/supabase-js'
 
interface UseRealtimeTableOptions<T> {
  table: string
  select?: string
  filter?: string
  orderBy?: { column: string; ascending?: boolean }
}
 
export function useRealtimeTable<T extends { id: string | number }>({
  table,
  select = '*',
  filter,
  orderBy
}: UseRealtimeTableOptions<T>) {
  const [data, setData] = useState<T[]>([])
  const [loading, setLoading] = useState(true)
  const supabase = createClient()
 
  // Cargar datos iniciales
  const fetchData = useCallback(async () => {
    let query = supabase.from(table).select(select)
 
    if (orderBy) {
      query = query.order(orderBy.column, { ascending: orderBy.ascending ?? true })
    }
 
    const { data: result, error } = await query
 
    if (!error && result) {
      setData(result as T[])
    }
    setLoading(false)
  }, [table, select, orderBy, supabase])
 
  useEffect(() => {
    fetchData()
 
    // Suscribirse a cambios
    const canal = supabase
      .channel(`realtime-${table}`)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table,
          ...(filter ? { filter } : {})
        },
        (payload: RealtimePostgresChangesPayload<T>) => {
          switch (payload.eventType) {
            case 'INSERT':
              setData((prev) => [...prev, payload.new as T])
              break
            case 'UPDATE':
              setData((prev) =>
                prev.map((item) =>
                  item.id === (payload.new as T).id ? (payload.new as T) : item
                )
              )
              break
            case 'DELETE':
              setData((prev) =>
                prev.filter((item) => item.id !== (payload.old as { id: string | number }).id)
              )
              break
          }
        }
      )
      .subscribe()
 
    return () => {
      supabase.removeChannel(canal)
    }
  }, [table, filter, fetchData, supabase])
 
  return { data, loading, refetch: fetchData }
}

Uso del hook:

tsx
'use client'
 
import { useRealtimeTable } from '@/hooks/useRealtimeTable'
 
interface Mensaje {
  id: string
  texto: string
  user_id: string
  created_at: string
}
 
export function ListaMensajes({ salaId }: { salaId: string }) {
  const { data: mensajes, loading } = useRealtimeTable<Mensaje>({
    table: 'mensajes',
    filter: `sala_id=eq.${salaId}`,
    orderBy: { column: 'created_at', ascending: true }
  })
 
  if (loading) return <p>Cargando mensajes...</p>
 
  return (
    <div className="space-y-2">
      {mensajes.map((msg) => (
        <div key={msg.id} className="p-2 bg-zinc-800 rounded">
          <p className="text-zinc-300">{msg.texto}</p>
          <span className="text-xs text-zinc-500">
            {new Date(msg.created_at).toLocaleTimeString()}
          </span>
        </div>
      ))}
    </div>
  )
}

Ejemplo completo: chat en tiempo real

Un chat funcional que combina Postgres Changes para mensajes persistentes, Broadcast para indicador de "escribiendo", y Presence para usuarios online.

Tabla de mensajes

sql
CREATE TABLE mensajes (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  sala_id TEXT NOT NULL,
  user_id UUID REFERENCES auth.users(id) NOT NULL,
  user_nombre TEXT NOT NULL,
  texto TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);
 
ALTER TABLE mensajes ENABLE ROW LEVEL SECURITY;
 
-- Cualquier usuario autenticado puede leer mensajes de cualquier sala
CREATE POLICY "leer mensajes"
ON mensajes FOR SELECT
TO authenticated
USING (true);
 
-- Usuarios pueden insertar mensajes con su propio user_id
CREATE POLICY "crear mensajes"
ON mensajes FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
 
-- Habilitar realtime
ALTER PUBLICATION supabase_realtime ADD TABLE mensajes;

Componente de chat

tsx
'use client'
 
import { useEffect, useState, useRef } from 'react'
import { createClient } from '@/lib/supabase/client'
 
interface Mensaje {
  id: string
  sala_id: string
  user_id: string
  user_nombre: string
  texto: string
  created_at: string
}
 
interface ChatUser {
  id: string
  nombre: string
}
 
export function Chat({ salaId, currentUser }: { salaId: string; currentUser: ChatUser }) {
  const [mensajes, setMensajes] = useState<Mensaje[]>([])
  const [nuevoTexto, setNuevoTexto] = useState('')
  const [usersOnline, setUsersOnline] = useState<ChatUser[]>([])
  const [typing, setTyping] = useState<string[]>([])
  const scrollRef = useRef<HTMLDivElement>(null)
  const supabase = createClient()
 
  useEffect(() => {
    // 1. Cargar mensajes existentes
    async function cargarMensajes() {
      const { data } = await supabase
        .from('mensajes')
        .select('*')
        .eq('sala_id', salaId)
        .order('created_at', { ascending: true })
        .limit(100)
 
      if (data) setMensajes(data)
    }
 
    cargarMensajes()
 
    // 2. Suscribirse a nuevos mensajes (Postgres Changes)
    const canal = supabase.channel(`chat-${salaId}`)
 
    canal
      // Nuevos mensajes
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'mensajes',
          filter: `sala_id=eq.${salaId}`
        },
        (payload) => {
          setMensajes((prev) => [...prev, payload.new as Mensaje])
        }
      )
      // Indicador de "escribiendo" (Broadcast)
      .on('broadcast', { event: 'typing' }, ({ payload }) => {
        if (payload.userId !== currentUser.id) {
          setTyping((prev) => {
            if (!prev.includes(payload.nombre)) {
              return [...prev, payload.nombre]
            }
            return prev
          })
 
          // Limpiar despues de 2 segundos
          setTimeout(() => {
            setTyping((prev) => prev.filter((name) => name !== payload.nombre))
          }, 2000)
        }
      })
      // Presence (usuarios online)
      .on('presence', { event: 'sync' }, () => {
        const state = canal.presenceState<ChatUser>()
        const online = Object.values(state).flat()
        setUsersOnline(online)
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          await canal.track({
            id: currentUser.id,
            nombre: currentUser.nombre
          })
        }
      })
 
    return () => {
      supabase.removeChannel(canal)
    }
  }, [salaId, currentUser, supabase])
 
  // Auto-scroll al nuevo mensaje
  useEffect(() => {
    scrollRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [mensajes])
 
  async function enviarMensaje(e: React.FormEvent) {
    e.preventDefault()
    if (!nuevoTexto.trim()) return
 
    const { error } = await supabase.from('mensajes').insert({
      sala_id: salaId,
      user_id: currentUser.id,
      user_nombre: currentUser.nombre,
      texto: nuevoTexto.trim()
    })
 
    if (!error) {
      setNuevoTexto('')
    }
  }
 
  function handleTyping() {
    const canal = supabase.channel(`chat-${salaId}`)
    canal.send({
      type: 'broadcast',
      event: 'typing',
      payload: { userId: currentUser.id, nombre: currentUser.nombre }
    })
  }
 
  return (
    <div className="flex flex-col h-[600px] border border-zinc-700 rounded-lg overflow-hidden">
      {/* Header con usuarios online */}
      <div className="p-3 border-b border-zinc-700 bg-zinc-900">
        <div className="flex items-center gap-2 text-sm text-zinc-400">
          <span className="w-2 h-2 bg-green-500 rounded-full" />
          {usersOnline.length} online
        </div>
      </div>
 
      {/* Mensajes */}
      <div className="flex-1 overflow-y-auto p-4 space-y-3">
        {mensajes.map((msg) => (
          <div
            key={msg.id}
            className={`flex flex-col ${
              msg.user_id === currentUser.id ? 'items-end' : 'items-start'
            }`}
          >
            <span className="text-xs text-zinc-500 mb-1">{msg.user_nombre}</span>
            <div
              className={`px-3 py-2 rounded-lg max-w-[70%] ${
                msg.user_id === currentUser.id
                  ? 'bg-blue-600 text-white'
                  : 'bg-zinc-800 text-zinc-200'
              }`}
            >
              {msg.texto}
            </div>
            <span className="text-xs text-zinc-600 mt-1">
              {new Date(msg.created_at).toLocaleTimeString()}
            </span>
          </div>
        ))}
        <div ref={scrollRef} />
      </div>
 
      {/* Indicador de typing */}
      {typing.length > 0 && (
        <div className="px-4 py-1 text-xs text-zinc-500">
          {typing.join(', ')} {typing.length === 1 ? 'está' : 'estan'} escribiendo...
        </div>
      )}
 
      {/* Input */}
      <form onSubmit={enviarMensaje} className="p-3 border-t border-zinc-700 bg-zinc-900">
        <div className="flex gap-2">
          <input
            type="text"
            value={nuevoTexto}
            onChange={(e) => {
              setNuevoTexto(e.target.value)
              handleTyping()
            }}
            placeholder="Escribe un mensaje..."
            className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded text-zinc-200 placeholder-zinc-500 focus:outline-none focus:border-zinc-500"
          />
          <button
            type="submit"
            disabled={!nuevoTexto.trim()}
            className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
          >
            Enviar
          </button>
        </div>
      </form>
    </div>
  )
}

Uso del componente

tsx
// app/chat/[salaId]/page.tsx
import { Chat } from '@/components/Chat'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
 
export default async function ChatPage({
  params
}: {
  params: { salaId: string }
}) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) redirect('/login')
 
  // Obtener nombre del perfil
  const { data: perfil } = await supabase
    .from('perfiles')
    .select('nombre')
    .eq('id', user.id)
    .single()
 
  return (
    <div className="max-w-2xl mx-auto py-8">
      <h1 className="text-2xl font-bold mb-4">Chat - Sala {params.salaId}</h1>
      <Chat
        salaId={params.salaId}
        currentUser={{ id: user.id, nombre: perfil?.nombre ?? 'Anonimo' }}
      />
    </div>
  )
}

Suscripciones a múltiples tablas

Puedes escuchar cambios en varias tablas desde un mismo canal:

typescript
const canal = supabase
  .channel('dashboard-cambios')
  .on(
    'postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'ventas' },
    (payload) => {
      console.log('Nueva venta:', payload.new)
    }
  )
  .on(
    'postgres_changes',
    { event: '*', schema: 'public', table: 'pedidos' },
    (payload) => {
      console.log('Cambio en pedidos:', payload)
    }
  )
  .on(
    'postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'notificaciones' },
    (payload) => {
      console.log('Nueva notificacion:', payload.new)
    }
  )
  .subscribe()

Errores comunes

No se reciben cambios

  1. Verifica que la tabla tiene Realtime habilitado (ALTER PUBLICATION supabase_realtime ADD TABLE ...)
  2. Verifica que las políticas RLS permiten SELECT al usuario suscrito
  3. Verifica que el filtro es correcto (la sintaxis es columna=op.valor, no columna op valor)

Se reciben cambios duplicados

Si creas el canal dentro de un useEffect sin cleanup, cada re-render crea una nueva suscripción:

typescript
// MAL: sin cleanup
useEffect(() => {
  const canal = supabase.channel('mi-canal').on(...).subscribe()
  // Nunca se desuscribe
}, [])
 
// BIEN: con cleanup
useEffect(() => {
  const canal = supabase.channel('mi-canal').on(...).subscribe()
  return () => {
    supabase.removeChannel(canal)
  }
}, [])

old está vacío en UPDATE/DELETE

Por default, old solo contiene la clave primaria. Si necesitas todos los campos:

sql
ALTER TABLE tu_tabla REPLICA IDENTITY FULL;

Resumen

  • Usa .on('postgres_changes', ...) para escuchar cambios en tablas de la base de datos
  • Filtra por evento (INSERT, UPDATE, DELETE, *) y por columna (filter: 'col=eq.valor')
  • Siempre desuscríbete en el cleanup del useEffect
  • Las suscripciones respetan RLS -- configura políticas de SELECT correctas
  • Recarga datos completos al reconectarte para cubrir cambios perdidos
  • Usa REPLICA IDENTITY FULL si necesitas la fila completa anterior en UPDATE/DELETE
  • La tabla debe tener Realtime habilitado en la publicación supabase_realtime