Backend Comparison
| Feature | Supabase | Convex |
|---|---|---|
| Realtime Mechanism | PostgreSQL Changes | Built-in reactivity |
| Setup Required | Enable per table | Automatic |
| Subscriptions | Manual setup | Automatic with useQuery |
| Typing Indicators | Via Presence channel | Via mutations |
| Presence | Via Presence API | Via queries |
Quick Start
- Supabase
- Convex
import { useRealtimeMessages } from '@/hooks/useRealtimeMessages'
function ChatRoom({ channelId }: { channelId: string }) {
const { messages, sendMessage, isConnected } = useRealtimeMessages({ channelId })
return (
<View>
{messages.map(msg => <Text key={msg.id}>{msg.content}</Text>)}
<Button onPress={() => sendMessage('Hello!')} title="Send" />
</View>
)
}
import { useQuery, useMutation } from '@/hooks/convex'
import { api } from '@convex/_generated/api'
function ChatRoom({ channelId }: { channelId: string }) {
// Messages auto-update in real-time
const messages = useQuery(api.messages.list, { channelId })
const sendMessage = useMutation(api.messages.send)
return (
<View>
{messages?.map(msg => <Text key={msg._id}>{msg.content}</Text>)}
<Button
onPress={() => sendMessage({ channelId, content: 'Hello!' })}
title="Send"
/>
</View>
)
}
Chat with Supabase
useRealtimeMessages
Full-featured chat hook with typing indicators.const {
messages, // Current messages
sendMessage, // Send a new message
updateMessage, // Edit a message
deleteMessage, // Delete a message
typingUsers, // Users currently typing
setTyping, // Broadcast typing state
isConnected, // Connection status
loading, // Loading state
error, // Error state
refresh, // Refresh messages
} = useRealtimeMessages({
channelId: 'room-123',
maxMessages: 100,
onNewMessage: (msg) => playNotificationSound(),
onTypingChange: (users) => console.log('Typing:', users),
})
Full Chat Example
Full Chat Example
function ChatRoom({ channelId }: { channelId: string }) {
const [inputText, setInputText] = useState('')
const {
messages,
sendMessage,
typingUsers,
setTyping,
isConnected,
loading,
} = useRealtimeMessages({
channelId,
onNewMessage: () => Haptics.impactAsync(),
})
const handleSend = async () => {
if (!inputText.trim()) return
const { error } = await sendMessage(inputText)
if (!error) setInputText('')
}
const handleInputChange = (text: string) => {
setInputText(text)
setTyping(text.length > 0)
}
if (loading) return <LoadingSpinner />
return (
<View style={{ flex: 1 }}>
{!isConnected && <Banner text="Reconnecting..." />}
<FlatList
data={messages}
renderItem={({ item }) => (
<MessageBubble
content={item.content}
isOwn={item.user_id === currentUserId}
timestamp={item.created_at}
/>
)}
keyExtractor={(item) => item.id}
inverted
/>
{typingUsers.length > 0 && (
<Text style={styles.typing}>
{typingUsers.join(', ')} typing...
</Text>
)}
<View style={styles.inputRow}>
<TextInput
value={inputText}
onChangeText={handleInputChange}
placeholder="Type a message..."
/>
<Button onPress={handleSend} title="Send" />
</View>
</View>
)
}
useRealtimePresence
Track online users with status indicators.const {
presentUsers, // List of online users
userCount, // Number of users online
isConnected, // Connection status
updateStatus, // Update your status
updateCustomData, // Update custom presence data
isUserOnline, // Check if specific user is online
getUserPresence, // Get user's presence state
} = useRealtimePresence({
channelName: 'room:123',
initialStatus: 'online',
customData: { currentScreen: 'chat' },
onUserJoin: (user) => console.log(`${user.user_id} joined`),
onUserLeave: (userId) => console.log(`${userId} left`),
})
useRealtimeSubscription
Generic hook for subscribing to any table changes.const { isConnected, error, reconnect, disconnect } = useRealtimeSubscription<Order>({
table: 'orders',
event: 'INSERT', // 'INSERT' | 'UPDATE' | 'DELETE' | '*'
filter: { column: 'user_id', value: userId },
onInsert: (order) => toast.success(`New order #${order.id}!`),
onUpdate: (order, oldOrder) => console.log('Updated:', order),
onDelete: (oldOrder) => console.log('Deleted:', oldOrder.id),
})
Chat with Convex
With Convex, queries are automatically reactive - no manual subscription setup needed.Messages Query
// convex/messages.ts
import { query, mutation } from './_generated/server'
import { v } from 'convex/values'
export const list = query({
args: { channelId: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query('messages')
.withIndex('by_channel', (q) => q.eq('channelId', args.channelId))
.order('desc')
.take(100)
},
})
export const send = mutation({
args: { channelId: v.string(), content: v.string() },
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) throw new Error('Not authenticated')
return await ctx.db.insert('messages', {
channelId: args.channelId,
content: args.content,
userId: identity.subject,
createdAt: Date.now(),
})
},
})
Using in Components
import { useQuery, useMutation } from '@/hooks/convex'
import { api } from '@convex/_generated/api'
function ChatRoom({ channelId }: { channelId: string }) {
const messages = useQuery(api.messages.list, { channelId })
const sendMessage = useMutation(api.messages.send)
const [inputText, setInputText] = useState('')
// messages automatically updates when new messages arrive!
const handleSend = async () => {
if (!inputText.trim()) return
await sendMessage({ channelId, content: inputText })
setInputText('')
}
if (messages === undefined) return <LoadingSpinner />
return (
<View style={{ flex: 1 }}>
<FlatList
data={messages}
renderItem={({ item }) => (
<MessageBubble
content={item.content}
isOwn={item.userId === currentUserId}
timestamp={item.createdAt}
/>
)}
keyExtractor={(item) => item._id}
inverted
/>
<View style={styles.inputRow}>
<TextInput
value={inputText}
onChangeText={setInputText}
placeholder="Type a message..."
/>
<Button onPress={handleSend} title="Send" />
</View>
</View>
)
}
Presence with Convex
The boilerplate includes ready-to-use presence functions inconvex/realtime.ts:
import { useQuery, useMutation } from '@/hooks/convex'
import { api } from '@convex/_generated/api'
function OnlineUsers({ channel }: { channel: string }) {
// Get all users in this channel (auto-updates!)
const presence = useQuery(api.realtime.getPresence, { channel })
// Join/leave channel
const joinPresence = useMutation(api.realtime.joinPresence)
const leavePresence = useMutation(api.realtime.leavePresence)
const updatePresence = useMutation(api.realtime.updatePresence)
useEffect(() => {
// Join when component mounts
joinPresence({ channel, state: { status: 'online' } })
// Leave when component unmounts
return () => {
leavePresence({ channel })
}
}, [channel])
return (
<View>
<Text>{presence?.count} users online</Text>
{presence?.users.map(user => (
<Text key={user.presenceId}>{user.user?.name}</Text>
))}
</View>
)
}
Presence Functions
| Function | Description |
|---|---|
joinPresence({ channel, state }) | Join a channel with optional state |
updatePresence({ channel, state }) | Update your presence state (heartbeat) |
leavePresence({ channel }) | Leave a channel |
getPresence({ channel }) | Get all users in a channel (reactive) |
Custom Presence State
Store any data with your presence:// Track cursor position
await updatePresence({
channel: 'document:123',
state: {
cursor: { x: 100, y: 200 },
status: 'editing',
color: '#ff0000',
}
})
Broadcast with Convex
Send ephemeral messages to channel subscribers:import { useQuery, useMutation } from '@/hooks/convex'
import { api } from '@convex/_generated/api'
function RealtimeUpdates({ channel }: { channel: string }) {
// Subscribe to broadcasts (auto-updates!)
const broadcasts = useQuery(api.realtime.subscribeBroadcast, {
channel,
since: Date.now() - 30000, // Last 30 seconds
})
// Send a broadcast
const broadcast = useMutation(api.realtime.broadcast)
const sendCursorMove = (x: number, y: number) => {
broadcast({
channel,
event: 'cursor_move',
payload: { x, y },
})
}
return (
<View>
{broadcasts?.messages.map(msg => (
<Text key={msg.id}>
{msg.event}: {JSON.stringify(msg.payload)}
</Text>
))}
</View>
)
}
Broadcast Functions
| Function | Description |
|---|---|
broadcast({ channel, event, payload }) | Send a message to all subscribers |
subscribeBroadcast({ channel, since }) | Get recent messages (reactive) |
Auto-Cleanup: Broadcast messages expire after 30 seconds and are automatically cleaned up by a cron job. This keeps your database lean for ephemeral data like cursor positions.
Database Setup
- Supabase
- Convex
Messages Table
CREATE TABLE public.messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
channel_id TEXT NOT NULL,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read messages in their channels"
ON public.messages FOR SELECT TO authenticated USING (true);
CREATE POLICY "Users can insert their own messages"
ON public.messages FOR INSERT TO authenticated
WITH CHECK (auth.uid() = user_id);
-- Enable realtime
ALTER PUBLICATION supabase_realtime ADD TABLE public.messages;
CREATE INDEX idx_messages_channel_id ON public.messages(channel_id);
CREATE INDEX idx_messages_created_at ON public.messages(created_at DESC);
Enable Realtime
In Supabase Dashboard:- Go to Database → Replication
- Under “Realtime”, enable for your tables
- Or run:
ALTER PUBLICATION supabase_realtime ADD TABLE your_table;
Realtime Schema (Pre-configured)
The boilerplate includes presence and broadcast tables inconvex/schema.ts:// Presence tracking
presence: defineTable({
channel: v.string(),
userId: v.id("users"),
state: v.optional(v.any()),
joinedAt: v.number(),
lastSeenAt: v.number(),
})
.index("by_channel", ["channel"])
.index("by_channel_user", ["channel", "userId"])
.index("by_lastSeenAt", ["lastSeenAt"]),
// Broadcast messages
broadcasts: defineTable({
channel: v.string(),
event: v.string(),
payload: v.any(),
senderId: v.optional(v.id("users")),
createdAt: v.number(),
expiresAt: v.number(),
})
.index("by_channel", ["channel"])
.index("by_channel_created", ["channel", "createdAt"])
.index("by_expiresAt", ["expiresAt"]),
Automatic Cleanup
Cron jobs inconvex/crons.ts handle cleanup:- Presence cleanup: Every minute, removes stale presence older than 60 seconds
- Broadcast cleanup: Every 5 minutes, removes expired broadcast messages
Mock Mode
All realtime hooks work in mock mode for development without backend credentials:- Messages are stored locally and persist during the session
- Presence shows the current user as online
- Subscriptions simulate connected state
Testing Tip: Use mock mode to build and test your UI before connecting to a real backend.
TypeScript Types
Import types for your realtime data:import type {
RealtimeMessage,
RealtimeMessageWithUser,
RealtimeActivity,
PresenceState,
TypingState,
} from '@/types/realtime'
apps/app/app/types/realtime.ts for all available types.
