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)
Related Articles
- Build a URL Shortener with Claude — Previous article
- Build a Full-Stack Blog with Claude — Another full-stack project
- Build a CLI Todo App with Claude — First article in this series
- Vibe Coding with Claude — Complete Series — Series landing page
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.