PotaLab
← All examples
SnippetRealtimeData APIRLSNext.js

Realtime chat

Messages are stored in a table and streamed with postgres_changes. A channel_access policy limits the channel to room members; presence shows who is online. Enable realtime for the project and the messages table first.

localhost:3000/chat

#general

3 online

  • Member 3f9aDid the migration finish?
  • YouYes, the RLS policies are live.
  • Member b2c1Great, testing realtime now.

Inserts arrive over postgres_changes; presence and typing use the same private channel.

Preview · sample data
'use client'
import { useEffect, useRef, useState } from 'react'
import { createBrowserClient } from '@potalab/base/next'
import { useMutation, useQuery } from '@potalab/base/react'
const base = () =>
createBrowserClient({ url: process.env.NEXT_PUBLIC_POTALAB_BASE_URL!, key: process.env.NEXT_PUBLIC_POTALAB_BASE_KEY! })
export function Chat(props: { roomId: string; userId: string }) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
return mounted ? <Room {...props} /> : <p>Loading...</p>
}
function Room({ roomId, userId }: { roomId: string; userId: string }) {
const client = base()
const [body, setBody] = useState('')
const [online, setOnline] = useState(1)
const [typing, setTyping] = useState(false)
const channel = useRef<ReturnType<typeof client.channel> | null>(null)
const history = useQuery(
client.from('messages').select('id,user_id,body').eq('room_id', roomId).order('created_at').limit(50),
)
const send = useMutation((text: string) => client.from('messages').insert({ room_id: roomId, body: text }))
useEffect(() => {
const ch = client
.channel(`room:${roomId}`, { config: { presence: { key: userId } } })
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` },
() => void history.refetch(),
)
.on('broadcast', { event: 'typing' }, ({ payload }) => {
if (payload.userId !== userId) setTyping(payload.on)
})
.on('presence', { event: 'sync' }, () => setOnline(Object.keys(ch.presenceState()).length))
.on('system', {}, (p) => {
if (p.code === 'resync') void history.refetch() // missed events while reconnecting
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') await ch.track({ online: true })
})
channel.current = ch
return () => void client.removeChannel(ch)
}, [roomId, userId])
return (
<div>
<p>{online} online</p>
<ul>
{history.data?.map((m) => (
<li key={m.id} className={m.user_id === userId ? 'mine' : ''}>
<b>{m.user_id === userId ? 'You' : `Member ${m.user_id.slice(0, 4)}`}</b> {m.body}
</li>
))}
</ul>
{typing && <p>Someone is typing...</p>}
<form
onSubmit={(e) => {
e.preventDefault()
send.mutate(body)
setBody('')
}}
>
<input
value={body}
placeholder="Write a message"
onChange={(e) => {
setBody(e.target.value)
void channel.current?.send({ type: 'broadcast', event: 'typing', payload: { userId, on: !!e.target.value } })
}}
/>
<button type="submit" disabled={!body.trim()}>Send</button>
</form>
</div>
)
}

Snippet for illustration, written for the Next.js App Router with @potalab/base/next,@potalab/base/react and @potalab/base. Set NEXT_PUBLIC_POTALAB_BASE_URL andNEXT_PUBLIC_POTALAB_BASE_KEY, adapt names and redirect URLs, and review policies before production.

Next: Todo app with RLS →