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:
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:
// 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)
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)
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)
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
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.
// 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:
// 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:
-- Configura REPLICA IDENTITY FULL en la tabla
ALTER TABLE mensajes REPLICA IDENTITY FULL;Ahora el payload de UPDATE y DELETE incluye la fila completa:
// 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.
// 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:
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:
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:
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:
// 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:
'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
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
'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
// 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:
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
- Verifica que la tabla tiene Realtime habilitado (
ALTER PUBLICATION supabase_realtime ADD TABLE ...) - Verifica que las políticas RLS permiten SELECT al usuario suscrito
- Verifica que el filtro es correcto (la sintaxis es
columna=op.valor, nocolumna op valor)
Se reciben cambios duplicados
Si creas el canal dentro de un useEffect sin cleanup, cada re-render crea una nueva suscripción:
// 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:
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 FULLsi necesitas la fila completa anterior en UPDATE/DELETE - La tabla debe tener Realtime habilitado en la publicación
supabase_realtime