How to Build a Real-Time Chat Application with Supabase
Real-time chat is one of the most requested features in modern web applications. Whether you are building customer support, team collaboration, or social messaging, real-time communication requires careful handling of WebSocket connections, message persistence, user presence, and typing indicators.
Supabase Realtime makes this surprisingly straightforward. Built on PostgreSQL replication and WebSockets, Supabase Realtime lets you subscribe to database changes and receive updates in real time without managing your own WebSocket server. Combined with Next.js 14 and TypeScript, you can build a production-ready chat application in days instead of weeks.
Architecture Overview
A real-time chat application built with Supabase requires:
- A conversations table for chat rooms
- A messages table for message history
- Supabase Realtime subscriptions for live updates
- User presence tracking for online status
- File upload support for image sharing
- Typing indicators for better UX
The Real-Time Chat Starter template includes all of this pre-built.
Database Schema
-- Conversations (chat rooms)
create table conversations (
id uuid default gen_random_uuid() primary key,
name text,
is_group boolean default false,
created_at timestamptz default now()
);
-- Conversation participants
create table conversation_participants (
conversation_id uuid references conversations on delete cascade,
user_id uuid references auth.users on delete cascade,
last_read_at timestamptz default now(),
primary key (conversation_id, user_id)
);
-- Messages
create table messages (
id uuid default gen_random_uuid() primary key,
conversation_id uuid references conversations on delete cascade not null,
user_id uuid references auth.users not null,
content text not null,
file_url text,
created_at timestamptz default now()
);
-- Enable real-time for messages
alter publication supabase_realtime add table messages;Sending Messages via Server Action
// app/actions/send-message.ts
'use server'
import { supabase } from '@/lib/supabase'
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
export async function sendMessage(
conversationId: string,
content: string
) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const { error } = await supabase.from('messages').insert({
conversation_id: conversationId,
user_id: session.user.id,
content,
})
if (error) throw new Error('Failed to send message')
revalidatePath(`/chat/${conversationId}`)
}Real-Time Message Subscription
'use client'
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
interface Message {
id: string
content: string
user_id: string
created_at: string
user: { name: string; avatar_url: string }
}
export function useRealtimeMessages(conversationId: string) {
const [messages, setMessages] = useState<Message[]>([])
useEffect(() => {
// Load initial messages
const loadMessages = async () => {
const { data } = await supabase
.from('messages')
.select('*, user:user_id(name, avatar_url)')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true })
if (data) setMessages(data)
}
loadMessages()
// Subscribe to new messages
const channel = supabase
.channel(`messages:${conversationId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `conversation_id=eq.${conversationId}`,
},
(payload) => {
const newMessage = payload.new as Message
setMessages(prev => [...prev, newMessage])
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [conversationId])
return messages
}Chat UI Component
'use client'
import { useState, useRef, useEffect } from 'react'
import { useRealtimeMessages } from './useRealtimeMessages'
import { sendMessage } from '@/app/actions/send-message'
import { Send } from 'lucide-react'
export function ChatView({
conversationId,
userId,
}: {
conversationId: string
userId: string
}) {
const messages = useRealtimeMessages(conversationId)
const [input, setInput] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim()) return
await sendMessage(conversationId, input.trim())
setInput('')
}
return (
<div className="flex h-[600px] flex-col rounded-xl border bg-white">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map(msg => (
<div
key={msg.id}
className={`flex ${
msg.user_id === userId ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[70%] rounded-2xl px-4 py-2 ${
msg.user_id === userId
? 'bg-brand-600 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
<p className="text-sm">{msg.content}</p>
<p className={`mt-1 text-xs ${
msg.user_id === userId ? 'text-white/70' : 'text-gray-400'
}`}>
{new Date(msg.created_at).toLocaleTimeString()}
</p>
</div>
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSubmit} className="border-t p-4">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Type a message..."
className="flex-1 rounded-xl border px-4 py-2.5 text-sm"
/>
<button
type="submit"
className="rounded-xl bg-brand-600 px-4 py-2.5 text-white"
>
<Send className="h-4 w-4" />
</button>
</div>
</form>
</div>
)
}The Bottom Line
Real-time chat is easier than ever with Supabase Realtime and Next.js 14. The Realtime Chat Starter template gives you a complete, production-ready chat application with message persistence, real-time updates, user presence, and file sharing — ready to customize for your use case.
Related Template
Try this production-ready starter kit to build your project faster.
Online — 5
Alice 10:30 AM
Hey everyone! 👋
Bob 10:31 AM
Morning team!
Real-Time Chat App
Build Slack-like chat in hours
Ready to Build?
Get started with our production-ready starter kits and ship your project faster.
Browse Starter Kits