Skip to main content
Build a fully functional real-time chat application with user presence, typing indicators, and instant message delivery using Supabase Realtime.

What You’ll Build

A real-time chat app featuring:
  • Instant message delivery
  • Online user presence
  • Typing indicators
  • Message history
  • User authentication
  • Channel-based conversations

Architecture

This app uses:
  • Supabase Auth for user authentication
  • Supabase Database for message storage
  • Supabase Realtime for live updates
  • Presence for online status
  • Broadcast for typing indicators
1

Create Your Project

  1. Go to supabase.com/dashboard
  2. Create a new project
  3. Wait for the database to be ready
2

Set Up Database Schema

Run this SQL in the SQL Editor:
-- Create messages table
create table messages (
  id uuid default gen_random_uuid() primary key,
  created_at timestamp with time zone default timezone('utc'::text, now()) not null,
  user_id uuid references auth.users on delete cascade not null,
  channel_id text not null,
  content text not null,
  constraint content_length check (char_length(content) > 0)
);

-- Create user profiles table
create table profiles (
  id uuid references auth.users on delete cascade primary key,
  username text unique not null,
  avatar_url text,
  created_at timestamp with time zone default timezone('utc'::text, now()) not null,
  constraint username_length check (char_length(username) >= 3)
);

-- Enable Row Level Security
alter table messages enable row level security;
alter table profiles enable row level security;

-- Messages policies
create policy "Users can view messages in channels they have access to"
  on messages for select
  using (auth.role() = 'authenticated');

create policy "Authenticated users can insert messages"
  on messages for insert
  with check (auth.uid() = user_id);

create policy "Users can delete their own messages"
  on messages for delete
  using (auth.uid() = user_id);

-- Profiles policies
create policy "Profiles are viewable by everyone"
  on profiles for select
  using (true);

create policy "Users can insert their own profile"
  on profiles for insert
  with check (auth.uid() = id);

create policy "Users can update own profile"
  on profiles for update
  using (auth.uid() = id);

-- Auto-create profile on signup
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = public
as $$
begin
  insert into public.profiles (id, username)
  values (new.id, new.email);
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

-- Enable realtime
alter publication supabase_realtime add table messages;
alter publication supabase_realtime add table profiles;
3

Enable Realtime in Dashboard

  1. Go to DatabaseReplication
  2. Enable replication for:
    • messages table
    • profiles table
  3. Click Save
4

Set Up Your Next.js App

Create a new Next.js app:
npx create-next-app@latest realtime-chat
cd realtime-chat
Install Supabase:
npm install @supabase/supabase-js
Create .env.local:
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
5

Create Supabase Client

Create lib/supabase.ts:
import { createClient } from '@supabase/supabase-js'

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

export type Message = {
  id: string
  created_at: string
  user_id: string
  channel_id: string
  content: string
  profiles: {
    username: string
    avatar_url: string | null
  }
}
6

Build the Chat Component

Create components/Chat.tsx:
'use client'
import { useEffect, useState, useRef } from 'react'
import { supabase, type Message } from '@/lib/supabase'
import { RealtimeChannel } from '@supabase/supabase-js'

export default function Chat({ 
  channelId, 
  userId 
}: { 
  channelId: string
  userId: string 
}) {
  const [messages, setMessages] = useState<Message[]>([])
  const [newMessage, setNewMessage] = useState('')
  const [onlineUsers, setOnlineUsers] = useState<string[]>([])
  const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set())
  const messagesEndRef = useRef<HTMLDivElement>(null)
  const channelRef = useRef<RealtimeChannel | null>(null)

  // Scroll to bottom
  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }

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

    // Set up realtime subscription
    const channel = supabase
      .channel(`room:${channelId}`)
      // Listen for new messages
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages',
          filter: `channel_id=eq.${channelId}`,
        },
        (payload) => {
          loadMessages() // Reload to get profile data
        }
      )
      // Track presence (online users)
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState()
        const users = Object.keys(state)
        setOnlineUsers(users)
      })
      .on('presence', { event: 'join' }, ({ key }) => {
        console.log('User joined:', key)
      })
      .on('presence', { event: 'leave' }, ({ key }) => {
        console.log('User left:', key)
      })
      // Listen for typing indicators
      .on('broadcast', { event: 'typing' }, ({ payload }) => {
        if (payload.userId !== userId) {
          setTypingUsers((prev) => new Set([...prev, payload.userId]))
          setTimeout(() => {
            setTypingUsers((prev) => {
              const next = new Set(prev)
              next.delete(payload.userId)
              return next
            })
          }, 3000)
        }
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          // Track this user's presence
          await channel.track({ user_id: userId, online_at: new Date().toISOString() })
        }
      })

    channelRef.current = channel

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

  useEffect(() => {
    scrollToBottom()
  }, [messages])

  const loadMessages = async () => {
    const { data, error } = await supabase
      .from('messages')
      .select(`
        *,
        profiles (username, avatar_url)
      `)
      .eq('channel_id', channelId)
      .order('created_at', { ascending: true })

    if (error) {
      console.error('Error loading messages:', error)
    } else {
      setMessages(data as Message[])
    }
  }

  const sendMessage = async (e: React.FormEvent) => {
    e.preventDefault()
    
    if (!newMessage.trim()) return

    const { error } = await supabase
      .from('messages')
      .insert({
        content: newMessage,
        channel_id: channelId,
        user_id: userId,
      })

    if (error) {
      console.error('Error sending message:', error)
    } else {
      setNewMessage('')
    }
  }

  const handleTyping = () => {
    if (channelRef.current) {
      channelRef.current.send({
        type: 'broadcast',
        event: 'typing',
        payload: { userId },
      })
    }
  }

  return (
    <div className="flex flex-col h-screen max-w-4xl mx-auto">
      {/* Header */}
      <div className="bg-blue-500 text-white p-4">
        <h1 className="text-2xl font-bold">Chat Room</h1>
        <p className="text-sm">
          {onlineUsers.length} user{onlineUsers.length !== 1 ? 's' : ''} online
        </p>
      </div>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
        {messages.map((message) => (
          <div
            key={message.id}
            className={`flex ${
              message.user_id === userId ? 'justify-end' : 'justify-start'
            }`}
          >
            <div
              className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
                message.user_id === userId
                  ? 'bg-blue-500 text-white'
                  : 'bg-white text-gray-800'
              }`}
            >
              {message.user_id !== userId && (
                <p className="text-xs font-semibold mb-1">
                  {message.profiles.username}
                </p>
              )}
              <p>{message.content}</p>
              <p className="text-xs mt-1 opacity-70">
                {new Date(message.created_at).toLocaleTimeString()}
              </p>
            </div>
          </div>
        ))}
        
        {/* Typing indicator */}
        {typingUsers.size > 0 && (
          <div className="text-sm text-gray-500 italic">
            Someone is typing...
          </div>
        )}
        
        <div ref={messagesEndRef} />
      </div>

      {/* Input */}
      <form onSubmit={sendMessage} className="p-4 bg-white border-t">
        <div className="flex gap-2">
          <input
            type="text"
            value={newMessage}
            onChange={(e) => setNewMessage(e.target.value)}
            onKeyPress={handleTyping}
            placeholder="Type a message..."
            className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          <button
            type="submit"
            className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition"
          >
            Send
          </button>
        </div>
      </form>
    </div>
  )
}
7

Create the Auth Flow

Create app/page.tsx:
'use client'
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import Chat from '@/components/Chat'
import { User } from '@supabase/supabase-js'

export default function Home() {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  useEffect(() => {
    // Check active session
    supabase.auth.getSession().then(({ data: { session } }) => {
      setUser(session?.user ?? null)
      setLoading(false)
    })

    // Listen for auth changes
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      setUser(session?.user ?? null)
    })

    return () => subscription.unsubscribe()
  }, [])

  const handleSignUp = async (e: React.FormEvent) => {
    e.preventDefault()
    const { error } = await supabase.auth.signUp({ email, password })
    if (error) alert(error.message)
  }

  const handleSignIn = async (e: React.FormEvent) => {
    e.preventDefault()
    const { error } = await supabase.auth.signInWithPassword({ email, password })
    if (error) alert(error.message)
  }

  const handleSignOut = async () => {
    await supabase.auth.signOut()
  }

  if (loading) {
    return <div className="flex items-center justify-center h-screen">Loading...</div>
  }

  if (!user) {
    return (
      <div className="flex items-center justify-center min-h-screen bg-gray-100">
        <div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
          <h1 className="text-2xl font-bold mb-6">Chat App</h1>
          <form className="space-y-4">
            <input
              type="email"
              placeholder="Email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              className="w-full px-4 py-2 border rounded-lg"
            />
            <input
              type="password"
              placeholder="Password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              className="w-full px-4 py-2 border rounded-lg"
            />
            <div className="flex gap-2">
              <button
                onClick={handleSignIn}
                className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg"
              >
                Sign In
              </button>
              <button
                onClick={handleSignUp}
                className="flex-1 px-4 py-2 bg-gray-200 rounded-lg"
              >
                Sign Up
              </button>
            </div>
          </form>
        </div>
      </div>
    )
  }

  return (
    <div>
      <Chat channelId="general" userId={user.id} />
      <button
        onClick={handleSignOut}
        className="fixed top-4 right-4 px-4 py-2 bg-red-500 text-white rounded"
      >
        Sign Out
      </button>
    </div>
  )
}
8

Run Your Chat App

Start the development server:
npm run dev
Open multiple browser windows at http://localhost:3000 to test real-time messaging!

Key Features Explained

Postgres Changes

Listens for new rows in the messages table:
.on('postgres_changes', {
  event: 'INSERT',
  schema: 'public',
  table: 'messages',
  filter: `channel_id=eq.${channelId}`,
}, callback)

Presence

Tracks online users in the channel:
.on('presence', { event: 'sync' }, () => {
  const state = channel.presenceState()
  const users = Object.keys(state)
  setOnlineUsers(users)
})

Broadcast

Sends ephemeral messages (like typing indicators):
channel.send({
  type: 'broadcast',
  event: 'typing',
  payload: { userId },
})

Enhancements

Create a reactions table and use Realtime to sync reactions across clients.
Integrate Supabase Storage to allow users to share images and files.
Create a channels table and let users create/join different chat rooms.
Add 1-on-1 private messaging between users.
Track which messages have been read by each user.

Next Steps

Realtime Presence

Learn more about presence tracking

Broadcast

Master broadcast channels

Postgres Changes

Deep dive into database subscriptions

Storage

Add file sharing to your chat