Back to Tutorials

Build a Real-time Chat with Next.js 15 and Supabase

April 6, 2026
1 min read
Explore Your Brain Editorial Team

Explore Your Brain Editorial Team

Science Communication

Science Communication Certified
Peer-Reviewed by Domain Experts

Real-time communication is the beating heart of modern interactive web applications. Whether you are engineering a live-bidding auction notification system, a multiplayer collaborative document editor, or a Slack-clone direct messaging platform, modern users do not aggressively refresh their browsers; they expect data to synchronize instantly.

Historically, achieving this required maintaining your own brittle WebSocket infrastructure, handling aggressive state-sync edge cases, and manually reconciling database writes with ephemeral pub/sub events. In this masterclass, we will eliminate that complexity by building a professional chat application utilizing Next.js 15 and Supabase's heavily optimized Realtime engine.

1. Database Schema and Security Setup

The foundation begins in our PostgreSQL database. We must create a robust messages table. More importantly, to instruct Supabase to forward mutations to connected clients via WebSockets, we must explicitly enable Row-Level Security (RLS) and allocate the table to the Realtime publication loop.

          -- 1. Scaffold the core table schema
CREATE TABLE messages (
  id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
  content text NOT NULL,
  sender_id uuid REFERENCES auth.users NOT NULL,
  room_id text NOT NULL,
  created_at timestamp with time zone DEFAULT now()
);

-- 2. Strictly secure the table
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Authenticated users can read messages in their rooms" 
ON messages FOR SELECT 
TO authenticated USING (true);

CREATE POLICY "Users can insert their own messages" 
ON messages FOR INSERT 
TO authenticated WITH CHECK (auth.uid() = sender_id);

-- 3. Explicitly toggle the Realtime CDC pipeline
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
ALTER TABLE messages REPLICA IDENTITY FULL;
        

2. Subscribing to Postgres Changes (React Client)

The magic operates by subscribing to Change Data Capture (CDC) events directly on the client. As soon as another user writes to the database, your React component will cleanly append it to the local UI state. We utilize a React useEffect to maintain the WebSocket channel lifecycle.

          'use client'
import { useEffect, useState } from 'react';
import { createClient } from '@/utils/supabase/client';

export default function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const supabase = createClient();

  useEffect(() => {
    // 1. Initialize the channel specifically targeting our room ID
    const channel = supabase.channel(\`room:\${roomId}\`);

    // 2. Bind the postgres CDC listener
    channel.on(
      'postgres_changes',
      { 
        event: 'INSERT', 
        schema: 'public', 
        table: 'messages',
        filter: \`room_id=eq.\${roomId}\` // Only listen for THIS room
      },
      (payload) => {
        // Automatically append incoming massive to React state
        setMessages((current) => [...current, payload.new]);
      }
    ).subscribe();

    // 3. Prevent memory leaks gracefully
    return () => {
      supabase.removeChannel(channel);
    };
  }, [roomId]);

  return <ChatUI messages={messages} />;
}
      

3. Engineering Live User Presence

Displaying a static chat log is boring. Sharing exactly who is active online makes an application feel vibrant. Rather than building a complicated heartbeat timer, we tap into Supabase Presence, which utilizes an optimized CRDT (Conflict-free Replicated Data Type) engine underneath.

          useEffect(() => {
  const presenceChannel = supabase.channel(\`presence:\${roomId}\`);

  presenceChannel
    .on('presence', { event: 'sync' }, () => {
      // Fires immediately when anyone joins or aggressively disconnects
      const newState = presenceChannel.presenceState();
      
      // Flatten the complex CRDT map into a simple online users array
      const activeIds = Object.keys(newState);
      setOnlineUsers(activeIds);
    })
    .subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        // Broadcast our own identity into the vortex
        await presenceChannel.track({ 
            user_id: user.id, 
            status: 'online',
            online_at: new Date().toISOString() 
        });
      }
    });

    return () => { presenceChannel.unsubscribe(); };
}, []);
        

Conclusion

By merging the hyper-optimized UI rendering paradigms of Next.js 15 with Supabase's heavily abstracted Realtime data pipelines, we've bypassed months of backend infrastructure hurdles, engineering a highly scalable, production-grade chat system in mere hours. To extend this further, consider employing Supabase Storage to allow multi-megabyte image attachments, or implementing raw Broadcast channels for ephemeral "User A is typing..." UI indicators.

Explore Your Brain Editorial Team

About Explore Your Brain Editorial Team

Science Communication

Our editorial team consists of science writers, researchers, and educators dedicated to making complex scientific concepts accessible to everyone. We review all content with subject matter experts to ensure accuracy and clarity.

Science Communication CertifiedPeer-Reviewed by Domain ExpertsEditorial Standards: AAAS GuidelinesFact-Checked by Research Librarians

Frequently Asked Questions

Do I need to manage WebSockets on a separate Express backend?

No! That is the primary magic of Supabase Realtime. It operates as a globally distributed WebSocket layer tightly coupled to your PostgreSQL database. You don't need to spin up Socket.io or maintain persistent server infrastructure. You simply subscribe to the channels you want using the Supabase client library directly inside your frontend components.

Is Supabase Realtime definitively secure? Can users inject malicious events?

Yes, it is highly secure when configured correctly. Because Supabase Realtime integrates intimately with Postgres, all streaming events are bound by your Row-Level Security (RLS) policies. Even maliciously connecting to the WebSocket channel via a script will immediately fail if the user's JWT lacks the permission to 'SELECT' from the protected tables.

How does the 'Presence' feature differ from 'Broadcast'?

Presence is specifically engineered to track application state across distributed clients (e.g., 'Who is currently online in this specific chat room?'). It automatically handles sudden client disconnects and internet dropouts without ghosts. Broadcast is a low-latency pub/sub mechanism meant for ephemeral messages (e.g., 'User A is currently typing...') that do not require database persistence.

References