Messaging apps connect people instantly through text, images, and real-time conversations. Building one requires handling user authentication, real-time message delivery, conversation management, and a responsive interface that feels natural to use. In this guide, we will build a messaging app like Telegram using ZEGOCLOUD’s SDK for real-time messaging, Supabase for authentication and user management, and React for the frontend.
The app will support one-on-one conversations, typing indicators, message replies, and real-time message delivery. Now, let’s jump right in!
How to Develop a Messaging App Like Telegram with ZEGOCLOUD
When you develop a messaging app like Telegram, you need infrastructure that handles real-time message delivery reliably.
ZEGOCLOUD’s In-app Chat SDK provides the messaging backbone, handling message transport, conversation management, and delivery status without requiring you to build complex server infrastructure.
Prerequisites
Before starting development, ensure you have:
- A ZEGOCLOUD account with ZIM services enabled → Sign up here
- A Supabase account with a project created → Sign up at supabase.com
- Node.js 18+ and npm installed
- Valid AppID and ServerSecret from the ZEGOCLOUD console
- Supabase URL and keys from your project settings
- Basic understanding of React and TypeScript
Step 1. Project Setup and Architecture
The complete implementation for this guide is available in the telegram-app repository. You can also test the live application at telegram-app-orpin.vercel.app.
1.1 Architecture Overview
Our messaging app has two main parts. The backend uses Express and handles ZEGOCLOUD token generation and user management through Supabase. It provides API endpoints for fetching users and generating authentication tokens.
The frontend is a React application that creates the chat interface. Users can sign up, log in, search for other users, start conversations, and exchange messages in real-time. It uses ZEGOCLOUD’s ZIM SDK for all messaging operations and Supabase for authentication.
The backend handles token generation securely while ZEGOCLOUD handles real-time message routing and storage. This keeps your server simple while providing professional messaging capabilities.
1.2 Environment Setup and Dependencies
Create the project structure:
mkdir telegram-app && cd telegram-app
mkdir server client
Backend Configuration
cd server
npm init -y
npm install express cors dotenv @supabase/supabase-js
npm install --save-dev typescript tsx @types/express @types/cors @types/node
Create server/.env:
PORT=8080
SUPABASE_URL=your_supabase_url
SUPABASE_SERVICE_KEY=your_supabase_service_key
ZEGO_APP_ID=your_zego_app_id
ZEGO_SERVER_SECRET=your_32_character_server_secret
Frontend Configuration
cd ../client
npm create vite@latest . -- --template react-ts
npm install @supabase/supabase-js zego-zim-web zustand react-router-dom axios
npm install -D tailwindcss
Create client/.env:
VITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
VITE_ZEGO_APP_ID=your_zego_app_id
VITE_API_BASE_URL=http://localhost:8080
Create the configuration file:
// client/src/config.ts
export const config = {
supabase: {
url: import.meta.env.VITE_SUPABASE_URL,
anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
},
zego: {
appId: parseInt(import.meta.env.VITE_ZEGO_APP_ID),
},
api: {
baseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
},
}
Step 2. Database Setup
Run this SQL in your Supabase SQL editor to create the users table:
-- Users table (extends Supabase auth.users)
CREATE TABLE users (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
avatar_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Index for search
CREATE INDEX idx_users_username ON users(username);
-- Row Level Security (RLS)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Users policies
CREATE POLICY "Users can view all users" ON users
FOR SELECT USING (true);
CREATE POLICY "Users can insert own profile" ON users
FOR INSERT WITH CHECK (auth.uid() = id);
CREATE POLICY "Users can update own profile" ON users
FOR UPDATE USING (auth.uid() = id);
ZEGOCLOUD handles all message and conversation storage, so you only need a users table for profile information.
Step 3. Building the Backend Server
The backend manages ZEGOCLOUD token generation and user queries.
3.1 Supabase Configuration
// server/src/config/supabase.ts
import { createClient } from '@supabase/supabase-js'
import dotenv from 'dotenv'
dotenv.config()
export const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
)
3.2 Authentication Middleware
// server/src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express'
import { supabase } from '../config/supabase.js'
export interface AuthRequest extends Request {
user?: { id: string; email: string }
}
export async function authMiddleware(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing authorization header' })
return
}
const token = authHeader.substring(7)
const { data: { user }, error } = await supabase.auth.getUser(token)
if (error || !user) {
res.status(401).json({ error: 'Invalid token' })
return
}
req.user = { id: user.id, email: user.email! }
next()
}
3.3 ZEGOCLOUD Token Generation
ZEGOCLOUD requires secure token generation on the server side:
// server/src/services/zego-service.ts
import crypto from 'crypto'
function makeRandomIv(): string {
const str = '0123456789abcdefghijklmnopqrstuvwxyz'
const result: string[] = []
for (let i = 0; i < 16; i++) {
const r = Math.floor(Math.random() * str.length)
result.push(str.charAt(r))
}
return result.join('')
}
function getAlgorithm(key: Buffer): string {
switch (key.length) {
case 16: return 'aes-128-cbc'
case 24: return 'aes-192-cbc'
case 32: return 'aes-256-cbc'
}
throw new Error('Invalid key length')
}
function aesEncrypt(plainText: string, key: string, iv: string): ArrayBuffer {
const cipher = crypto.createCipheriv(getAlgorithm(Buffer.from(key)), key, iv)
cipher.setAutoPadding(true)
const encrypted = cipher.update(plainText)
const final = cipher.final()
return Uint8Array.from(Buffer.concat([encrypted, final])).buffer
}
export class ZegoService {
private appId: number
private serverSecret: string
constructor() {
this.appId = parseInt(process.env.ZEGO_APP_ID!)
this.serverSecret = process.env.ZEGO_SERVER_SECRET!
}
generateToken(userId: string, effectiveTimeInSeconds: number = 3600): string {
const createTime = Math.floor(Date.now() / 1000)
const tokenInfo = {
app_id: this.appId,
user_id: userId,
nonce: Math.floor(Math.random() * 4294967295) - 2147483648,
ctime: createTime,
expire: createTime + effectiveTimeInSeconds,
payload: ''
}
const plainText = JSON.stringify(tokenInfo)
const iv = makeRandomIv()
const encryptBuf = aesEncrypt(plainText, this.serverSecret, iv)
const b1 = new Uint8Array(8)
const b2 = new Uint8Array(2)
const b3 = new Uint8Array(2)
new DataView(b1.buffer).setBigInt64(0, BigInt(tokenInfo.expire), false)
new DataView(b2.buffer).setUint16(0, iv.length, false)
new DataView(b3.buffer).setUint16(0, encryptBuf.byteLength, false)
const buf = Buffer.concat([
Buffer.from(b1),
Buffer.from(b2),
Buffer.from(iv),
Buffer.from(b3),
Buffer.from(encryptBuf),
])
return '04' + buf.toString('base64')
}
}
export const zegoService = new ZegoService()
3.4 API Routes
// server/src/routes/zego.ts
import { Router } from 'express'
import { zegoService } from '../services/zego-service.js'
import { authMiddleware, AuthRequest } from '../middleware/auth.js'
export const zegoRoutes = Router()
zegoRoutes.get('/token', authMiddleware, async (req: AuthRequest, res) => {
const rawUserId = req.query.user_id as string || req.user!.id
const userId = rawUserId.replace(/-/g, '').substring(0, 32)
const token = zegoService.generateToken(userId, 3600)
res.json({ token })
})
// server/src/routes/users.ts
import { Router } from 'express'
import { supabase } from '../config/supabase.js'
import { authMiddleware, AuthRequest } from '../middleware/auth.js'
export const userRoutes = Router()
userRoutes.get('/', authMiddleware, async (req: AuthRequest, res) => {
const { search, exclude_self } = req.query
let query = supabase
.from('users')
.select('id, username, email, avatar_url, created_at')
.order('username')
if (exclude_self === 'true') {
query = query.neq('id', req.user!.id)
}
if (search && typeof search === 'string') {
query = query.or(`username.ilike.%${search}%,email.ilike.%${search}%`)
}
const { data, error } = await query.limit(50)
if (error) throw error
res.json(data || [])
})
3.5 Express Server Setup
// server/src/server.ts
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import { userRoutes } from './routes/users.js'
import { zegoRoutes } from './routes/zego.js'
dotenv.config()
const app = express()
const PORT = process.env.PORT || 8080
app.use(cors())
app.use(express.json())
app.use('/api/users', userRoutes)
app.use('/api/zego', zegoRoutes)
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
Step 4. ZEGOCLOUD ZIM SDK Integration
The frontend uses ZEGOCLOUD’s ZIM SDK for all messaging operations. A service class encapsulates the SDK to provide a clean interface for React components.
4.1 ZEGO Service Initialization
// client/src/services/zego.ts
import { ZIM } from 'zego-zim-web'
import { config } from '../config'
import { apiService } from './api'
export class ZegoService {
private static instance: ZegoService
private zim: any = null
private isInitialized = false
private currentUserId: string | null = null
private shortUserId: string | null = null
private messageCallbacks: ((message: any) => void)[] = []
private typingCallbacks: ((status: any) => void)[] = []
static getInstance(): ZegoService {
if (!ZegoService.instance) {
ZegoService.instance = new ZegoService()
}
return ZegoService.instance
}
async initialize(userId: string): Promise<void> {
if (this.isInitialized && this.currentUserId === userId) return
const shortUserId = userId.replace(/-/g, '').substring(0, 32)
this.shortUserId = shortUserId
this.zim = ZIM.create({ appID: config.zego.appId })
this.setupEventListeners()
const { token } = await apiService.getZegoToken(shortUserId)
await this.zim.login(shortUserId, { userName: shortUserId, token })
this.currentUserId = userId
this.isInitialized = true
}
// ... continued in next section
}
export const zegoService = ZegoService.getInstance()
4.2 Event Listeners for Real-time Updates
// client/src/services/zego.ts (continued)
private setupEventListeners(): void {
if (!this.zim) return
this.zim.on('connectionStateChanged', (_zim: any, { state, event }: any) => {
if (state === 0 && event === 3) {
this.reconnect()
}
})
this.zim.on('peerMessageReceived', (_zim: any, { messageList, fromConversationID }: any) => {
messageList.forEach((msg: any) => {
if (msg.type === 200) {
// Handle typing indicators
const data = JSON.parse(msg.message)
if (data.type === 'typing') {
this.typingCallbacks.forEach(cb => cb({
user_id: data.user_id,
conversation_id: fromConversationID,
is_typing: data.is_typing,
}))
}
} else {
// Handle regular messages
const message = this.convertZegoMessage(msg, fromConversationID)
this.messageCallbacks.forEach(cb => cb(message))
}
})
})
this.zim.on('tokenWillExpire', async () => {
if (this.shortUserId) {
const { token } = await apiService.getZegoToken(this.shortUserId)
await this.zim.renewToken(token)
}
})
}
4.3 Conversation and Message Operations
// client/src/services/zego.ts (continued)
async queryConversationList(count: number = 100): Promise<any[]> {
if (!this.zim) return []
const config = { nextConversation: null, count }
const { conversationList } = await this.zim.queryConversationList(config)
return conversationList
}
async queryHistoryMessages(conversationId: string, count: number = 50): Promise<any[]> {
if (!this.zim) return []
const config = { nextMessage: null, count, reverse: true }
const { messageList } = await this.zim.queryHistoryMessage(conversationId, 0, config)
return messageList
.filter((msg: any) => msg.type !== 200)
.map((msg: any) => this.convertZegoMessage(msg, conversationId))
}
async sendMessage(conversationId: string, content: string): Promise<any> {
if (!this.zim) throw new Error('ZEGO not initialized')
const messageObj = { type: 1, message: content }
const sendConfig = { priority: 2 }
const notification = { onMessageAttached: () => {} }
const result = await this.zim.sendMessage(
messageObj, conversationId, 0, sendConfig, notification
)
return this.convertZegoMessage(result.message, conversationId)
}
async sendTypingStatus(conversationId: string, isTyping: boolean): Promise<void> {
if (!this.zim || !this.currentUserId) return
const customMessage = {
type: 200,
message: JSON.stringify({
type: 'typing',
user_id: this.currentUserId,
is_typing: isTyping,
}),
}
await this.zim.sendMessage(
customMessage, conversationId, 0,
{ priority: 1, disableUnreadMessageCount: true }
)
}
private convertZegoMessage(zegoMsg: any, conversationId: string): any {
return {
id: zegoMsg.messageID.toString(),
conversation_id: conversationId,
sender_id: zegoMsg.senderUserID,
content: zegoMsg.message || zegoMsg.fileDownloadUrl || '',
type: this.getMessageType(zegoMsg.type),
created_at: new Date(zegoMsg.timestamp).toISOString(),
_raw: zegoMsg,
}
}
private getMessageType(zegoType: number): string {
const types: Record<number, string> = {
1: 'text', 11: 'image', 12: 'file', 13: 'audio', 14: 'video'
}
return types[zegoType] || 'text'
}
onMessage(callback: (message: any) => void): () => void {
this.messageCallbacks.push(callback)
return () => {
this.messageCallbacks = this.messageCallbacks.filter(cb => cb !== callback)
}
}
onTyping(callback: (status: any) => void): () => void {
this.typingCallbacks.push(callback)
return () => {
this.typingCallbacks = this.typingCallbacks.filter(cb => cb !== callback)
}
}
async logout(): Promise<void> {
if (this.zim) {
await this.zim.logout()
this.zim.destroy()
this.zim = null
this.isInitialized = false
}
}
Step 5. State Management with Zustand
Zustand provides lightweight state management for conversations and messages.
5.1 Authentication Store
// client/src/store/auth-store.ts
import { create } from 'zustand'
import { supabase } from '../services/supabase'
import { zegoService } from '../services/zego'
interface AuthState {
user: any | null
loading: boolean
initialized: boolean
login: (email: string, password: string) => Promise<void>
signup: (email: string, password: string, username: string) => Promise<void>
logout: () => Promise<void>
initialize: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
loading: false,
initialized: false,
initialize: async () => {
const { data: { session } } = await supabase.auth.getSession()
if (session?.user) {
localStorage.setItem('access_token', session.access_token)
const { data: profile } = await supabase
.from('users')
.select('*')
.eq('id', session.user.id)
.single()
if (profile) {
set({ user: profile })
await zegoService.initialize(profile.id)
}
}
set({ initialized: true })
},
login: async (email, password) => {
set({ loading: true })
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
if (error) throw error
if (data.session) {
localStorage.setItem('access_token', data.session.access_token)
}
const { data: profile } = await supabase
.from('users')
.select('*')
.eq('id', data.user!.id)
.single()
if (profile) {
set({ user: profile })
await zegoService.initialize(profile.id)
}
set({ loading: false })
},
signup: async (email, password, username) => {
set({ loading: true })
const { data, error } = await supabase.auth.signUp({ email, password })
if (error) throw error
if (data.session) {
localStorage.setItem('access_token', data.session.access_token)
}
await supabase.from('users').insert({
id: data.user!.id,
email,
username,
})
const { data: profile } = await supabase
.from('users')
.select('*')
.eq('id', data.user!.id)
.single()
if (profile) {
set({ user: profile })
await zegoService.initialize(profile.id)
}
set({ loading: false })
},
logout: async () => {
await zegoService.logout()
await supabase.auth.signOut()
localStorage.removeItem('access_token')
set({ user: null })
},
}))
5.2 Chat Store
// client/src/store/chat-store.ts
import { create } from 'zustand'
import { zegoService } from '../services/zego'
import { apiService } from '../services/api'
interface ChatState {
conversations: any[]
messages: Record<string, any[]>
activeConversationId: string | null
typingUsers: Record<string, string[]>
loading: boolean
loadConversations: () => Promise<void>
loadMessages: (conversationId: string) => Promise<void>
sendMessage: (conversationId: string, content: string) => Promise<void>
setActiveConversation: (conversationId: string | null) => void
startConversation: (userId: string) => Promise<string>
addMessage: (message: any) => void
updateTypingStatus: (status: any) => void
sendTyping: (conversationId: string, isTyping: boolean) => void
}
export const useChatStore = create<ChatState>((set, get) => ({
conversations: [],
messages: {},
activeConversationId: null,
typingUsers: {},
loading: false,
loadConversations: async () => {
const zegoConversations = await zegoService.queryConversationList()
const users = await apiService.getUsers()
const conversations = zegoConversations
.filter((c: any) => c.type === 0)
.map((c: any) => {
const visitorId = c.conversationID
const otherUser = users.find((u: any) =>
u.id.replace(/-/g, '').substring(0, 32) === visitorId
)
return {
id: c.conversationID,
conversationName: otherUser?.username || c.conversationID,
lastMessage: c.lastMessage?.type !== 200 ? c.lastMessage : undefined,
unreadMessageCount: c.unreadMessageCount || 0,
orderKey: c.orderKey || 0,
other_user: otherUser,
}
})
set({ conversations })
},
loadMessages: async (conversationId) => {
set({ loading: true })
const messages = await zegoService.queryHistoryMessages(conversationId)
set((state) => ({
messages: { ...state.messages, [conversationId]: messages },
loading: false,
}))
},
sendMessage: async (conversationId, content) => {
const message = await zegoService.sendMessage(conversationId, content)
get().addMessage(message)
},
setActiveConversation: (conversationId) => {
set({ activeConversationId: conversationId })
if (conversationId) {
get().loadMessages(conversationId)
}
},
startConversation: async (userId) => {
const shortUserId = userId.replace(/-/g, '').substring(0, 32)
const users = await apiService.getUsers()
const otherUser = users.find((u: any) => u.id === userId)
set((state) => {
const exists = state.conversations.some(c => c.id === shortUserId)
if (exists) return state
return {
conversations: [{
id: shortUserId,
conversationName: otherUser?.username || shortUserId,
unreadMessageCount: 0,
orderKey: Date.now(),
other_user: otherUser,
}, ...state.conversations]
}
})
return shortUserId
},
addMessage: (message) => {
set((state) => {
const conversationMessages = state.messages[message.conversation_id] || []
const exists = conversationMessages.some(m => m.id === message.id)
if (exists) return state
return {
messages: {
...state.messages,
[message.conversation_id]: [...conversationMessages, message],
},
}
})
get().loadConversations()
},
updateTypingStatus: (status) => {
set((state) => {
const currentTyping = state.typingUsers[status.conversation_id] || []
if (status.is_typing && !currentTyping.includes(status.user_id)) {
return {
typingUsers: {
...state.typingUsers,
[status.conversation_id]: [...currentTyping, status.user_id],
},
}
} else if (!status.is_typing) {
return {
typingUsers: {
...state.typingUsers,
[status.conversation_id]: currentTyping.filter(id => id !== status.user_id),
},
}
}
return state
})
},
sendTyping: async (conversationId, isTyping) => {
await zegoService.sendTypingStatus(conversationId, isTyping)
},
}))
Step 6. Building the User Interface Components
The interface provides a clean environment for messaging with a sidebar for conversations and a main chat area.
6.1 Main Application Component
// client/src/App.tsx
import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './store/auth-store'
import { useChatStore } from './store/chat-store'
import { zegoService } from './services/zego'
import { Login } from './components/auth/login'
import { Signup } from './components/auth/signup'
import { Sidebar } from './components/sidebar/sidebar'
import { ChatWindow } from './components/chat/chat-window'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, initialized } = useAuthStore()
if (!initialized) {
return <div className="min-h-screen flex items-center justify-center bg-gray-950 text-white">Loading...</div>
}
if (!user) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function ChatLayout() {
const { activeConversationId, setActiveConversation, addMessage, updateTypingStatus, loadConversations } = useChatStore()
useEffect(() => {
loadConversations()
const unsubscribeMessage = zegoService.onMessage((message) => {
addMessage(message)
})
const unsubscribeTyping = zegoService.onTyping((status) => {
updateTypingStatus(status)
})
return () => {
unsubscribeMessage()
unsubscribeTyping()
}
}, [])
return (
<div className="flex h-screen bg-gray-950">
<Sidebar
onSelectConversation={setActiveConversation}
activeConversationId={activeConversationId}
/>
<div className="flex-1 flex flex-col">
{activeConversationId ? (
<ChatWindow conversationId={activeConversationId} />
) : (
<div className="h-full flex items-center justify-center text-gray-500">
Select a conversation to start chatting
</div>
)}
</div>
</div>
)
}
export function App() {
const { initialize, initialized } = useAuthStore()
useEffect(() => {
initialize()
}, [initialize])
if (!initialized) {
return <div className="min-h-screen flex items-center justify-center bg-gray-950 text-white">Loading...</div>
}
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/" element={<ProtectedRoute><ChatLayout /></ProtectedRoute>} />
</Routes>
</BrowserRouter>
)
}
6.2 Sidebar Component
// client/src/components/sidebar/sidebar.tsx
import { useState } from 'react'
import { useAuthStore } from '../../store/auth-store'
import { ConversationList } from '../conversation/conversation-list'
import { UserList } from './user-list'
interface SidebarProps {
onSelectConversation: (conversationId: string) => void
activeConversationId: string | null
}
export function Sidebar({ onSelectConversation, activeConversationId }: SidebarProps) {
const { user, logout } = useAuthStore()
const [showUserList, setShowUserList] = useState(false)
return (
<div className="w-80 border-r border-gray-800 flex flex-col bg-gray-900 h-full">
<div className="p-5 border-b border-gray-800">
<div className="flex items-center justify-between mb-5">
<h1 className="text-xl font-bold text-white">Messages</h1>
<button onClick={logout} className="px-3 py-2 rounded-lg bg-gray-800 text-gray-400 hover:text-white text-sm">
Logout
</button>
</div>
<div className="flex items-center gap-3 mb-5">
<div className="w-12 h-12 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold text-lg">
{user?.username?.[0]?.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-white truncate">{user?.username}</p>
<p className="text-sm text-gray-400 truncate">{user?.email}</p>
</div>
</div>
<button
onClick={() => setShowUserList(!showUserList)}
className="w-full py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
>
{showUserList ? 'Show Conversations' : 'New Chat'}
</button>
</div>
{showUserList ? (
<UserList onSelectUser={() => setShowUserList(false)} />
) : (
<ConversationList onSelectConversation={onSelectConversation} activeConversationId={activeConversationId} />
)}
</div>
)
}
6.3 Chat Window Component
// client/src/components/chat/chat-window.tsx
import { useEffect, useRef, useState } from 'react'
import { useChatStore } from '../../store/chat-store'
import { useAuthStore } from '../../store/auth-store'
import { MessageBubble } from './message-bubble'
import { MessageInput } from './message-input'
interface ChatWindowProps {
conversationId: string
}
export function ChatWindow({ conversationId }: ChatWindowProps) {
const { messages, typingUsers, conversations } = useChatStore()
const { user } = useAuthStore()
const [replyingTo, setReplyingTo] = useState<any>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const conversationMessages = messages[conversationId] || []
const conversation = conversations.find(c => c.id === conversationId)
const otherUser = conversation?.other_user
const typingUsernames = (typingUsers[conversationId] || [])
.filter(userId => userId !== user?.id)
.map(() => otherUser?.username || 'Someone')
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [conversationMessages, typingUsernames])
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-gray-800 bg-gray-900">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold text-lg">
{otherUser?.username?.[0]?.toUpperCase() || '?'}
</div>
<h2 className="text-lg font-semibold text-white">
{otherUser?.username || 'Unknown User'}
</h2>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 bg-gray-950">
{conversationMessages.length === 0 ? (
<div className="h-full flex items-center justify-center text-gray-500">
No messages yet. Start the conversation!
</div>
) : (
<>
{conversationMessages.map((message) => (
<MessageBubble key={message.id} message={message} onReply={setReplyingTo} />
))}
{typingUsernames.length > 0 && (
<div className="py-2 px-4 text-sm text-gray-400 italic">
{typingUsernames.join(', ')} is typing...
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
<MessageInput conversationId={conversationId} replyingTo={replyingTo} onCancelReply={() => setReplyingTo(null)} />
</div>
)
}
6.4 Message Bubble Component
// client/src/components/chat/message-bubble.tsx
import { useAuthStore } from '../../store/auth-store'
interface MessageBubbleProps {
message: any
onReply: (message: any) => void
}
export function MessageBubble({ message, onReply }: MessageBubbleProps) {
const { user } = useAuthStore()
const currentUserShortId = user?.id.replace(/-/g, '').substring(0, 32)
const isOwn = message.sender_id === currentUserShortId || message.sender_id === user?.id
const formatTime = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
return (
<div className={`flex ${isOwn ? 'justify-end' : 'justify-start'} mb-3`}>
<div className="max-w-[70%]">
<div className={`px-4 py-3 rounded-2xl ${isOwn ? 'bg-blue-600 text-white' : 'bg-gray-800 text-white'}`}>
<p className="break-words leading-relaxed">{message.content}</p>
</div>
<div className="flex items-center gap-2 mt-1 px-1">
<span className="text-xs text-gray-500">{formatTime(message.created_at)}</span>
<button onClick={() => onReply(message)} className="text-xs text-gray-400 hover:text-white">
Reply
</button>
</div>
</div>
</div>
)
}
6.5 Message Input Component
// client/src/components/chat/message-input.tsx
import { useState, useRef, useEffect } from 'react'
import { useChatStore } from '../../store/chat-store'
interface MessageInputProps {
conversationId: string
replyingTo: any | null
onCancelReply: () => void
}
export function MessageInput({ conversationId, replyingTo, onCancelReply }: MessageInputProps) {
const [message, setMessage] = useState('')
const [isTyping, setIsTyping] = useState(false)
const { sendMessage, sendTyping } = useChatStore()
const inputRef = useRef<HTMLTextAreaElement>(null)
const typingTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
useEffect(() => {
if (replyingTo) inputRef.current?.focus()
}, [replyingTo])
const handleTyping = (value: string) => {
setMessage(value)
if (!isTyping && value.length > 0) {
setIsTyping(true)
sendTyping(conversationId, true)
}
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
typingTimeoutRef.current = setTimeout(() => {
setIsTyping(false)
sendTyping(conversationId, false)
}, 2000)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!message.trim()) return
await sendMessage(conversationId, message.trim())
setMessage('')
onCancelReply()
if (isTyping) {
setIsTyping(false)
sendTyping(conversationId, false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}
return (
<div className="px-6 py-4 border-t border-gray-800 bg-gray-900">
{replyingTo && (
<div className="mb-3 p-3 bg-gray-800 rounded-lg flex items-center justify-between">
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-400 mb-1">Replying to</p>
<p className="text-sm text-gray-300 truncate">{replyingTo.content}</p>
</div>
<button onClick={onCancelReply} className="ml-3 text-gray-400 hover:text-white text-xl">×</button>
</div>
)}
<form onSubmit={handleSubmit} className="flex items-end gap-3">
<textarea
ref={inputRef}
value={message}
onChange={(e) => handleTyping(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={1}
className="flex-1 resize-none px-4 py-3 bg-gray-800 border border-gray-700 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={!message.trim()}
className="px-6 py-3 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 disabled:bg-gray-700 disabled:cursor-not-allowed"
>
Send
</button>
</form>
</div>
)
}
Step 7. API Client Service
The frontend communicates with the backend through a centralized API service:
// client/src/services/api.ts
import axios from 'axios'
import { config } from '../config'
const api = axios.create({
baseURL: config.api.baseUrl,
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export const apiService = {
async getZegoToken(userId: string): Promise<{ token: string }> {
const response = await api.get(`/api/zego/token?user_id=${userId}`)
return response.data
},
async getUsers(search?: string, excludeSelf?: boolean): Promise<any[]> {
const response = await api.get('/api/users', {
params: { search, exclude_self: excludeSelf ? 'true' : undefined }
})
return response.data
},
}
Step 8. Running and Testing the Application
8.1 Backend Startup
From the server directory:
npm install
npm run dev
The server will start on http://localhost:8080.
8.2 Frontend Startup
From the client directory:
npm install
npm run dev
Navigate to http://localhost:5173 in your browser.
8.3 Testing the Application
- Open two browser windows (or use incognito mode for the second)
- Create two different user accounts
- Log in with each account in separate windows
- Search for the other user and start a conversation
- Send messages and observe real-time delivery
- Type in one window and see the typing indicator in the other
Run a Demo
Conclusion
You now have a functional messaging app built with ZEGOCLOUD’s ZIM SDK. The application handles user authentication through Supabase, real-time message delivery through ZEGOCLOUD, and provides a clean interface for one-on-one conversations.
To develop chat apps like Telegram further, you can extend this foundation with features such as group conversations, media sharing, push notifications, or end-to-end encryption. The same architecture works well for customer support chat, team collaboration tools, or any application where real-time messaging improves the user experience.
ZEGOCLOUD handles the complex infrastructure of message routing and delivery, letting you focus on building features that matter to your users.
FAQ
Q1. How to build an app like Telegram?
Start by defining core features such as user authentication, real-time messaging, media sharing, and notifications. Then choose your tech stack, build the backend and frontend, test for performance and security, and launch.
Q2. How much does it cost to make an app like Telegram?
The cost depends on features and scale. A basic version may cost $30,000–$50,000, while a full-featured app with high scalability can exceed $100,000.
Q3. Can I make my own messaging app?
Yes. You can build your own messaging app using ready-made SDKs and cloud services to handle real-time chat, user management, and scalability.
Q4. How can I create my own Telegram app?
Create an MVP with essential chat features, choose a reliable real-time messaging solution, and gradually add advanced functions, such as channels, groups, and encryption, as your app grows.
Let’s Build APP Together
Start building with real-time video, voice & chat SDK for apps today!






