TL;DR: You'll build a real-time chat app where messages appear instantly for all connected users — no page refresh needed. One prompt to your AI gets you a working React + Supabase setup. This tutorial walks you through what AI generates, the real-time subscription model, optimistic updates, and the five things AI consistently gets wrong that will break your app in production. Total build time with AI: about 45 minutes.

Why Build This Project

Every beginner project — todo apps, calculators, portfolios — has something in common: nothing happens until the user clicks a button. Real-time is different. Data arrives without the user doing anything. That's a fundamentally different programming model, and it's what powers every app people actually use daily: Slack, Discord, WhatsApp, iMessage.

Building a chat app teaches you skills that don't show up in simpler projects:

  • Real-time data subscriptions — listening for changes instead of requesting them (the core of WebSockets)
  • Optimistic updates — showing the message immediately before the server confirms it, so the UI feels instant
  • State synchronization — keeping multiple users' screens in sync without conflicts
  • User authentication — connecting messages to real authenticated users, not just anonymous text
  • Security at the data layer — making sure users can only see messages they're supposed to see

If you've built a todo app and a portfolio, this is your next step. It's the project where you graduate from "I can build static things" to "I can build things that feel alive."

What You'll Build

The finished chat app has these features:

  • 💬 Real-time messaging — messages appear instantly for all connected users
  • 🔐 User authentication — sign up, log in, and messages tied to your account
  • 👤 User presence — see who's online right now
  • 📜 Message history — load previous messages when you join
  • ⬇️ Auto-scroll — new messages scroll into view automatically
  • Optimistic updates — your messages appear instantly, even before the server confirms
  • 🕐 Timestamps — see when each message was sent
  • 📱 Responsive layout — works on desktop and mobile

Step 1: The Initial Prompt

💬 Your Prompt to AI

"Build a real-time chat application in React with Vite. Use Supabase for the backend — authentication (email/password), a messages table, and Supabase Realtime for live message delivery. When a user sends a message, it should appear instantly for all connected users without refreshing. Include: login/signup form, message list with auto-scroll to bottom, message input with Enter-to-send, timestamps on each message, and user display names. Use Supabase Row Level Security so only authenticated users can read and insert messages. Dark theme. Make it responsive."

That prompt gives AI enough specifics to scaffold a working app. But "working" and "production-ready" are very different things. Here's what AI typically produces — and the five places it breaks.

Step 2: What AI Generates

Your AI will set up the Supabase client, create the authentication flow, and build the chat interface. Here's the core chat component it generates:

// src/Chat.tsx — AI-generated real-time chat
import { useState, useEffect, useRef } from 'react';
import { supabase } from './supabaseClient';
import type { User } from '@supabase/supabase-js';

interface Message {
  id: string;
  content: string;
  user_id: string;
  display_name: string;
  created_at: string;
}

export default function Chat({ user }: { user: User }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [newMessage, setNewMessage] = useState('');
  const [loading, setLoading] = useState(true);
  const messagesEndRef = useRef<HTMLDivElement>(null);

  // Fetch existing messages on mount
  useEffect(() => {
    const fetchMessages = async () => {
      const { data, error } = await supabase
        .from('messages')
        .select('*')
        .order('created_at', { ascending: true })
        .limit(100);

      if (error) {
        console.error('Error fetching messages:', error);
      } else {
        setMessages(data || []);
      }
      setLoading(false);
    };

    fetchMessages();
  }, []);

  // Subscribe to new messages in real time
  useEffect(() => {
    const channel = supabase
      .channel('messages')
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages',
        },
        (payload) => {
          const newMsg = payload.new as Message;
          setMessages((prev) => [...prev, newMsg]);
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, []);

  // Auto-scroll to bottom when new messages arrive
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  // Send a message
  const sendMessage = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!newMessage.trim()) return;

    const messageContent = newMessage.trim();
    setNewMessage('');

    const { error } = await supabase.from('messages').insert({
      content: messageContent,
      user_id: user.id,
      display_name: user.user_metadata?.display_name || user.email,
    });

    if (error) {
      console.error('Error sending message:', error);
      setNewMessage(messageContent); // Restore on failure
    }
  };

  if (loading) return <div className="chat-loading">Loading messages...</div>;

  return (
    <div className="chat-container">
      <div className="chat-header">
        <h2>💬 Chat Room</h2>
        <span className="user-info">
          {user.user_metadata?.display_name || user.email}
        </span>
      </div>

      <div className="messages-list">
        {messages.map((msg) => (
          <div
            key={msg.id}
            className={`message ${msg.user_id === user.id ? 'own' : ''}`}
          >
            <div className="message-header">
              <span className="message-author">{msg.display_name}</span>
              <span className="message-time">
                {new Date(msg.created_at).toLocaleTimeString()}
              </span>
            </div>
            <div className="message-content">{msg.content}</div>
          </div>
        ))}
        <div ref={messagesEndRef} />
      </div>

      <form onSubmit={sendMessage} className="message-form">
        <input
          type="text"
          value={newMessage}
          onChange={(e) => setNewMessage(e.target.value)}
          placeholder="Type a message..."
          className="message-input"
          autoFocus
        />
        <button type="submit" className="send-button">Send</button>
      </form>
    </div>
  );
}

And AI will also generate the Supabase client setup and the SQL for your messages table:

-- SQL: Create the messages table in Supabase
create table public.messages (
  id uuid default gen_random_uuid() primary key,
  content text not null,
  user_id uuid references auth.users(id) not null,
  display_name text not null,
  created_at timestamptz default now() not null
);

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

-- Policy: Authenticated users can read all messages
create policy "Anyone can read messages"
  on public.messages for select
  to authenticated
  using (true);

-- Policy: Authenticated users can insert their own messages
create policy "Users can insert own messages"
  on public.messages for insert
  to authenticated
  with check (auth.uid() = user_id);

-- Enable Realtime for the messages table
alter publication supabase_realtime add table public.messages;

This looks solid. The SQL sets up Supabase Row Level Security so only logged-in users can read or write messages, and users can only insert messages tagged with their own ID. The React component subscribes to real-time changes and updates the UI. But there are critical problems hiding in this code.

Step 3: Understanding What AI Built

Supabase Real-Time Subscriptions

The magic happens in the second useEffect. Instead of your app asking "are there new messages?" every few seconds (that's called polling), Supabase pushes new messages to your app the moment they're inserted into the database. Under the hood, this uses WebSockets — a persistent connection between your browser and Supabase's servers.

Think of it like this: polling is you calling a restaurant every 5 minutes asking "is my table ready?" WebSockets is the restaurant texting you when it's ready. One wastes everyone's time. The other just works.

The .on('postgres_changes', ...) listener tells Supabase: "whenever a new row is inserted into the messages table, send it to me." Your callback receives the new row as payload.new and appends it to the message list.

Message State Management

The messages state array is the single source of truth for what's displayed. Three things modify it:

  1. Initial fetch — loads the last 100 messages from the database on mount
  2. Real-time subscription — appends new messages as they arrive via WebSocket
  3. Optimistic updates — (when added) shows your message immediately before server confirmation

Notice how setMessages((prev) => [...prev, newMsg]) uses the functional updater form. This is important — it ensures you're always working with the latest state, even if multiple messages arrive in rapid succession. If AI wrote setMessages([...messages, newMsg]) instead (using the stale closure), messages would get lost during fast conversations.

Optimistic Updates

Look at the sendMessage function. Right now it clears the input, sends to Supabase, and waits for the real-time subscription to add the message back to the list. That means there's a visible delay — you hit Send, the input clears, and your message doesn't appear for 200-500ms while it round-trips through the server.

A better pattern: add the message to local state immediately, then let the server confirm it. If the insert fails, remove it and show an error. This is called an optimistic update — you're optimistic that the server will accept it, and you handle the rare failure case separately. Every chat app you've ever used does this.

Step 4: What AI Gets Wrong (and How to Fix It)

⚠️ AI Failure #1: Not Unsubscribing from Real-Time Channels

About 40% of the time, AI forgets the cleanup function in the subscription useEffect. It creates the channel but never calls supabase.removeChannel(channel) when the component unmounts. This causes memory leaks — every time the Chat component re-mounts (like navigating away and back), a new WebSocket subscription stacks on top of the old one. After a few navigations, you're receiving duplicate messages. Always check that the useEffect returns a cleanup function that removes the channel.

⚠️ AI Failure #2: Missing Scroll-to-Bottom Logic

AI usually adds scrollIntoView on the last message, but it does it every time any message arrives — even when you've scrolled up to read older messages. Someone sends a message while you're reading history? You get yanked to the bottom. The fix: only auto-scroll if the user is already near the bottom. Check scrollHeight - scrollTop - clientHeight < 100 before calling scrollIntoView. Tell your AI: "Only auto-scroll to new messages if the user is already scrolled to the bottom of the chat."

⚠️ AI Failure #3: No Message Ordering Guarantee

The initial fetch orders messages by created_at, but the real-time subscription just appends new messages to the end of the array. If two users send messages at nearly the same time, or if a message arrives out of order due to network latency, your chat displays them in the wrong sequence. Fix: after appending a real-time message, sort the array by created_at. Or better yet, use the message id (UUID v7 if available) which is naturally ordered. Tell your AI: "Sort messages by created_at after every real-time insert to prevent ordering issues."

⚠️ AI Failure #4: Auth Not Connected to Messages

AI often generates the auth flow and the chat component separately — and forgets to connect them properly. You'll see user_id set to a hardcoded value, or the display name defaulting to "Anonymous" because AI didn't pull it from the auth session. Check that user.id comes from supabase.auth.getUser() and that the display name comes from the user's metadata set during signup. If your messages show the wrong user, this is why.

⚠️ AI Failure #5: XSS Vulnerability in Message Content

The generated code renders message content as plain text inside a <div>, which is mostly safe in React because JSX auto-escapes strings. But if you later add Markdown support, link previews, or HTML formatting — or if AI uses dangerouslySetInnerHTML anywhere — you've opened a cross-site scripting (XSS) hole. Someone types <img src=x onerror=alert('hacked')> and every user's browser executes it. Always sanitize with DOMPurify if you render any HTML, and never trust message content from other users.

Step 5: Level-Up Prompts

Once the base chat works, use these prompts to add features that real chat apps have:

💡 Enhancement Prompts
  • Typing indicators: "Add typing indicators using Supabase Realtime presence. When a user is typing, show 'User is typing...' below the message list. Debounce the typing status so it disappears 2 seconds after they stop typing."
  • Read receipts: "Add read receipts. Track the last message each user has seen using a separate 'read_receipts' table. Show a small checkmark next to messages that have been read by the recipient."
  • File sharing: "Add image and file sharing. Use Supabase Storage to upload files and store the file URL in the message. Show image previews inline and download links for other file types. Limit uploads to 5MB."
  • Emoji reactions: "Add emoji reactions to messages. Users can click a reaction button on any message to add an emoji. Show reaction counts below each message. Store reactions in a separate 'reactions' table with a unique constraint on user_id + message_id + emoji."
  • Multiple rooms: "Add chat rooms. Create a 'rooms' table. Users can create rooms, join existing ones, and switch between them. Only subscribe to real-time messages for the active room."

What You Learned

This project taught you skills that power every modern real-time application:

  • Real-time subscriptions — the push-based data model behind WebSockets, used in Slack, Discord, multiplayer games, live dashboards, and collaborative editors
  • Optimistic updates — making UIs feel instant by updating locally before server confirmation, the same pattern used by every social media app's "like" button
  • Authentication-linked data — connecting user identity to data with Row Level Security, so your database enforces permissions even if your frontend code has bugs
  • Subscription lifecycle management — creating and cleaning up real-time connections to prevent memory leaks and duplicate data
  • XSS prevention in user content — a security fundamental for any app that displays content from other users

The real-time chat pattern extends far beyond messaging. Add Supabase presence and you have a collaborative whiteboard. Swap messages for cursor positions and you have Google Docs-style collaboration. Swap messages for game state and you have multiplayer. Once you understand subscriptions, you can build any of these.

Frequently Asked Questions

Not directly. Supabase handles the WebSocket connection behind the scenes. You subscribe to a database table and Supabase pushes new rows to your app automatically. You should understand the concept — data pushed to you instead of you asking for it — but you don't need to write WebSocket code yourself. Supabase's real-time library abstracts it away.

Yes. Supabase's JavaScript client works with any framework or vanilla JS. Tell your AI "build this chat app in plain HTML/CSS/JavaScript with Supabase real-time instead of React" and it will generate a working version. React just gives you cleaner state management for a chat UI with lots of dynamic updates.

Supabase's free tier includes 500MB database storage, 2GB bandwidth, and 200 concurrent real-time connections. That's more than enough for a personal project or small team. You'll only hit limits if you have hundreds of simultaneous users chatting — at which point you've built something worth paying for.

Add a "rooms" table and associate messages with a room_id instead of a single global channel. Create a room for each pair of users and filter real-time subscriptions by room_id. Tell your AI: "Add private messaging — create a rooms table, let users start conversations, and only subscribe to rooms the current user belongs to." Row Level Security on the messages table ensures users can only read messages from their own rooms.

Supabase's client automatically reconnects when the connection drops. But messages sent while disconnected won't appear until reconnection — and they may arrive out of order. Add a "reconnected" handler that fetches missed messages from the database to fill gaps. Tell your AI: "Add reconnection handling that fetches messages sent since the last received timestamp."