Chat Application Example
Build a real-time chat app with live content moderation.
Overview
This example demonstrates:
- Real-time messaging with Socket.io
- Live content moderation before message send
- Direct messages and group chats
- Message deletion and editing
- User blocking and reporting
- Optimistic UI updates
Tech Stack
- Frontend: Next.js 14, React, Socket.io Client
- Backend: Next.js API Routes, Socket.io Server
- Database: PostgreSQL (Prisma)
- Real-time: Socket.io
- Moderation: Vettly SDK
Database Schema
prisma
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
name String
avatar String?
status String @default("offline") // online, away, offline
messagesSent Message[] @relation("SentMessages")
messagesReceived Message[] @relation("ReceivedMessages")
roomMembers RoomMember[]
createdAt DateTime @default(now())
}
model Room {
id String @id @default(cuid())
name String?
type String // 'direct' or 'group'
members RoomMember[]
messages Message[]
createdAt DateTime @default(now())
}
model RoomMember {
id String @id @default(cuid())
roomId String
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id])
role String @default("member") // member, admin
joinedAt DateTime @default(now())
@@unique([roomId, userId])
@@index([roomId])
@@index([userId])
}
model Message {
id String @id @default(cuid())
content String @db.Text
roomId String
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
senderId String
sender User @relation("SentMessages", fields: [senderId], references: [id])
recipientId String? // For DMs
recipient User? @relation("ReceivedMessages", fields: [recipientId], references: [id])
edited Boolean @default(false)
editedAt DateTime?
deleted Boolean @default(false)
moderationSafe Boolean @default(true)
moderationAction String @default("allow")
moderationDecisionId String?
createdAt DateTime @default(now())
@@index([roomId])
@@index([senderId])
@@index([createdAt])
}Chat Component
tsx
// components/ChatRoom.tsx
'use client'
import { useState, useEffect, useRef } from 'react'
import { io, Socket } from 'socket.io-client'
import { useModeration } from '@nextauralabs/vettly-react'
interface Message {
id: string
content: string
sender: {
id: string
name: string
avatar?: string
}
moderationAction?: string
createdAt: string
}
export default function ChatRoom({
roomId,
userId,
userName
}: {
roomId: string
userId: string
userName: string
}) {
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState('')
const [socket, setSocket] = useState<Socket | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
// Real-time moderation
const { result, check } = useModeration({
apiKey: process.env.NEXT_PUBLIC_VETTLY_API_KEY!,
policyId: 'strict', // Use strict policy for chat
debounceMs: 300, // Fast feedback for chat
onCheck: (response) => {
console.log('Moderation result:', response)
}
})
// Initialize socket connection
useEffect(() => {
const socketInstance = io(process.env.NEXT_PUBLIC_SOCKET_URL!, {
query: { userId, userName }
})
socketInstance.on('connect', () => {
console.log('Connected to chat server')
socketInstance.emit('join_room', roomId)
})
socketInstance.on('message', (message: Message) => {
setMessages(prev => [...prev, message])
})
socketInstance.on('message_deleted', (messageId: string) => {
setMessages(prev => prev.filter(m => m.id !== messageId))
})
setSocket(socketInstance)
return () => {
socketInstance.disconnect()
}
}, [roomId, userId, userName])
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Check message on input change
useEffect(() => {
if (inputValue.trim()) {
check(inputValue)
}
}, [inputValue])
const sendMessage = async () => {
if (!inputValue.trim() || !result.safe || !socket) return
const tempId = Date.now().toString()
const tempMessage: Message = {
id: tempId,
content: inputValue,
sender: {
id: userId,
name: userName
},
createdAt: new Date().toISOString()
}
// Optimistic update
setMessages(prev => [...prev, tempMessage])
setInputValue('')
try {
// Send to server with moderation result
socket.emit('send_message', {
roomId,
content: inputValue,
tempId,
moderationDecisionId: result.decisionId
})
} catch (error) {
console.error('Failed to send message:', error)
// Remove optimistic message on failure
setMessages(prev => prev.filter(m => m.id !== tempId))
alert('Failed to send message')
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
return (
<div className="flex flex-col h-screen bg-gray-100">
{/* Chat Header */}
<div className="bg-white border-b px-6 py-4">
<h2 className="text-xl font-semibold">Chat Room</h2>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{messages.map(message => (
<MessageBubble
key={message.id}
message={message}
isOwnMessage={message.sender.id === userId}
/>
))}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="bg-white border-t px-6 py-4">
{/* Moderation Feedback */}
{inputValue && !result.safe && (
<div className="mb-3 p-3 bg-red-50 border border-red-200 rounded">
<p className="text-sm text-red-800">
⚠️ This message contains inappropriate content and cannot be sent.
</p>
{result.categories
.filter(c => c.triggered)
.map(c => (
<span
key={c.category}
className="inline-block mt-2 mr-2 text-xs bg-red-100 text-red-700 px-2 py-1 rounded"
>
{c.category}
</span>
))}
</div>
)}
{result.isChecking && (
<div className="mb-2 text-sm text-gray-500">
🔍 Checking message...
</div>
)}
<div className="flex gap-3">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message..."
rows={1}
className={`flex-1 px-4 py-3 border rounded-lg resize-none focus:outline-none focus:ring-2 ${
inputValue && !result.safe
? 'border-red-300 focus:ring-red-500'
: inputValue && result.safe
? 'border-green-300 focus:ring-green-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
style={{ maxHeight: '120px' }}
/>
<button
onClick={sendMessage}
disabled={!inputValue.trim() || !result.safe || result.isChecking}
className={`px-6 py-3 rounded-lg font-semibold transition-colors ${
inputValue.trim() && result.safe && !result.isChecking
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
Send
</button>
</div>
</div>
</div>
)
}
function MessageBubble({
message,
isOwnMessage
}: {
message: Message
isOwnMessage: boolean
}) {
return (
<div className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[70%] ${
isOwnMessage
? 'bg-blue-600 text-white'
: 'bg-white text-gray-900'
} rounded-lg px-4 py-2 shadow`}
>
{!isOwnMessage && (
<div className="text-xs font-semibold mb-1">
{message.sender.name}
</div>
)}
<p className="whitespace-pre-wrap break-words">{message.content}</p>
<div
className={`text-xs mt-1 ${
isOwnMessage ? 'text-blue-100' : 'text-gray-500'
}`}
>
{new Date(message.createdAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
{message.moderationAction === 'warn' && ' • Flagged'}
</div>
</div>
</div>
)
}Socket.io Server
typescript
// lib/socket.ts
import { Server as SocketIOServer } from 'socket.io'
import { Server as HTTPServer } from 'http'
import { ModerationClient } from '@nextauralabs/vettly-sdk'
import { db } from './db'
const moderationClient = new ModerationClient({
apiKey: process.env.VETTLY_API_KEY!
})
export function initializeSocket(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, {
cors: {
origin: process.env.NEXT_PUBLIC_APP_URL,
methods: ['GET', 'POST']
}
})
io.on('connection', (socket) => {
console.log('User connected:', socket.id)
const userId = socket.handshake.query.userId as string
const userName = socket.handshake.query.userName as string
// Join room
socket.on('join_room', (roomId: string) => {
socket.join(roomId)
console.log(`${userName} joined room ${roomId}`)
// Notify others
socket.to(roomId).emit('user_joined', {
userId,
userName
})
})
// Send message
socket.on('send_message', async (data: {
roomId: string
content: string
tempId: string
moderationDecisionId?: string
}) => {
try {
// Server-side moderation verification
const result = await moderationClient.check({
content: data.content,
policyId: 'strict',
contentType: 'text',
metadata: {
userId,
roomId: data.roomId,
type: 'chat_message'
}
})
// Block if unsafe
if (result.action === 'block') {
socket.emit('message_blocked', {
tempId: data.tempId,
reason: 'Content violated our community guidelines',
categories: result.categories.filter(c => c.triggered)
})
return
}
// Save to database
const message = await db.message.create({
data: {
content: data.content,
roomId: data.roomId,
senderId: userId,
moderationSafe: result.safe,
moderationAction: result.action,
moderationDecisionId: result.decisionId
},
include: {
sender: true
}
})
// Broadcast to room
io.to(data.roomId).emit('message', {
id: message.id,
content: message.content,
sender: {
id: message.sender.id,
name: message.sender.name,
avatar: message.sender.avatar
},
moderationAction: message.moderationAction,
createdAt: message.createdAt.toISOString()
})
// Log flagged messages
if (result.action === 'warn' || result.action === 'flag') {
console.warn('Flagged message:', {
messageId: message.id,
userId,
decisionId: result.decisionId,
categories: result.categories.filter(c => c.triggered)
})
}
} catch (error) {
console.error('Error sending message:', error)
socket.emit('message_error', {
tempId: data.tempId,
error: 'Failed to send message'
})
}
})
// Delete message
socket.on('delete_message', async (data: {
messageId: string
roomId: string
}) => {
try {
const message = await db.message.findUnique({
where: { id: data.messageId }
})
// Only allow sender or room admin to delete
if (message?.senderId === userId) {
await db.message.update({
where: { id: data.messageId },
data: { deleted: true }
})
io.to(data.roomId).emit('message_deleted', data.messageId)
}
} catch (error) {
console.error('Error deleting message:', error)
}
})
// Typing indicator
socket.on('typing', (roomId: string) => {
socket.to(roomId).emit('user_typing', {
userId,
userName
})
})
socket.on('stop_typing', (roomId: string) => {
socket.to(roomId).emit('user_stop_typing', {
userId,
userName
})
})
// Disconnect
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id)
})
})
return io
}Next.js Server Setup
typescript
// server.ts (Custom server for Socket.io)
import { createServer } from 'http'
import { parse } from 'url'
import next from 'next'
import { initializeSocket } from './lib/socket'
const dev = process.env.NODE_ENV !== 'production'
const hostname = 'localhost'
const port = 3000
const app = next({ dev, hostname, port })
const handle = app.getRequestHandler()
app.prepare().then(() => {
const httpServer = createServer(async (req, res) => {
try {
const parsedUrl = parse(req.url!, true)
await handle(req, res, parsedUrl)
} catch (err) {
console.error('Error occurred handling', req.url, err)
res.statusCode = 500
res.end('internal server error')
}
})
// Initialize Socket.io
initializeSocket(httpServer)
httpServer.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`)
})
})Direct Messages Component
tsx
// components/DirectMessage.tsx
'use client'
import { useState } from 'react'
import ChatRoom from './ChatRoom'
export default function DirectMessages({ currentUserId }: { currentUserId: string }) {
const [selectedUser, setSelectedUser] = useState<string | null>(null)
const [users, setUsers] = useState([
{ id: '1', name: 'Alice', status: 'online' },
{ id: '2', name: 'Bob', status: 'away' },
{ id: '3', name: 'Charlie', status: 'offline' }
])
return (
<div className="flex h-screen">
{/* Sidebar */}
<div className="w-64 bg-gray-800 text-white p-4">
<h2 className="text-xl font-bold mb-4">Direct Messages</h2>
<div className="space-y-2">
{users.map(user => (
<button
key={user.id}
onClick={() => setSelectedUser(user.id)}
className={`w-full text-left px-3 py-2 rounded hover:bg-gray-700 flex items-center gap-2 ${
selectedUser === user.id ? 'bg-gray-700' : ''
}`}
>
<div
className={`w-2 h-2 rounded-full ${
user.status === 'online'
? 'bg-green-500'
: user.status === 'away'
? 'bg-yellow-500'
: 'bg-gray-500'
}`}
/>
{user.name}
</button>
))}
</div>
</div>
{/* Chat Area */}
<div className="flex-1">
{selectedUser ? (
<ChatRoom
roomId={`dm_${currentUserId}_${selectedUser}`}
userId={currentUserId}
userName="You"
/>
) : (
<div className="flex items-center justify-center h-full text-gray-500">
Select a user to start chatting
</div>
)}
</div>
</div>
)
}Key Features
1. Real-time Moderation
- Check messages before sending
- Block unsafe content immediately
- Visual feedback during typing
2. Optimistic Updates
- Show message immediately
- Confirm with server
- Rollback if blocked
3. Socket.io Events
typescript
// Client-side events
socket.emit('send_message', { content, roomId })
socket.emit('typing', roomId)
socket.emit('delete_message', { messageId, roomId })
// Server-side events
socket.on('message', (message) => { })
socket.on('message_blocked', ({ reason }) => { })
socket.on('user_typing', ({ userName }) => { })4. Moderation Policies
Use strict policies for chat:
typescript
const { result } = useModeration({
policyId: 'strict', // Stricter for real-time chat
debounceMs: 300 // Faster feedback (300ms vs 500ms)
})Best Practices
1. Server-Side Verification
Always verify moderation server-side:
typescript
// ❌ Don't trust only client-side
socket.emit('send_message', { content })
// ✅ Verify server-side
const result = await moderationClient.check({ content })
if (result.action === 'block') {
socket.emit('message_blocked')
return
}2. Handle Race Conditions
typescript
// Store pending messages
const pendingMessages = new Map()
socket.on('send_message', async (data) => {
const messageId = generateId()
pendingMessages.set(messageId, data)
try {
const result = await moderationClient.check(data.content)
// Handle result...
} finally {
pendingMessages.delete(messageId)
}
})3. Rate Limiting
typescript
// Limit messages per user
const messageRates = new Map()
function checkRateLimit(userId: string): boolean {
const now = Date.now()
const userMessages = messageRates.get(userId) || []
// Remove old messages (older than 1 minute)
const recentMessages = userMessages.filter((t: number) => now - t < 60000)
if (recentMessages.length >= 20) {
return false // Too many messages
}
messageRates.set(userId, [...recentMessages, now])
return true
}Performance Optimization
1. Debouncing
typescript
const { result } = useModeration({
debounceMs: 300 // Fast for chat
})2. Caching
typescript
// Cache recent moderation results
const cache = new LRUCache({ max: 100 })
if (cache.has(content)) {
return cache.get(content)
}
const result = await client.check(content)
cache.set(content, result)3. Batch Processing
typescript
// For message history, batch check
const results = await client.batchCheck({
policyId: 'strict',
items: messages.map(m => ({
id: m.id,
content: m.content
}))
})