Skip to main content
โšก Calmops

Real-time Features on a Budget: Supabase, Pusher, Socket.io

Introduction

Real-time features like chat, notifications, and live updates can differentiate your product. But implementing real-time can be expensive. This guide shows you how to add real-time features on a budget.


Options Comparison

# Real-time solutions
solutions:
  - name: "Supabase Realtime"
    type: "Built-in with database"
    complexity: "Very Low"
    pricing: "Free (with Supabase)"
    best_for: "Using Supabase already"
    
  - name: "Pusher"
    type: "Managed WebSocket"
    complexity: "Low"
    pricing: "$0-50/month"
    best_for: "Any stack"
    
  - name: "Socket.io"
    type: "Self-hosted"
    complexity: "Medium"
    pricing: "Hosting costs"
    best_for: "Full control needed"
    
  - name: "Ably"
    type: "Managed WebSocket"
    complexity: "Low"
    pricing: "$0-100/month"
    best_for: "Enterprise features"

Supabase Realtime

Quick Start

// Subscribe to database changes
import { createClient } from '@supabase/supabase-js'

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

// Subscribe to table changes
const channel = supabase
  .channel('custom-insert-channel')
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages',
    },
    (payload) => {
      console.log('New message:', payload.new)
    }
  )
  .subscribe()

Chat Implementation

// components/ChatRoom.tsx
'use client'

import { useEffect, useState } from 'react'
import { createClient } from '@supabase/supabase-js'

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

interface Message {
  id: string
  content: string
  user_id: string
  created_at: string
}

export function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([])
  const [newMessage, setNewMessage] = useState('')

  useEffect(() => {
    // Load initial messages
    loadMessages()

    // Subscribe to new messages
    const channel = supabase
      .channel(`room-${roomId}`)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages',
          filter: `room_id=eq.${roomId}`,
        },
        (payload) => {
          setMessages((prev) => [...prev, payload.new as Message])
        }
      )
      .subscribe()

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

  async function loadMessages() {
    const { data } = await supabase
      .from('messages')
      .select('*')
      .eq('room_id', roomId)
      .order('created_at', { ascending: true })
    
    if (data) setMessages(data)
  }

  async function sendMessage(e: React.FormEvent) {
    e.preventDefault()
    
    const { error } = await supabase.from('messages').insert({
      room_id: roomId,
      content: newMessage,
      user_id: (await supabase.auth.getUser()).data.user?.id,
    })

    if (!error) setNewMessage('')
  }

  return (
    <div>
      <div className="h-96 overflow-y-auto">
        {messages.map((msg) => (
          <div key={msg.id}>{msg.content}</div>
        ))}
      </div>
      <form onSubmit={sendMessage}>
        <input
          value={newMessage}
          onChange={(e) => setNewMessage(e.target.value)}
          placeholder="Type a message..."
        />
        <button type="submit">Send</button>
      </form>
    </div>
  )
}

Pusher

Setup

# Install Pusher
npm install pusher pusher-js
// lib/pusher.ts
import Pusher from 'pusher'
import PusherClient from 'pusher-js'

export const pusherServer = new Pusher({
  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!,
  useTLS: true,
})

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

Server: Trigger Events

// api/chat/send-message/route.ts
import { pusherServer } from '@/lib/pusher'
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
  const { message, roomId } = await req.json()

  // Save to database
  await saveMessage({ message, roomId })

  // Trigger real-time update
  await pusherServer.trigger(`chat-${roomId}`, 'new-message', {
    message,
  })

  return NextResponse.json({ success: true })
}

Client: Subscribe

// components/Chat.tsx
'use client'

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

export function ChatComponent({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<string[]>([])

  useEffect(() => {
    const channel = pusherClient.subscribe(`chat-${roomId}`)

    channel.bind('new-message', (data: { message: string }) => {
      setMessages((prev) => [...prev, data.message])
    })

    return () => {
      pusherClient.unsubscribe(`chat-${roomId}`)
    }
  }, [roomId])

  return (
    <div>
      {messages.map((msg, i) => (
        <div key={i}>{msg}</div>
      ))}
    </div>
  )
}

Pricing Comparison

# Real-time pricing (2025)
supabase:
  free: "50K monthly active users"
  pro: "$25/month with 200K MAU"
  
pusher:
  free: "100K messages/day, 50 connections"
  free_tier: "Chat, Presence features"
  sandbox: "$0/month"
  hobby: "$25/month"
  
socket.io:
  hosting: "Your server costs"
  # Can be free on existing server
  
ably:
  free: "6M messages/month, 100 peak connections"
  starter: "$50/month"

Key Takeaways

  • Supabase Realtime - Free if using Supabase
  • Pusher - Best for any stack
  • Socket.io - Full control, requires hosting

External Resources

Comments