Transform your static apps into dynamic, real-time experiences with just a few lines of code
Hey there, fellow developers! 👋 Ready to add some real-time superpowers to your Next.js applications? Today we're diving into one of my favorite features of Supabase – real-time database subscriptions. By the end of this guide, you'll be building apps that update instantly across all connected clients, no refresh button needed!
Why Real-time Matters in 2025
Think about the apps you use daily – Discord messages appearing instantly, collaborative docs updating as teammates type, live dashboards showing fresh data. Users expect this level of interactivity, and Supabase makes it surprisingly simple to deliver.
What we'll build today:
- A live chat system that updates instantly
- Real-time notifications for database changes
- Best practices for handling connections efficiently
- Clean TypeScript implementation throughout
Prerequisites (Don't worry, it's simple!)
Before we jump in, make sure you have:
- Basic knowledge of React/Next.js
- A Supabase project (from my previous guide or new one)
- Node.js 18+ and PNPM installed
- 10 minutes of your time ⏰
Haven't set up Prisma with Supabase yet? Check out my previous comprehensive guide first!
Step 1: Project Setup (Lightning Fast with PNPM)
Let's create a fresh Next.js 15 project:
pnpm create next-app@latest realtime-chat-app
cd realtime-chat-app
Choose these options when prompted:
- ✅ TypeScript
- ✅ ESLint
- ✅ Tailwind CSS
- ✅ App Router
- ✅ Import alias
Install our dependencies:
pnpm add @supabase/supabase-js @supabase/ssr uuid
pnpm add -D @types/uuid
Step 2: Supabase Configuration
Create lib/supabase/client.ts
:
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
realtime: {
params: {
eventsPerSecond: 10,
},
},
});
// Types for our chat system
export interface Message {
id: string;
content: string;
user_name: string;
created_at: string;
}
export interface Profile {
id: string;
name: string;
online: boolean;
last_seen: string;
}
Add your environment variables to .env.local
:
NEXT_PUBLIC_SUPABASE_URL=your_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
Step 3: Database Schema (Simple & Effective)
Head to your Supabase dashboard and run this SQL or you use can Prisma ORM, which makes schema management easier to maintain and version control as your project grows:
-- Enable real-time for public schema
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
ALTER PUBLICATION supabase_realtime ADD TABLE profiles;
-- Messages table
CREATE TABLE messages (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
content TEXT NOT NULL,
user_name VARCHAR(50) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Profiles table for online status
CREATE TABLE profiles (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
online BOOLEAN DEFAULT false,
last_seen TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Enable Row Level Security
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Allow all operations (for demo - adjust for production)
CREATE POLICY "Allow all operations" ON messages FOR ALL USING (true);
CREATE POLICY "Allow all operations" ON profiles FOR ALL USING (true);
Step 4: Real-time Hook (The Magic Happens Here)
Create hooks/useRealtime.ts
:
"use client";
import { useEffect, useState } from "react";
import { supabase, Message, Profile } from "@/lib/supabase/client";
import { RealtimeChannel } from "@supabase/supabase-js";
export function useMessages() {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let channel: RealtimeChannel;
// Fetch initial messages
const fetchMessages = async () => {
const { data, error } = await supabase
.from("messages")
.select("*")
.order("created_at", { ascending: true })
.limit(50);
if (error) {
console.error("Error fetching messages:", error);
} else {
setMessages(data || []);
}
setLoading(false);
};
// Set up real-time subscription
const setupSubscription = () => {
channel = supabase
.channel("messages")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "messages" },
(payload) => {
const newMessage = payload.new as Message;
setMessages((prev) => [...prev, newMessage]);
}
)
.on(
"postgres_changes",
{ event: "DELETE", schema: "public", table: "messages" },
(payload) => {
const deletedId = payload.old.id;
setMessages((prev) => prev.filter((msg) => msg.id !== deletedId));
}
)
.subscribe();
};
fetchMessages();
setupSubscription();
// Cleanup on unmount
return () => {
if (channel) {
supabase.removeChannel(channel);
}
};
}, []);
return { messages, loading };
}
export function useOnlineUsers() {
const [onlineUsers, setOnlineUsers] = useState<Profile[]>([]);
useEffect(() => {
let channel: RealtimeChannel;
const fetchOnlineUsers = async () => {
const { data } = await supabase
.from("profiles")
.select("*")
.eq("online", true);
setOnlineUsers(data || []);
};
const setupSubscription = () => {
channel = supabase
.channel("profiles")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "profiles" },
(payload) => {
if (payload.eventType === "UPDATE") {
const updatedProfile = payload.new as Profile;
setOnlineUsers((prev) => {
const filtered = prev.filter(
(user) => user.id !== updatedProfile.id
);
return updatedProfile.online
? [...filtered, updatedProfile]
: filtered;
});
}
}
)
.subscribe();
};
fetchOnlineUsers();
setupSubscription();
return () => {
if (channel) {
supabase.removeChannel(channel);
}
};
}, []);
return onlineUsers;
}
Step 5: Building the Chat Interface
Create components/ChatRoom.tsx
:
"use client";
import { useState, useRef, useEffect } from "react";
import { supabase } from "@/lib/supabase/client";
import { useMessages, useOnlineUsers } from "@/hooks/useRealtime";
export default function ChatRoom() {
const [message, setMessage] = useState("");
const [userName, setUserName] = useState("");
const [isJoined, setIsJoined] = useState(false);
const { messages, loading } = useMessages();
const onlineUsers = useOnlineUsers();
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const joinChat = async () => {
if (!userName.trim()) return;
// Update user online status
await supabase.from("profiles").upsert({
name: userName,
online: true,
last_seen: new Date().toISOString(),
});
setIsJoined(true);
};
const leaveChat = async () => {
// Update user offline status
await supabase
.from("profiles")
.update({ online: false, last_seen: new Date().toISOString() })
.eq("name", userName);
setIsJoined(false);
};
const sendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim() || !userName) return;
await supabase.from("messages").insert({
content: message.trim(),
user_name: userName,
});
setMessage("");
};
// Cleanup on unmount
useEffect(() => {
return () => {
if (isJoined && userName) {
supabase
.from("profiles")
.update({ online: false })
.eq("name", userName);
}
};
}, [isJoined, userName]);
if (!isJoined) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-md w-full">
<h1 className="text-2xl font-bold text-center mb-6 text-gray-800">
Join the Chat 💬
</h1>
<div className="space-y-4">
<input
type="text"
placeholder="Enter your name"
value={userName}
onChange={(e) => setUserName(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
onKeyPress={(e) => e.key === "Enter" && joinChat()}
/>
<button
onClick={joinChat}
disabled={!userName.trim()}
className="w-full bg-blue-600 text-white p-3 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Join Chat
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar - Online Users */}
<div className="w-64 bg-white border-r border-gray-200 p-4">
<div className="flex justify-between items-center mb-4">
<h2 className="font-semibold text-gray-800">
Online ({onlineUsers.length})
</h2>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
</div>
<div className="space-y-2">
{onlineUsers.map((user) => (
<div
key={user.id}
className="flex items-center gap-2 p-2 rounded-lg bg-gray-50"
>
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm text-gray-700">{user.name}</span>
</div>
))}
</div>
<button
onClick={leaveChat}
className="mt-4 w-full text-sm text-red-600 hover:text-red-700 p-2 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
>
Leave Chat
</button>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col">
{/* Header */}
<div className="bg-white border-b border-gray-200 p-4">
<h1 className="text-xl font-semibold text-gray-800">
Real-time Chat
</h1>
<p className="text-sm text-gray-600">Welcome, {userName}! 👋</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{loading ? (
<div className="flex justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex ${
msg.user_name === userName ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-2xl ${
msg.user_name === userName
? "bg-blue-600 text-white"
: "bg-white text-gray-800 border border-gray-200"
}`}
>
{msg.user_name !== userName && (
<p className="text-xs font-medium text-gray-500 mb-1">
{msg.user_name}
</p>
)}
<p className="text-sm">{msg.content}</p>
<p
className={`text-xs mt-1 ${
msg.user_name === userName
? "text-blue-100"
: "text-gray-400"
}`}
>
{new Date(msg.created_at).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Message Input */}
<form
onSubmit={sendMessage}
className="p-4 bg-white border-t border-gray-200"
>
<div className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
className="flex-1 p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="submit"
disabled={!message.trim()}
className="bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Send
</button>
</div>
</form>
</div>
</div>
);
}
Step 6: Wire It All Up
Update your app/page.tsx
:
import ChatRoom from "@/components/ChatRoom";
export default function Home() {
return <ChatRoom />;
}
Step 7: Best Practices & Performance Tips
1. Connection Management
// Always clean up subscriptions
useEffect(() => {
const channel = supabase.channel("my-channel");
// ... setup subscription
return () => {
supabase.removeChannel(channel);
};
}, []);
2. Rate Limiting
// Configure events per second to prevent spam
const supabase = createClient(url, key, {
realtime: {
params: {
eventsPerSecond: 10, // Adjust based on your needs
},
},
});
3. Error Handling
channel.subscribe((status) => {
if (status === "SUBSCRIBED") {
console.log("Connected to real-time!");
} else if (status === "CHANNEL_ERROR") {
console.error("Real-time connection failed");
}
});
Testing Your Real-time App
Start your development server:
pnpm dev
Open multiple browser tabs to http://localhost:3000
and watch the magic happen! Messages appear instantly across all tabs. 🎉
Production Deployment Tips
- Environment Variables: Ensure your Supabase credentials are properly set
- Row Level Security: Implement proper RLS policies for production
- Connection Limits: Monitor your connection usage in Supabase dashboard
- Error Boundaries: Add React error boundaries for better UX
What's Next?
Ready to level up? Here are some exciting features to add:
- 🎨 Typing Indicators: Show when users are typing
- 📁 File Sharing: Add image and file upload support
- 🔔 Push Notifications: Browser notifications for new messages
- 🌙 Dark Mode: Because dark mode is always cool
- 📱 Mobile Optimization: Perfect mobile experience
Wrapping Up
Congratulations! 🎉 You've just built a real-time chat application with modern web technologies. The combination of Supabase's real-time capabilities with Next.js 15's performance makes for an incredibly smooth development experience.
Key takeaways:
- Real-time subscriptions are surprisingly simple with Supabase
- TypeScript keeps everything type-safe and maintainable
- Proper cleanup prevents memory leaks and connection issues
- PNPM makes dependency management a breeze
Found this helpful? Give it a ❤️ and follow me for more web development insights! I share practical guides like this every week.
What would you like to see next? Drop a comment with your suggestions – I love hearing from fellow developers!
Connect with me:
Happy coding! 🚀
Top comments (0)