Welcome to Part 3: Advanced Projects. We are done with quick builds and half-day apps. Now we tackle complex, multi-component systems.

First up: a real-time chat application. WebSockets, authentication, rooms, typing indicators, online presence, message history. This is the kind of project that tests whether Claude can handle multiple moving parts that all need to work together in real time.

Total time: 4 hours 38 minutes. 7 prompts. WebSocket auth was the hardest part.

What We’re Building

A real-time chat application with:

  • User registration and login with JWT tokens
  • Chat rooms — create, join, and leave rooms
  • Real-time messaging via Socket.io
  • Message history persisted in PostgreSQL
  • Typing indicators — “Alex is typing…” with debounce
  • Online user list per room — see who is in the room right now
  • Direct messages (DMs) between users
  • File and image sharing — upload and display inline
  • Message reactions — emoji reactions like Slack
  • Unread message count badges on room list

Tech stack: Node.js (Express + Socket.io), React, PostgreSQL (Supabase), JWT auth, Tailwind CSS

Prerequisites

  • Claude Code installed and configured
  • Node.js 18+ installed
  • A Supabase account (free tier)
  • Basic understanding of WebSockets (helpful but not required)

The Build Session

Total time: 4 hours 38 minutes. 7 prompts.

Prompt 1: Project Scaffolding — Backend + Frontend + Auth (Minute 0)

For a real-time app, the backend and frontend need to be set up together. I gave Claude a detailed first prompt:

Create a real-time chat application with this structure:

Backend (server/):
1. Express + Socket.io server
2. PostgreSQL with Prisma ORM
3. JWT authentication (register, login, refresh token)
4. Database schema:
   - User: id, username, email, passwordHash, avatarUrl, createdAt
   - Room: id, name, description, createdBy, isPrivate, createdAt
   - RoomMember: userId, roomId, joinedAt
   - Message: id, content, userId, roomId, type (text/image/file), createdAt
5. REST endpoints:
   - POST /api/auth/register
   - POST /api/auth/login
   - POST /api/auth/refresh
   - GET /api/rooms (list rooms user is in)
   - POST /api/rooms (create room)
   - GET /api/rooms/:id/messages (paginated history)
6. Socket.io events:
   - join-room, leave-room
   - send-message, new-message
   - typing-start, typing-stop, user-typing
   - user-online, user-offline

Frontend (client/):
1. React with Vite + Tailwind CSS
2. Pages: Login, Register, Chat (main app)
3. Chat layout: sidebar with room list, main area with messages, header with room info
4. Socket.io client connecting to the backend
5. JWT token stored in memory (not localStorage) with refresh flow

What Claude generated:

Claude created the full project structure in one pass. The backend had clean separation:

server/
  src/
    middleware/
      auth.ts           # JWT verification middleware
    routes/
      auth.ts           # Register, login, refresh
      rooms.ts          # CRUD rooms, get messages
    socket/
      handlers.ts       # Socket.io event handlers
      middleware.ts      # Socket auth middleware
    prisma/
      schema.prisma     # Database schema
    index.ts            # Express + Socket.io setup

The Prisma schema:

model User {
  id           String   @id @default(cuid())
  username     String   @unique
  email        String   @unique
  passwordHash String
  avatarUrl    String?
  createdAt    DateTime @default(now())
  messages     Message[]
  rooms        RoomMember[]
  createdRooms Room[]
}

model Room {
  id          String   @id @default(cuid())
  name        String
  description String?
  isPrivate   Boolean  @default(false)
  createdBy   String
  createdAt   DateTime @default(now())
  creator     User     @relation(fields: [createdBy], references: [id])
  members     RoomMember[]
  messages    Message[]
}

model RoomMember {
  userId   String
  roomId   String
  joinedAt DateTime @default(now())
  user     User     @relation(fields: [userId], references: [id])
  room     Room     @relation(fields: [roomId], references: [id])

  @@id([userId, roomId])
}

model Message {
  id        String   @id @default(cuid())
  content   String
  type      String   @default("text")
  userId    String
  roomId    String
  createdAt DateTime @default(now())
  user      User     @relation(fields: [userId], references: [id])
  room      Room     @relation(fields: [roomId], references: [id])

  @@index([roomId, createdAt])
}

The Socket.io setup with JWT auth:

import { Server } from "socket.io";
import jwt from "jsonwebtoken";

const io = new Server(httpServer, {
  cors: { origin: "http://localhost:5173", credentials: true },
});

// Socket authentication middleware
io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  if (!token) return next(new Error("Authentication required"));

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!);
    socket.data.userId = (payload as any).userId;
    socket.data.username = (payload as any).username;
    next();
  } catch {
    next(new Error("Invalid token"));
  }
});

The frontend had a clean chat layout:

function ChatPage() {
  return (
    <div className="flex h-screen bg-gray-900 text-white">
      {/* Sidebar - Room List */}
      <div className="w-72 border-r border-gray-700 flex flex-col">
        <div className="p-4 border-b border-gray-700">
          <h2 className="font-semibold text-lg">Rooms</h2>
          <button
            onClick={() => setShowCreateRoom(true)}
            className="mt-2 w-full py-2 bg-blue-600 rounded-lg text-sm"
          >
            + New Room
          </button>
        </div>
        <RoomList rooms={rooms} activeRoom={activeRoom} onSelect={setActiveRoom} />
      </div>

      {/* Main Chat Area */}
      <div className="flex-1 flex flex-col">
        <ChatHeader room={activeRoom} onlineUsers={onlineUsers} />
        <MessageList messages={messages} currentUser={user} />
        <TypingIndicator typingUsers={typingUsers} />
        <MessageInput onSend={sendMessage} onTyping={handleTyping} />
      </div>
    </div>
  );
}

My review: The structure was solid. Clean separation of concerns, proper TypeScript throughout, and the database schema made sense. The composite index on [roomId, createdAt] for messages was a good touch — it optimizes the most common query (get recent messages for a room).

Problem: The Socket.io authentication middleware used socket.handshake.auth.token, but the frontend was sending the token in the query string instead of the auth object. Quick fix — I updated the frontend socket initialization:

const socket = io("http://localhost:3001", {
  auth: { token: accessToken },
});

Time spent: 42 minutes

Prompt 2: Real-Time Messaging and Room Join/Leave (Minute 42)

Now wire up the real-time messaging:

1. When a user joins a room:
   - Add to Socket.io room
   - Broadcast "user joined" event to room members
   - Send last 50 messages as initial history
   - Update online user list for all room members

2. When a user sends a message:
   - Save to database
   - Broadcast to all room members
   - Include sender info (username, avatar) in the event

3. When a user leaves a room:
   - Remove from Socket.io room
   - Broadcast "user left" event
   - Update online user list

4. Handle disconnection:
   - Remove from all rooms
   - Broadcast offline status to all rooms the user was in

5. Message format: { id, content, type, user: { id, username, avatarUrl }, createdAt }

What Claude generated:

The socket handler file:

export function registerSocketHandlers(io: Server, socket: Socket) {
  const userId = socket.data.userId;
  const username = socket.data.username;

  // Track which rooms this socket is in
  const userRooms = new Set<string>();

  socket.on("join-room", async (roomId: string) => {
    // Verify membership
    const member = await prisma.roomMember.findUnique({
      where: { userId_roomId: { userId, roomId } },
    });
    if (!member) {
      socket.emit("error", { message: "Not a member of this room" });
      return;
    }

    socket.join(roomId);
    userRooms.add(roomId);

    // Send message history
    const messages = await prisma.message.findMany({
      where: { roomId },
      include: { user: { select: { id: true, username: true, avatarUrl: true } } },
      orderBy: { createdAt: "desc" },
      take: 50,
    });
    socket.emit("message-history", messages.reverse());

    // Get online users in room
    const socketsInRoom = await io.in(roomId).fetchSockets();
    const onlineUserIds = [...new Set(socketsInRoom.map((s) => s.data.userId))];
    const onlineUsers = await prisma.user.findMany({
      where: { id: { in: onlineUserIds } },
      select: { id: true, username: true, avatarUrl: true },
    });

    io.to(roomId).emit("online-users", onlineUsers);
    socket.to(roomId).emit("user-joined", { userId, username });
  });

  socket.on("send-message", async (data: { roomId: string; content: string }) => {
    const message = await prisma.message.create({
      data: {
        content: data.content,
        userId,
        roomId: data.roomId,
        type: "text",
      },
      include: { user: { select: { id: true, username: true, avatarUrl: true } } },
    });

    io.to(data.roomId).emit("new-message", message);
  });

  socket.on("leave-room", (roomId: string) => {
    socket.leave(roomId);
    userRooms.delete(roomId);
    socket.to(roomId).emit("user-left", { userId, username });
  });

  socket.on("disconnect", () => {
    for (const roomId of userRooms) {
      socket.to(roomId).emit("user-left", { userId, username });
    }
  });
}

The frontend useChat hook:

function useChat(socket: Socket | null, roomId: string | null) {
  const [messages, setMessages] = useState<Message[]>([]);

  useEffect(() => {
    if (!socket || !roomId) return;

    socket.emit("join-room", roomId);

    socket.on("message-history", (history: Message[]) => {
      setMessages(history);
    });

    socket.on("new-message", (message: Message) => {
      setMessages((prev) => [...prev, message]);
    });

    return () => {
      socket.emit("leave-room", roomId);
      socket.off("message-history");
      socket.off("new-message");
    };
  }, [socket, roomId]);

  const sendMessage = (content: string) => {
    if (!socket || !roomId) return;
    socket.emit("send-message", { roomId, content });
  };

  return { messages, sendMessage };
}

My review: The real-time messaging worked. I opened two browser windows, joined the same room, and messages appeared instantly in both. The online user list updated when users joined and left.

Problem: When a user was connected to multiple tabs, logging out of one tab removed them from the online list even though their other tab was still connected. The fetchSockets() approach fixed this — it checks all connected sockets, not just the one that disconnected. But the user-left event was still broadcast on disconnect. I needed to check if the user had other active sockets in the room before broadcasting.

socket.on("disconnect", async () => {
  for (const roomId of userRooms) {
    // Check if user has other sockets in this room
    const socketsInRoom = await io.in(roomId).fetchSockets();
    const stillConnected = socketsInRoom.some((s) => s.data.userId === userId);

    if (!stillConnected) {
      socket.to(roomId).emit("user-left", { userId, username });
    }
  }
});

Time spent: 35 minutes

Prompt 3: Typing Indicators (Minute 77)

Add typing indicators:

1. Frontend: when user types in the message input, emit "typing-start" event
2. Debounce: stop emitting after 2 seconds of no typing
3. Backend: broadcast "user-typing" event to other room members (not the sender)
4. Frontend: show "Alex is typing..." below the message list
5. If multiple users typing: "Alex and Sam are typing..."
6. If more than 2: "3 people are typing..."
7. Clear typing indicator after 3 seconds of no "typing-start" from that user

What Claude generated:

Backend handler:

socket.on("typing-start", (roomId: string) => {
  socket.to(roomId).emit("user-typing", { userId, username, roomId });
});

socket.on("typing-stop", (roomId: string) => {
  socket.to(roomId).emit("user-stop-typing", { userId, roomId });
});

Frontend typing hook:

function useTypingIndicator(socket: Socket | null, roomId: string | null) {
  const [typingUsers, setTypingUsers] = useState<Map<string, string>>(new Map());
  const timeoutsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
  const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    if (!socket || !roomId) return;

    socket.on("user-typing", ({ userId, username }: { userId: string; username: string }) => {
      setTypingUsers((prev) => new Map(prev).set(userId, username));

      // Clear after 3 seconds
      const existing = timeoutsRef.current.get(userId);
      if (existing) clearTimeout(existing);

      timeoutsRef.current.set(
        userId,
        setTimeout(() => {
          setTypingUsers((prev) => {
            const next = new Map(prev);
            next.delete(userId);
            return next;
          });
        }, 3000)
      );
    });

    socket.on("user-stop-typing", ({ userId }: { userId: string }) => {
      setTypingUsers((prev) => {
        const next = new Map(prev);
        next.delete(userId);
        return next;
      });
    });

    return () => {
      socket.off("user-typing");
      socket.off("user-stop-typing");
      timeoutsRef.current.forEach((t) => clearTimeout(t));
    };
  }, [socket, roomId]);

  const emitTyping = useCallback(() => {
    if (!socket || !roomId) return;

    socket.emit("typing-start", roomId);

    if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
    typingTimeoutRef.current = setTimeout(() => {
      socket.emit("typing-stop", roomId);
    }, 2000);
  }, [socket, roomId]);

  return { typingUsers, emitTyping };
}

The typing indicator component:

function TypingIndicator({ typingUsers }: { typingUsers: Map<string, string> }) {
  if (typingUsers.size === 0) return null;

  const names = Array.from(typingUsers.values());
  let text: string;

  if (names.length === 1) {
    text = `${names[0]} is typing...`;
  } else if (names.length === 2) {
    text = `${names[0]} and ${names[1]} are typing...`;
  } else {
    text = `${names.length} people are typing...`;
  }

  return (
    <div className="px-4 py-1 text-sm text-gray-400 flex items-center gap-2">
      <span className="flex gap-1">
        <span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" />
        <span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce delay-100" />
        <span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce delay-200" />
      </span>
      {text}
    </div>
  );
}

My review: The typing indicators worked well. The debounce prevented flooding the WebSocket with events, and the 3-second timeout on the receiving end cleared stale indicators. The bouncing dots animation was a nice touch.

No issues. Worked on first try.

Time spent: 15 minutes

Prompt 4: Direct Messages (Minute 92)

Add direct messages (DMs):

1. When user clicks another user's name, start a DM conversation
2. DM conversations appear in the sidebar under a "Direct Messages" section
3. Backend: create a private room with exactly 2 members
4. If a DM room already exists between those 2 users, reuse it
5. DM rooms show the other user's name and avatar, not "Room name"
6. Messages work the same as room messages (same events, same storage)

What Claude generated:

A new endpoint for starting DMs:

router.post("/api/dm/:targetUserId", authMiddleware, async (req, res) => {
  const userId = req.user.id;
  const targetUserId = req.params.targetUserId;

  if (userId === targetUserId) {
    return res.status(400).json({ error: "Cannot DM yourself" });
  }

  // Check if DM room already exists
  const existingRoom = await prisma.room.findFirst({
    where: {
      isPrivate: true,
      AND: [
        { members: { some: { userId } } },
        { members: { some: { userId: targetUserId } } },
      ],
      members: { every: { userId: { in: [userId, targetUserId] } } },
    },
    include: {
      members: {
        include: { user: { select: { id: true, username: true, avatarUrl: true } } },
      },
    },
  });

  if (existingRoom) return res.json(existingRoom);

  // Create new DM room
  const targetUser = await prisma.user.findUnique({ where: { id: targetUserId } });
  if (!targetUser) return res.status(404).json({ error: "User not found" });

  const room = await prisma.room.create({
    data: {
      name: `dm-${userId}-${targetUserId}`,
      isPrivate: true,
      createdBy: userId,
      members: {
        create: [{ userId }, { userId: targetUserId }],
      },
    },
    include: {
      members: {
        include: { user: { select: { id: true, username: true, avatarUrl: true } } },
      },
    },
  });

  return res.json(room);
});

The sidebar DM section:

function Sidebar({ rooms, dmRooms, currentUser, activeRoom, onSelect, onStartDM }) {
  return (
    <div className="flex flex-col h-full">
      {/* Rooms section */}
      <div className="p-3 text-xs uppercase text-gray-500 font-semibold">Rooms</div>
      {rooms.map((room) => (
        <RoomItem key={room.id} room={room} active={activeRoom?.id === room.id} onClick={() => onSelect(room)} />
      ))}

      {/* DMs section */}
      <div className="p-3 text-xs uppercase text-gray-500 font-semibold mt-4">
        Direct Messages
      </div>
      {dmRooms.map((room) => {
        const otherUser = room.members.find((m) => m.userId !== currentUser.id)?.user;
        return (
          <div
            key={room.id}
            onClick={() => onSelect(room)}
            className={`flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-gray-700 ${
              activeRoom?.id === room.id ? "bg-gray-700" : ""
            }`}
          >
            <div className="w-8 h-8 rounded-full bg-gray-600 flex items-center justify-center text-sm">
              {otherUser?.username?.[0]?.toUpperCase()}
            </div>
            <span className="text-sm">{otherUser?.username}</span>
          </div>
        );
      })}
    </div>
  );
}

My review: DMs worked. The “find existing or create new” pattern was correct. The Prisma query for finding existing DM rooms was complex but correct — it checks for a private room where both users are members and no one else is.

Problem: The every clause in the Prisma query was wrong. It checked that every member was in the list, but it did not check that the room had exactly 2 members. A group chat that happened to include both users would also match. I added a count check:

const existingRoom = await prisma.room.findFirst({
  where: {
    isPrivate: true,
    members: {
      every: { userId: { in: [userId, targetUserId] } },
    },
    AND: [
      { members: { some: { userId } } },
      { members: { some: { userId: targetUserId } } },
    ],
  },
});

// Verify exactly 2 members
if (existingRoom) {
  const memberCount = await prisma.roomMember.count({
    where: { roomId: existingRoom.id },
  });
  if (memberCount === 2) return res.json(existingRoom);
}

Not elegant, but it works. In production, I would add a type field to the Room model (group vs dm).

Time spent: 28 minutes

Prompt 5: File and Image Sharing (Minute 120)

Add file and image sharing in chat:

1. Upload endpoint: POST /api/upload
   - Accept images (jpg, png, gif, webp) and files (pdf, txt, zip)
   - Max size: 5MB
   - Store in Supabase Storage bucket "chat-files"
   - Return the public URL

2. Message types:
   - "text"  regular message
   - "image"  displays inline with the image
   - "file"  shows as a downloadable link with filename and size

3. Frontend: add a file upload button next to the message input
   - Show upload progress
   - Preview images before sending
   - After upload, send a message with type "image" or "file" and the URL as content

What Claude generated:

The upload endpoint with multer:

import multer from "multer";
import { createClient } from "@supabase/supabase-js";

const upload = multer({
  limits: { fileSize: 5 * 1024 * 1024 },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "text/plain", "application/zip"];
    if (allowed.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("File type not allowed"));
    }
  },
});

router.post("/api/upload", authMiddleware, upload.single("file"), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: "No file provided" });

  const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!);
  const filename = `${Date.now()}-${req.file.originalname}`;

  const { data, error } = await supabase.storage
    .from("chat-files")
    .upload(filename, req.file.buffer, {
      contentType: req.file.mimetype,
    });

  if (error) return res.status(500).json({ error: "Upload failed" });

  const { data: urlData } = supabase.storage.from("chat-files").getPublicUrl(filename);

  res.json({
    url: urlData.publicUrl,
    filename: req.file.originalname,
    size: req.file.size,
    type: req.file.mimetype.startsWith("image/") ? "image" : "file",
  });
});

The message renderer with image and file support:

function MessageBubble({ message, isOwn }: { message: Message; isOwn: boolean }) {
  return (
    <div className={`flex ${isOwn ? "justify-end" : "justify-start"} mb-2`}>
      <div className={`max-w-md px-4 py-2 rounded-lg ${isOwn ? "bg-blue-600" : "bg-gray-700"}`}>
        {!isOwn && (
          <p className="text-xs text-blue-400 mb-1">{message.user.username}</p>
        )}

        {message.type === "text" && <p className="text-sm">{message.content}</p>}

        {message.type === "image" && (
          <img
            src={message.content}
            alt="Shared image"
            className="max-w-full rounded-lg cursor-pointer"
            onClick={() => window.open(message.content, "_blank")}
          />
        )}

        {message.type === "file" && (
          <a
            href={message.content}
            target="_blank"
            rel="noopener noreferrer"
            className="flex items-center gap-2 text-sm text-blue-300 hover:text-blue-200"
          >
            <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
                d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
            </svg>
            {message.metadata?.filename || "Download file"}
            {message.metadata?.size && (
              <span className="text-gray-400">({formatBytes(message.metadata.size)})</span>
            )}
          </a>
        )}

        <p className="text-xs text-gray-400 mt-1">
          {new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
        </p>
      </div>
    </div>
  );
}

My review: File upload worked. Images displayed inline, files showed as download links. The Supabase Storage integration was clean.

Problem: Claude forgot to add a metadata field to the Message model in Prisma. The file name and size were lost after upload. I added a metadata JSON field:

model Message {
  id        String   @id @default(cuid())
  content   String
  type      String   @default("text")
  metadata  Json?
  userId    String
  roomId    String
  createdAt DateTime @default(now())
  // ... relations
}

And updated the send-message handler to include metadata when the message type is file or image.

Time spent: 32 minutes

Prompt 6: Message Reactions (Minute 152)

Add emoji reactions to messages (like Slack):

1. Database: Reaction model  messageId, userId, emoji (string like "thumbsup", "heart", "laugh")
2. Socket event: "add-reaction" and "remove-reaction"
3. UI: hover over a message to see a reaction picker (5 common emojis: thumbsup, heart, laugh, surprised, sad)
4. Show reaction counts below the message
5. Clicking an existing reaction toggles it (add if not reacted, remove if already reacted)
6. Show who reacted on hover (tooltip)

What Claude generated:

New Prisma model:

model Reaction {
  id        String   @id @default(cuid())
  emoji     String
  userId    String
  messageId String
  createdAt DateTime @default(now())
  user      User     @relation(fields: [userId], references: [id])
  message   Message  @relation(fields: [messageId], references: [id])

  @@unique([userId, messageId, emoji])
}

Socket handlers:

socket.on("add-reaction", async (data: { messageId: string; emoji: string; roomId: string }) => {
  try {
    const reaction = await prisma.reaction.create({
      data: {
        emoji: data.emoji,
        userId,
        messageId: data.messageId,
      },
      include: { user: { select: { id: true, username: true } } },
    });
    io.to(data.roomId).emit("reaction-added", {
      messageId: data.messageId,
      reaction,
    });
  } catch {
    // Unique constraint violation — already reacted, so remove it
    await prisma.reaction.deleteMany({
      where: { userId, messageId: data.messageId, emoji: data.emoji },
    });
    io.to(data.roomId).emit("reaction-removed", {
      messageId: data.messageId,
      userId,
      emoji: data.emoji,
    });
  }
});

The reaction picker:

const EMOJI_MAP: Record<string, string> = {
  thumbsup: "\u{1F44D}",
  heart: "\u2764\uFE0F",
  laugh: "\u{1F602}",
  surprised: "\u{1F62E}",
  sad: "\u{1F622}",
};

function ReactionPicker({ onSelect }: { onSelect: (emoji: string) => void }) {
  return (
    <div className="flex gap-1 bg-gray-800 rounded-lg p-1 shadow-lg">
      {Object.entries(EMOJI_MAP).map(([name, emoji]) => (
        <button
          key={name}
          onClick={() => onSelect(name)}
          className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-700 text-lg"
        >
          {emoji}
        </button>
      ))}
    </div>
  );
}

My review: The toggle approach using a unique constraint violation as the signal to remove was clever. Instead of checking first, then inserting or deleting, it tries to insert and catches the error. This is an atomic operation and avoids race conditions.

The emoji rendering used Unicode characters instead of an emoji library, which kept the bundle small.

No issues. Clean implementation.

Time spent: 20 minutes

Prompt 7: Unread Message Counts and Polish (Minute 172)

Add unread message counts and final polish:

1. Track last read message per user per room in RoomMember (lastReadAt timestamp)
2. Show unread count badge on room list items
3. When user views a room, update lastReadAt to now
4. Socket event: emit unread counts when a new message arrives in a room the user is not currently viewing

Also add:
5. Auto-scroll to bottom when new messages arrive (but not if user has scrolled up)
6. Message grouping: consecutive messages from same user within 2 minutes show without repeated header
7. "Load more" button at top of message list for older messages
8. Enter to send, Shift+Enter for new line

What Claude generated:

Updated Prisma schema:

model RoomMember {
  userId     String
  roomId     String
  joinedAt   DateTime @default(now())
  lastReadAt DateTime @default(now())
  user       User     @relation(fields: [userId], references: [id])
  room       Room     @relation(fields: [roomId], references: [id])

  @@id([userId, roomId])
}

The unread count logic:

socket.on("mark-read", async (roomId: string) => {
  await prisma.roomMember.update({
    where: { userId_roomId: { userId, roomId } },
    data: { lastReadAt: new Date() },
  });
});

// When broadcasting a new message, also send unread counts
async function broadcastUnreadCounts(roomId: string) {
  const members = await prisma.roomMember.findMany({
    where: { roomId },
  });

  for (const member of members) {
    const unreadCount = await prisma.message.count({
      where: {
        roomId,
        createdAt: { gt: member.lastReadAt },
        userId: { not: member.userId },
      },
    });

    const memberSockets = await io.fetchSockets();
    const targetSocket = memberSockets.find((s) => s.data.userId === member.userId);
    if (targetSocket) {
      targetSocket.emit("unread-count", { roomId, count: unreadCount });
    }
  }
}

The smart scroll hook:

function useAutoScroll(messages: Message[]) {
  const containerRef = useRef<HTMLDivElement>(null);
  const isAtBottomRef = useRef(true);

  const handleScroll = () => {
    const el = containerRef.current;
    if (!el) return;
    const threshold = 100;
    isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
  };

  useEffect(() => {
    if (isAtBottomRef.current) {
      containerRef.current?.scrollTo({
        top: containerRef.current.scrollHeight,
        behavior: "smooth",
      });
    }
  }, [messages]);

  return { containerRef, handleScroll };
}

My review: The unread count logic was correct but had a performance problem. For every new message, it queried the database for every room member’s unread count. With a busy room of 100 members, that is 100 database queries per message.

I replaced the per-message broadcast with a simpler approach: the frontend tracks unread counts locally based on incoming socket events, and only syncs with the database on room switch.

The message grouping and auto-scroll worked well. Shift+Enter for newlines used a simple onKeyDown handler.

Time spent: 38 minutes

The remaining time (about 2 hours) was spent on:

  • Fixing the multi-tab disconnect issue (mentioned in Prompt 2)
  • Adding the DM metadata field to Prisma (mentioned in Prompt 5)
  • Optimizing the unread count queries
  • Styling refinements — message bubbles, room list, mobile responsive layout
  • Adding a “General” room that all new users auto-join
  • Running Prisma migrations on Supabase
  • Testing with 3 browser windows simultaneously
  • Adding error handling for network disconnections and reconnection

Total time: 4 hours 38 minutes.

What Went Right

  • Socket.io setup was correct. The authentication middleware, room management, and event broadcasting all worked out of the box. Claude clearly has strong Socket.io training data.
  • The chat UI was clean and functional. Dark theme, proper message bubbles, typing indicators with bouncing dots. It looked like a real chat app, not a tutorial project.
  • Typing indicators worked perfectly. The debounce on the sender side and timeout on the receiver side prevented all the common issues (stuck indicators, event flooding).
  • Reactions using unique constraint violation was clever. An atomic toggle without race conditions. I would not have thought of this approach myself.
  • Prisma schema was well-designed. Composite index on messages, proper relations, and the unique constraint on reactions were all correct.

What Went Wrong

1. Socket.io Auth Token Location

The frontend sent the JWT in the query string, but the backend expected it in socket.handshake.auth. A small mismatch, but it caused a silent authentication failure where connections were rejected without any error message in the browser console.

Lesson: When using Socket.io with auth, always check both sides of the connection. The Socket.io docs recommend the auth object over query strings.

2. Multi-Tab Disconnect Bug

When a user had two tabs open and closed one, the other tab’s user was removed from the online list. The fix was to check fetchSockets() before broadcasting the leave event.

Lesson: Real-time apps need to handle multi-tab scenarios. Always check for remaining connections before broadcasting “user left” events.

3. DM Room Query Matched Group Chats

The Prisma query to find existing DM rooms could match group chats that contained both users. The fix was adding a member count check.

Lesson: Prisma’s every filter checks that all existing records match the condition, but it does not limit the count. For exactly-N queries, you need a separate count check or a type field.

4. Unread Count N+1 Query

Broadcasting unread counts to all room members on every message created an N+1 query problem. With 100 members, that is 100 database queries per message.

Lesson: For read-heavy features like unread counts, compute on the client side and sync periodically. Do not query the database on every event.

5. Missing Metadata Field in Prisma

Claude forgot to add the metadata JSON field to the Message model when implementing file sharing. The file name and size were available during upload but lost when saving the message.

Lesson: When adding features that extend existing models, explicitly mention schema changes in your prompt. Claude focuses on the new code and sometimes forgets to update the data model.

The Final Result

A complete real-time chat application with:

  • User registration and login with JWT
  • Create and join chat rooms
  • Real-time messaging with Socket.io
  • Message history with pagination
  • Typing indicators with debounce
  • Online user presence per room
  • Direct messages between users
  • Image and file sharing via Supabase Storage
  • Emoji reactions on messages
  • Unread message count badges

To run it yourself:

git clone https://github.com/kemalcodes/vibe-coding-projects.git
cd vibe-coding-projects
git checkout chat-app

# Set up environment variables
cp server/.env.example server/.env
# Add your Supabase URL, key, and JWT secret

# Start backend
cd server
npm install
npx prisma migrate dev
npm run dev

# Start frontend (new terminal)
cd client
npm install
npm run dev

Open http://localhost:5173 in two browser windows to test.

Time and Cost

  • Total time: 4 hours 38 minutes
  • Claude prompts: 7 (plus 5 fixes)
  • Estimated token usage: ~80,000 tokens
  • Hosting cost: $0 (Supabase free tier + Railway free tier)
  • Dependencies: Express, Socket.io, Prisma, React, Tailwind CSS, multer

Lessons Learned

1. WebSocket auth is the hardest part of any real-time app. Getting the token from the HTTP layer to the WebSocket layer requires careful coordination. Socket.io’s auth middleware helps, but you still need to handle token refresh and reconnection.

2. Multi-tab behavior breaks everything. If you only test with one tab, you will miss bugs. Always test with at least 2 tabs for the same user and 2 different users (4 total browser windows).

3. Real-time features have compounding complexity. Each feature (typing, presence, unread counts, reactions) adds events that interact with every other feature. By the 7th prompt, the socket handler had 12 different event types.

4. Prisma works well for chat schemas. The relations, composite keys, and unique constraints handled the chat data model cleanly. The findFirst with complex where clauses was readable.

5. N+1 queries hide in real-time systems. Every broadcast is a potential N+1 query. Think about database access patterns for every socket event, especially “on new message” handlers.

Source Code

Full source code: kemalcodes/vibe-coding-projects (branch: chat-app)

What’s Next?

Next up: Build a SaaS Dashboard with Claude — Stripe + Auth + Admin Panel. We add payment processing with Stripe, subscription management, and an admin panel. This is the project that replaces $200+ SaaS boilerplate kits.