Overview
Presence allows you to track which users are currently online and synchronize state across clients in real-time. It’s perfect for showing “who’s online” indicators, collaborative features, and multiplayer games.
Presence automatically handles connection and disconnection events, removing users when they leave or lose connection.
Key Features
Automatic cleanup : Users are automatically removed when they disconnect
Conflict resolution : Built-in CRDT ensures consistent state across clients
Scalable : Efficiently handles thousands of concurrent users
Flexible state : Track any JSON-serializable user data
Basic Usage
Track Your Presence
Add yourself to a channel’s presence:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient (
'https://your-project.supabase.co' ,
'your-anon-key'
)
const channel = supabase . channel ( 'online-users' , {
config: {
presence: {
key: 'user-123' // Unique identifier for this user
}
}
})
channel . subscribe ( async ( status ) => {
if ( status === 'SUBSCRIBED' ) {
// Track your presence
await channel . track ({
user_name: 'Alice' ,
online_at: new Date (). toISOString ()
})
}
})
Get Current Presence State
Retrieve all users currently present:
channel . on ( 'presence' , { event: 'sync' }, () => {
const state = channel . presenceState ()
console . log ( 'Online users:' , state )
})
The presence state is an object where keys are user identifiers:
{
'user-123' : [
{
user_name: 'Alice' ,
online_at: '2024-03-04T10:30:00Z'
}
],
'user-456' : [
{
user_name: 'Bob' ,
online_at: '2024-03-04T10:31:00Z'
}
]
}
Complete Example
Here’s a real implementation based on the nextjs-auth-presence example in the source:
import { useSupabaseClient , useUser } from '@supabase/auth-helpers-react'
import { RealtimePresenceState } from '@supabase/supabase-js'
import { useEffect , useState } from 'react'
export default function OnlineUsers () {
const supabaseClient = useSupabaseClient ()
const user = useUser ()
const [ onlineUsers , setOnlineUsers ] = useState < RealtimePresenceState >({})
useEffect (() => {
const channel = supabaseClient . channel ( 'online-users' , {
config: {
presence: {
key: user ?. email || 'anonymous'
}
}
})
// Listen for presence changes
channel . on ( 'presence' , { event: 'sync' }, () => {
const presenceState = channel . presenceState ()
console . log ( 'Presence state updated:' , presenceState )
setOnlineUsers ({ ... presenceState })
})
channel . on ( 'presence' , { event: 'join' }, ({ newPresences }) => {
console . log ( 'New users joined:' , newPresences )
})
channel . on ( 'presence' , { event: 'leave' }, ({ leftPresences }) => {
console . log ( 'Users left:' , leftPresences )
})
// Subscribe and track presence
channel . subscribe ( async ( status ) => {
if ( status === 'SUBSCRIBED' ) {
const presenceTrackStatus = await channel . track ({
user_name: user ?. email || 'anonymous' ,
online_at: new Date (). toISOString ()
})
console . log ( 'Presence track status:' , presenceTrackStatus )
}
})
// Cleanup
return () => {
channel . unsubscribe ()
}
}, [ user ])
return (
< div >
< h2 > Currently Online Users : </ h2 >
< ul >
{ Object . keys ( onlineUsers ). map (( key ) => (
< li key = { key } > { key } </ li >
))}
</ ul >
</ div >
)
}
Presence Events
Presence emits three types of events:
Sync Event
Fired when the presence state is synchronized:
channel . on ( 'presence' , { event: 'sync' }, () => {
const state = channel . presenceState ()
console . log ( 'Current state:' , state )
})
Join Event
Fired when new users join:
channel . on ( 'presence' , { event: 'join' }, ({ newPresences }) => {
console . log ( 'Joined:' , newPresences )
// newPresences: Array of user presence data
})
Leave Event
Fired when users leave or disconnect:
channel . on ( 'presence' , { event: 'leave' }, ({ leftPresences }) => {
console . log ( 'Left:' , leftPresences )
// leftPresences: Array of user presence data that left
})
Advanced Patterns
Update Presence State
Update your presence data without reconnecting:
const channel = supabase . channel ( 'game-lobby' )
await channel . track ({
username: 'Alice' ,
status: 'idle'
})
// Later, update status
await channel . track ({
username: 'Alice' ,
status: 'playing' ,
current_level: 5
})
Collaborative Editing Example
Track cursor positions and selections in a collaborative editor:
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import { User , RealtimeChannel } from '@supabase/supabase-js'
import { useState , useEffect } from 'react'
interface CursorPosition {
email : string
cursor : { x : number ; y : number }
selection ?: { start : number ; end : number }
color : string
}
export default function CollaborativeEditor () {
const [ users , setUsers ] = useState < Set < string >>( new Set ())
const [ channel , setChannel ] = useState < RealtimeChannel | null >( null )
const supabase = createClientComponentClient ()
useEffect (() => {
const docChannel = supabase . channel ( 'document-123' , {
config: {
presence: { key: currentUser . email }
}
})
// Track cursor movements
docChannel
. on ( 'presence' , { event: 'join' }, ({ newPresences }) => {
newPresences . forEach (({ email }) => {
users . add ( email )
})
setUsers ( new Set ( users ))
})
. on ( 'presence' , { event: 'leave' }, ({ leftPresences }) => {
leftPresences . forEach (({ email }) => {
users . delete ( email )
})
setUsers ( new Set ( users ))
})
. subscribe ( async ( status ) => {
if ( status === 'SUBSCRIBED' ) {
await docChannel . track ({
email: currentUser . email ,
cursor: { x: 0 , y: 0 },
color: getRandomColor ()
})
setChannel ( docChannel )
}
})
return () => {
docChannel . unsubscribe ()
}
}, [])
function updateCursor ( x : number , y : number ) {
channel ?. track ({
email: currentUser . email ,
cursor: { x , y },
color: userColor
})
}
return (
< div >
< div className = "online-users" >
{ Array . from ( users ). map ( email => (
< span key = { email } > { email } </ span >
))}
</ div >
< div
className = "editor"
onMouseMove = {(e) => updateCursor (e.clientX, e.clientY)}
>
{ /* Editor content */ }
</ div >
</ div >
)
}
Multiplayer Game Lobby
Track players in a game lobby:
interface Player {
id : string
username : string
ready : boolean
team ?: 'red' | 'blue'
}
const lobby = supabase . channel ( 'game-lobby-1' , {
config: {
presence: { key: playerId }
}
})
// Join lobby
lobby . subscribe ( async ( status ) => {
if ( status === 'SUBSCRIBED' ) {
await lobby . track ({
username: playerName ,
ready: false ,
team: null
})
}
})
// Mark as ready
function setReady ( ready : boolean ) {
lobby . track ({
username: playerName ,
ready ,
team: selectedTeam
})
}
// Check if all players are ready
lobby . on ( 'presence' , { event: 'sync' }, () => {
const state = lobby . presenceState ()
const players = Object . values ( state ). flat ()
const allReady = players . every (( p : Player ) => p . ready )
if ( allReady && players . length >= 2 ) {
startGame ()
}
})
Private Channels with Presence
Combine presence with authorization for private spaces:
const privateRoom = supabase . channel ( 'private-team-room' , {
config: {
private: true , // Requires authorization
presence: { key: userId }
}
})
privateRoom
. on ( 'presence' , { event: 'sync' }, () => {
const teamMembers = privateRoom . presenceState ()
console . log ( 'Team members online:' , Object . keys ( teamMembers ))
})
. subscribe ( async ( status , err ) => {
if ( status === 'SUBSCRIBED' ) {
await privateRoom . track ({
username: currentUser . name ,
role: currentUser . role
})
}
if ( status === 'CHANNEL_ERROR' ) {
console . error ( 'Not authorized:' , err )
}
})
Combining Presence with Broadcast
Use both features together for rich collaborative experiences:
const channel = supabase . channel ( 'collaboration' , {
config: {
presence: { key: userId },
broadcast: { self: true }
}
})
// Track who's online with presence
channel
. on ( 'presence' , { event: 'sync' }, () => {
const users = channel . presenceState ()
updateOnlineUsersList ( users )
})
// Send messages with broadcast
. on ( 'broadcast' , { event: 'message' }, ({ payload }) => {
displayMessage ( payload )
})
. subscribe ( async ( status ) => {
if ( status === 'SUBSCRIBED' ) {
// Track presence
await channel . track ({
username: currentUser . name ,
avatar: currentUser . avatar
})
}
})
// Send a message
channel . send ({
type: 'broadcast' ,
event: 'message' ,
payload: { text: 'Hello!' , from: userId }
})
Throttle Presence Updates
Avoid updating presence too frequently:
import { throttle } from 'lodash'
// Update at most once per second
const updatePresence = throttle (( data ) => {
channel . track ( data )
}, 1000 )
// Use throttled version
document . addEventListener ( 'mousemove' , ( e ) => {
updatePresence ({
cursor: { x: e . clientX , y: e . clientY }
})
})
Limit Presence State Size
Keep presence data small for better performance:
// Good: Small, essential data
await channel . track ({
username: 'alice' ,
status: 'active'
})
// Avoid: Large, unnecessary data
await channel . track ({
username: 'alice' ,
profilePicture: 'base64...' , // Too large!
fullHistory: [ ... ] // Unnecessary
})
Clean Up on Unmount
Always unsubscribe when components unmount:
useEffect (() => {
const channel = supabase . channel ( 'presence' )
// ... setup
return () => {
channel . unsubscribe () // Important!
}
}, [])
Troubleshooting
User Not Appearing in Presence State
Verify subscription status :
channel . subscribe (( status ) => {
console . log ( 'Status:' , status )
})
Check if track() was called :
const trackStatus = await channel . track ({ username: 'alice' })
console . log ( 'Track status:' , trackStatus )
Ensure unique presence keys :
// Each user needs a unique key
const channel = supabase . channel ( 'users' , {
config: {
presence: { key: user . id } // Must be unique!
}
})
Users Not Being Removed
Presence automatically removes users after 60 seconds of inactivity. If a user appears stuck:
Check network connectivity
Verify the channel is still subscribed
Check browser console for errors
Next Steps
Broadcast Send ephemeral messages between clients
Postgres Changes Listen to database changes in real-time