← Back to articles

How to Add Real-Time Features to Your App (2026)

Real-time features — live notifications, collaborative editing, chat, presence — make apps feel alive. Here's how to add them to your Next.js app without building complex WebSocket infrastructure.

The Three Approaches

1. Managed Real-Time (Easiest)

Use Supabase, Liveblocks, or Pusher. They handle the infrastructure. You just subscribe to channels.

2. Server-Sent Events (Simple)

One-way communication (server → client). Good for notifications and live updates.

3. WebSockets (Full Control)

Two-way communication. Build your own real-time infrastructure. More work but maximum flexibility.

Recommendation: Start with Supabase Realtime. You're probably already using Postgres.

Option 1: Supabase Realtime (Best for Most Apps)

Setup (5 minutes)

npm install @supabase/supabase-js
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

Real-Time Database Changes

'use client'
import { useEffect, useState } from 'react'

export function LiveTodoList() {
  const [todos, setTodos] = useState([])

  useEffect(() => {
    // Initial fetch
    const fetchTodos = async () => {
      const { data } = await supabase.from('todos').select('*')
      setTodos(data || [])
    }
    fetchTodos()

    // Subscribe to changes
    const channel = supabase
      .channel('todos')
      .on('postgres_changes', 
        { event: '*', schema: 'public', table: 'todos' },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            setTodos(prev => [...prev, payload.new])
          } else if (payload.eventType === 'DELETE') {
            setTodos(prev => prev.filter(t => t.id !== payload.old.id))
          } else if (payload.eventType === 'UPDATE') {
            setTodos(prev => prev.map(t => t.id === payload.new.id ? payload.new : t))
          }
        }
      )
      .subscribe()

    return () => { supabase.removeChannel(channel) }
  }, [])

  return (
    <ul>
      {todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
    </ul>
  )
}

Every database change is broadcast to subscribed clients instantly.

Presence (Who's Online)

const channel = supabase.channel('room-1')

// Track presence
await channel.subscribe(async (status) => {
  if (status === 'SUBSCRIBED') {
    await channel.track({
      user_id: userId,
      online_at: new Date().toISOString(),
    })
  }
})

// Listen for presence changes
channel.on('presence', { event: 'sync' }, () => {
  const state = channel.presenceState()
  console.log('Online users:', Object.keys(state))
})

Broadcast (Custom Events)

// Send a message
await channel.send({
  type: 'broadcast',
  event: 'cursor-move',
  payload: { x: 100, y: 200, user: userId },
})

// Receive messages
channel.on('broadcast', { event: 'cursor-move' }, (payload) => {
  updateCursor(payload.x, payload.y)
})

Option 2: Server-Sent Events (SSE)

Good for one-way updates (server → client): live notifications, progress updates, streaming responses.

API Route

// app/api/events/route.ts
export async function GET(req: Request) {
  const encoder = new TextEncoder()
  
  const stream = new ReadableStream({
    async start(controller) {
      // Send initial connection message
      controller.enqueue(encoder.encode('data: Connected\n\n'))
      
      // Send updates every 5 seconds
      const interval = setInterval(() => {
        const data = { time: new Date().toISOString(), count: Math.random() }
        controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
      }, 5000)
      
      // Cleanup
      req.signal.addEventListener('abort', () => {
        clearInterval(interval)
        controller.close()
      })
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  })
}

Client Component

'use client'
import { useEffect, useState } from 'react'

export function LiveUpdates() {
  const [data, setData] = useState(null)

  useEffect(() => {
    const eventSource = new EventSource('/api/events')
    
    eventSource.onmessage = (event) => {
      try {
        const parsed = JSON.parse(event.data)
        setData(parsed)
      } catch (e) {
        // Initial "Connected" message
      }
    }

    return () => eventSource.close()
  }, [])

  return <div>Latest: {JSON.stringify(data)}</div>
}

Option 3: WebSockets with Pusher

Setup

npm install pusher pusher-js
// lib/pusher.ts
import PusherServer from 'pusher'
import PusherClient from 'pusher-js'

export const pusherServer = new PusherServer({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
})

export const pusherClient = new PusherClient(
  process.env.NEXT_PUBLIC_PUSHER_KEY!,
  { cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER! }
)

Trigger Events (Server)

// app/api/send-notification/route.ts
import { pusherServer } from '@/lib/pusher'

export async function POST(req: Request) {
  const { userId, message } = await req.json()
  
  await pusherServer.trigger(`user-${userId}`, 'notification', {
    message,
    timestamp: new Date().toISOString(),
  })
  
  return Response.json({ success: true })
}

Subscribe (Client)

'use client'
import { pusherClient } from '@/lib/pusher'
import { useEffect, useState } from 'react'

export function Notifications({ userId }) {
  const [notifications, setNotifications] = useState([])

  useEffect(() => {
    const channel = pusherClient.subscribe(`user-${userId}`)
    
    channel.bind('notification', (data) => {
      setNotifications(prev => [...prev, data])
    })

    return () => {
      channel.unbind_all()
      channel.unsubscribe()
    }
  }, [userId])

  return (
    <ul>
      {notifications.map((n, i) => <li key={i}>{n.message}</li>)}
    </ul>
  )
}

Common Use Cases

Live Notifications

Use Supabase Broadcast or Pusher. Send notifications to specific users.

Collaborative Editing

Use Liveblocks or Yjs. Purpose-built for collaborative features.

Chat

Use Supabase Realtime or build with Pusher. Store messages in database, broadcast new messages.

Presence ("Who's Online")

Use Supabase Presence or Pusher presence channels.

Live Dashboard

Use SSE or Supabase Realtime. Stream metrics and updates.

Pricing Comparison

SupabasePusherLiveblocks
Free tier500MB, 50K MAU200K messages/day100 MAU
Paid$25/mo$49/mo$99/mo

FAQ

WebSockets vs Server-Sent Events?

SSE for one-way (server → client) updates. WebSockets for two-way communication.

Can I use Supabase Realtime without Supabase database?

No. Realtime requires Supabase Postgres. For non-Supabase projects, use Pusher or build WebSockets.

How do I handle reconnection?

Supabase and Pusher handle reconnection automatically. For custom WebSockets, implement exponential backoff reconnection logic.

Bottom Line

Use Supabase Realtime if you're using Supabase. Use Pusher for everything else. Use SSE for simple one-way updates. Don't build custom WebSocket infrastructure unless you have specific needs that managed services don't cover.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.