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 dataRendered from the code, no backend
'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> )}// app/chat/page.tsx (Server Component)import { redirect } from 'next/navigation'import { baseServer } from '@/lib/base-server'import { Chat } from './Chat'
export default async function ChatPage() { const base = await baseServer() const { data: { user } } = await base.auth.getUser() if (!user) redirect('/login')
// RLS on room_members: you only see your own memberships const { data: rooms } = await base.from('room_members').select('room_id').limit(1) const roomId = rooms?.[0]?.room_id if (!roomId) return <p>You are not in any room yet.</p>
return ( <main> <h1>#general</h1> <Chat roomId={roomId} userId={user.id} /> </main> )}// lib/base-server.ts// One client PER REQUEST: it holds the signed-in user's session.import { cookies } from 'next/headers'import { createServerClient } from '@potalab/base/next'
export async function baseServer() { const store = await cookies() return createServerClient({ url: process.env.NEXT_PUBLIC_POTALAB_BASE_URL!, key: process.env.NEXT_PUBLIC_POTALAB_BASE_KEY!, // lb_pub_... cookies: { getAll: () => store.getAll(), setAll: (list) => list.forEach((c) => store.set(c.name, c.value, c.options)), }, })}create table public.room_members ( room_id uuid not null, user_id uuid not null default auth.uid(), primary key (room_id, user_id));create table public.messages ( id bigint generated always as identity primary key, room_id uuid not null, user_id uuid not null default auth.uid(), body text not null, created_at timestamptz not null default now());alter table public.room_members enable row level security;alter table public.messages enable row level security;
create policy members_self on public.room_members for select to authenticated using (user_id = (select auth.uid()));create policy messages_select on public.messages for select to authenticated using (exists (select 1 from public.room_members m where m.room_id = messages.room_id and m.user_id = (select auth.uid())));create policy messages_insert on public.messages for insert to authenticated with check (user_id = (select auth.uid()) and exists (select 1 from public.room_members m where m.room_id = messages.room_id and m.user_id = (select auth.uid())));
-- Private channel "room:<id>": only members may join, send and trackcreate policy room_members_channel on realtime.channel_access for select to authenticated using (channel like 'room:%' and exists (select 1 from public.room_members m where m.room_id = split_part(channel, ':', 2)::uuid and m.user_id = (select auth.uid())));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 →