DEV Community

Cover image for Building Real-time Magic: Supabase Subscriptions in Next.js 15 ⚡
Laxman Rathod
Laxman Rathod

Posted on

Building Real-time Magic: Supabase Subscriptions in Next.js 15 ⚡

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Add your environment variables to .env.local:

NEXT_PUBLIC_SUPABASE_URL=your_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Wire It All Up

Update your app/page.tsx:

import ChatRoom from "@/components/ChatRoom";

export default function Home() {
  return <ChatRoom />;
}
Enter fullscreen mode Exit fullscreen mode

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);
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

2. Rate Limiting

// Configure events per second to prevent spam
const supabase = createClient(url, key, {
  realtime: {
    params: {
      eventsPerSecond: 10, // Adjust based on your needs
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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");
  }
});
Enter fullscreen mode Exit fullscreen mode

Testing Your Real-time App

Start your development server:

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Open multiple browser tabs to http://localhost:3000 and watch the magic happen! Messages appear instantly across all tabs. 🎉

Production Deployment Tips

  1. Environment Variables: Ensure your Supabase credentials are properly set
  2. Row Level Security: Implement proper RLS policies for production
  3. Connection Limits: Monitor your connection usage in Supabase dashboard
  4. 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)