Overview
A production-ready realtime chat component that syncs messages instantly across all connected clients using Convex live queries. Features include auto-scrolling, connection status, message history, and demo mode support.
Features
Real-time message synchronization
Automatic scrolling to latest messages
Connection status indicator
Message history persistence
Demo mode (no auth required)
Loading states
Type-safe messages
Room-based chat
User identification
Installation
Install the component
npx shadcn@latest add https://convex-ui.vercel.app/r/realtime-chat-tanstack
Start Convex
The chat schema and functions will be deployed automatically.
What Gets Installed
Components
realtime-chat.tsx - Main chat interface
chat-message.tsx - Individual message display
Hooks
use-realtime-chat.tsx - Chat state and message sending
use-chat-scroll.tsx - Auto-scroll behavior
Backend (Convex)
convex/messages.ts - Message queries and mutations
convex/schema.ts - Messages database schema
Usage
Basic Chat
import { RealtimeChat } from "@/components/realtime-chat" ;
export function ChatPage () {
return (
< div className = "h-screen p-4" >
< RealtimeChat
roomName = "General"
username = "Alice"
/>
</ div >
);
}
Multiple Chat Rooms
import { RealtimeChat } from "@/components/realtime-chat" ;
import { Tabs , TabsContent , TabsList , TabsTrigger } from "@/components/ui/tabs" ;
export function ChatRooms () {
const username = "Alice" ;
return (
< Tabs defaultValue = "general" className = "h-screen" >
< TabsList >
< TabsTrigger value = "general" > General </ TabsTrigger >
< TabsTrigger value = "random" > Random </ TabsTrigger >
< TabsTrigger value = "help" > Help </ TabsTrigger >
</ TabsList >
< TabsContent value = "general" className = "h-[calc(100vh-60px)]" >
< RealtimeChat roomName = "general" username = { username } />
</ TabsContent >
< TabsContent value = "random" className = "h-[calc(100vh-60px)]" >
< RealtimeChat roomName = "random" username = { username } />
</ TabsContent >
< TabsContent value = "help" className = "h-[calc(100vh-60px)]" >
< RealtimeChat roomName = "help" username = { username } />
</ TabsContent >
</ Tabs >
);
}
With Authentication
Use the current user’s name:
import { RealtimeChat } from "@/components/realtime-chat" ;
import { useCurrentUserName } from "@/hooks/use-current-user-name" ;
import { useQuery } from "@tanstack/react-query" ;
import { convexQuery , api } from "@/lib/convex/server" ;
export function AuthenticatedChat () {
const { data : user } = useQuery ( convexQuery ( api . users . current , {}));
const userName = useCurrentUserName ();
if ( ! user || ! userName ) {
return < div > Please sign in to chat </ div > ;
}
return (
< RealtimeChat
roomName = "members-only"
username = { userName }
/>
);
}
Custom Styling
import { RealtimeChat } from "@/components/realtime-chat" ;
< RealtimeChat
roomName = "General"
username = "Alice"
className = "max-w-4xl mx-auto border-2 border-primary rounded-xl shadow-2xl"
/>
import { RealtimeChat } from "@/components/realtime-chat" ;
export function Layout () {
return (
< div className = "flex h-screen" >
< main className = "flex-1 p-6" >
{ /* Main content */ }
</ main >
< aside className = "w-96 border-l" >
< RealtimeChat
roomName = "team-chat"
username = "Alice"
className = "h-full"
/>
</ aside >
</ div >
);
}
API Reference
RealtimeChat Props
interface RealtimeChatProps {
roomName : string ;
username : string ;
className ?: string ;
}
Unique identifier for the chat room. Users in the same room see each other’s messages.
Display name for the current user. Shown next to their messages.
Additional CSS classes to apply to the chat container.
useRealtimeChat Hook
interface UseRealtimeChatProps {
roomName : string ;
username : string ;
}
interface UseRealtimeChatReturn {
messages : ChatMessage [];
sendMessage : ( content : string ) => Promise < void >;
isConnected : boolean ;
}
interface ChatMessage {
id : string ;
content : string ;
user : { name : string };
createdAt : string ;
}
Example:
import { useRealtimeChat } from "@/hooks/use-realtime-chat" ;
function CustomChat () {
const { messages , sendMessage , isConnected } = useRealtimeChat ({
roomName: "general" ,
username: "Alice" ,
});
return (
< div >
< div > Status: { isConnected ? "Connected" : "Disconnected" } </ div >
< div > Messages: { messages . length } </ div >
< button onClick = { () => sendMessage ( "Hello!" ) } >
Send Message
</ button >
</ div >
);
}
Backend Implementation
Message Schema
import { defineSchema , defineTable } from "convex/server" ;
import { v } from "convex/values" ;
export default defineSchema ({
messages: defineTable ({
roomId: v . string (),
userId: v . optional ( v . id ( "users" )),
content: v . string (),
userName: v . string (),
sessionId: v . optional ( v . string ()),
}). index ( "by_room" , [ "roomId" ]) ,
}) ;
List Messages Query
import { query } from "./_generated/server" ;
import { v } from "convex/values" ;
export const list = query ({
args: { roomId: v . string () },
handler : async ( ctx , args ) => {
return await ctx . db
. query ( "messages" )
. withIndex ( "by_room" , ( q ) => q . eq ( "roomId" , args . roomId ))
. order ( "asc" )
. collect ();
},
});
Send Message Mutation
import { mutation } from "./_generated/server" ;
import { v } from "convex/values" ;
export const send = mutation ({
args: {
roomId: v . string (),
content: v . string (),
userName: v . string (),
sessionId: v . optional ( v . string ()),
},
handler : async ( ctx , args ) => {
// Validate content
const content = args . content . trim ();
if ( ! content || content . length > 2000 ) {
throw new Error ( "Invalid message content" );
}
// Try to get authenticated user ID
let userId ;
try {
const { getAuthUserId } = await import ( "@convex-dev/auth/server" );
userId = await getAuthUserId ( ctx );
} catch {
userId = undefined ; // Demo mode
}
return await ctx . db . insert ( "messages" , {
roomId: args . roomId ,
userId ,
content ,
userName: args . userName . trim (). slice ( 0 , 50 ) || "Anonymous" ,
sessionId: args . sessionId ,
});
},
});
Features in Detail
The chat automatically scrolls to new messages:
const { containerRef , scrollToBottom , shouldAutoScroll } = useChatScroll ();
useEffect (() => {
if ( shouldAutoScroll ()) {
scrollToBottom ();
}
}, [ messages ]);
Scrolls to bottom when new messages arrive
Preserves scroll position when viewing history
Smooth scrolling animation
Connection Status
Real-time connection indicator:
< Badge variant = { isConnected ? "default" : "destructive" } >
< div className = "w-1.5 h-1.5 rounded-full bg-current animate-pulse" />
{ isConnected ? "Connected" : "Disconnected" }
</ Badge >
Demo Mode
Works without authentication using session IDs:
function getSessionId () : string {
if ( typeof window === "undefined" ) return "" ;
let id = localStorage . getItem ( "demo-session-id" );
if ( ! id ) {
id = `demo- ${ Date . now () } - ${ Math . random (). toString ( 36 ). substr ( 2 , 9 ) } ` ;
localStorage . setItem ( "demo-session-id" , id );
}
return id ;
}
Message Validation
Server-side validation prevents abuse:
// Maximum message length
const MAX_MESSAGE_LENGTH = 2000 ;
// Validate content
if ( content . length > MAX_MESSAGE_LENGTH ) {
throw new ConvexError ({
code: "INVALID_INPUT" ,
message: `Message too long. Maximum ${ MAX_MESSAGE_LENGTH } characters.` ,
});
}
Customization
Custom Message Component
Replace the default message display:
import { Avatar , AvatarFallback } from "@/components/ui/avatar" ;
interface ChatMessageProps {
message : {
content : string ;
user : { name : string };
createdAt : string ;
};
isOwnMessage : boolean ;
}
export function ChatMessage ({ message , isOwnMessage } : ChatMessageProps ) {
return (
< div className = { `flex gap-3 ${ isOwnMessage ? "flex-row-reverse" : "" } ` } >
< Avatar className = "h-8 w-8" >
< AvatarFallback > { message . user . name [ 0 ] } </ AvatarFallback >
</ Avatar >
< div className = { `flex flex-col ${ isOwnMessage ? "items-end" : "" } ` } >
< span className = "text-sm font-medium" > { message . user . name } </ span >
< div className = { `rounded-lg px-4 py-2 ${
isOwnMessage ? "bg-primary text-primary-foreground" : "bg-muted"
} ` } >
{ message . content }
</ div >
< span className = "text-xs text-muted-foreground mt-1" >
{new Date ( message . createdAt ). toLocaleTimeString () }
</ span >
</ div >
</ div >
);
}
Custom Empty State
{ messages . length === 0 ? (
< div className = "flex flex-col items-center justify-center h-full gap-4 text-center" >
< MessageSquare className = "h-12 w-12 text-muted-foreground" />
< div >
< h3 className = "font-semibold" > No messages yet </ h3 >
< p className = "text-sm text-muted-foreground" >
Be the first to start the conversation!
</ p >
</ div >
</ div >
) : (
messages . map (( message ) => (
< ChatMessage key = { message . id } message = { message } />
))
)}
Add Reactions
Extend the schema and components:
messages : defineTable ({
// ... existing fields
reactions: v . optional ( v . array ( v . object ({
emoji: v . string (),
userId: v . id ( "users" ),
}))),
})
Indexed Queries
Messages are efficiently queried using indexes:
. withIndex ( "by_room" , ( q ) => q . eq ( "roomId" , args . roomId ))
For large message histories, add pagination:
export const list = query ({
args: {
roomId: v . string (),
limit: v . optional ( v . number ()),
},
handler : async ( ctx , args ) => {
const limit = args . limit ?? 100 ;
return await ctx . db
. query ( "messages" )
. withIndex ( "by_room" , ( q ) => q . eq ( "roomId" , args . roomId ))
. order ( "desc" )
. take ( limit );
},
});
Troubleshooting
Messages not appearing in real-time
Ensure Convex is running (npx convex dev)
Check browser console for connection errors
Verify the room name is exactly the same across clients
Connection status always shows 'Disconnected'
Verify VITE_CONVEX_URL is set correctly
Check that ConvexClientProvider wraps your app
Check that the username prop is provided
Verify the mutation is working in Convex dashboard