Why Build a Real-Time Chat App
Real-time communication is the backbone of every marketplace, SaaS product, and community platform built in Africa today. From Jumia seller chats to OPay support channels, if your product involves people talking to each other, you need WebSockets — not HTTP polling.
Building a chat app teaches you everything: real-time data sync, authentication, database design, and deployment. This guide builds a complete chat app with rooms, typing indicators, online status, and message persistence.

The Free Stack
| Tool | Purpose | Free Limit | Cost After |
|---|---|---|---|
| Next.js 14 | Frontend + API routes | Free forever | Free forever |
| Socket.io | WebSocket library | Free forever | Free forever |
| Supabase | Message persistence + Auth | 500MB DB, 50k users | $25/month |
| Railway | Server hosting | $5 free credits | $5/month |
| Tailwind CSS | Styling | Free forever | Free forever |
Total cost: $0/month on free tiers
Step 1: Scaffold the Project
npx create-next-app@latest chat-app \
--javascript \
--tailwind \
--app \
--no-src-dir
cd chat-app
npm install socket.io socket.io-client @supabase/supabase-js
Your project structure:
chat-app/
├── app/
│ ├── layout.jsx
│ ├── page.jsx
│ ├── chat/
│ │ └── page.jsx
│ └── api/
│ └── socket/
│ └── route.js
├── components/
│ ├── ChatRoom.jsx
│ ├── MessageInput.jsx
│ └── UserList.jsx
├── lib/
│ ├── socket.js
│ └── supabase.js
└── server.js
Step 2: Set Up the Socket.io Server
Next.js API routes don't hold WebSocket connections well. The cleanest approach is running a custom server:
// server.js
const { createServer } = require('http')
const { parse } = require('url')
const next = require('next')
const { Server } = require('socket.io')
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url, true)
handle(req, res, parsedUrl)
})
const io = new Server(server, {
cors: { origin: '*' }
})
const onlineUsers = new Map()
io.on('connection', (socket) => {
console.log('User connected:', socket.id)
socket.on('join-room', ({ roomId, username }) => {
socket.join(roomId)
onlineUsers.set(socket.id, { username, roomId })
io.to(roomId).emit('user-joined', { username, onlineCount: io.sockets.adapter.rooms.get(roomId)?.size || 0 })
})
socket.on('send-message', async (data) => {
const message = {
id: Date.now().toString(),
roomId: data.roomId,
username: data.username,
text: data.text,
timestamp: new Date().toISOString()
}
// Persist to Supabase
await fetch(process.env.NEXT_PUBLIC_SITE_URL + '/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
})
io.to(data.roomId).emit('new-message', message)
})
socket.on('typing', ({ roomId, username }) => {
socket.to(roomId).emit('user-typing', { username })
})
socket.on('stop-typing', ({ roomId }) => {
socket.to(roomId).emit('stop-typing', {})
})
socket.on('disconnect', () => {
const user = onlineUsers.get(socket.id)
if (user) {
onlineUsers.delete(socket.id)
io.to(user.roomId).emit('user-left', { username: user.username })
}
})
})
server.listen(3000, () => {
console.log('> Ready on http://localhost:3000')
})
})
Step 3: Build the Chat UI
// app/chat/page.jsx
'use client'
import { useState, useEffect, useRef } from 'react'
import { io } from 'socket.io-client'
const socket = io()
export default function ChatPage() {
const [messages, setMessages] = useState([])
const [typingUser, setTypingUser] = useState('')
const [input, setInput] = useState('')
const [room, setRoom] = useState('general')
const [username, setUsername] = useState('')
const [joined, setJoined] = useState(false)
const messagesEnd = useRef(null)
useEffect(() => {
socket.on('new-message', (msg) => {
setMessages(prev => [...prev, msg])
})
socket.on('user-typing', ({ username }) => setTypingUser(username))
socket.on('stop-typing', () => setTypingUser(''))
socket.on('user-joined', ({ username }) => {
setMessages(prev => [...prev, { system: true, text: username + ' joined the room' }])
})
return () => socket.removeAllListeners()
}, [])
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
function joinRoom() {
if (!username.trim()) return
socket.emit('join-room', { roomId: room, username })
setJoined(true)
// Load previous messages
fetch('/api/messages?roomId=' + room)
.then(r => r.json())
.then(data => setMessages(data.messages || []))
}
function sendMessage(e) {
e.preventDefault()
if (!input.trim()) return
socket.emit('send-message', { roomId: room, username, text: input })
setInput('')
socket.emit('stop-typing', { roomId: room })
}
let typingTimeout
function handleTyping(e) {
setInput(e.target.value)
socket.emit('typing', { roomId: room, username })
clearTimeout(typingTimeout)
typingTimeout = setTimeout(() => {
socket.emit('stop-typing', { roomId: room })
}, 2000)
}
if (!joined) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-full max-w-sm space-y-4 p-6 bg-white rounded-xl shadow">
<h1 className="text-2xl font-bold">Join Chat</h1>
<input value={username} onChange={e => setUsername(e.target.value)}
placeholder="Your name" className="w-full border rounded-lg px-4 py-2" />
<select value={room} onChange={e => setRoom(e.target.value)}
className="w-full border rounded-lg px-4 py-2">
<option value="general">General</option>
<option value="dev">Dev Talk</option>
<option value="business">Business</option>
</select>
<button onClick={joinRoom} className="w-full bg-black text-white py-2 rounded-lg">Join Room</button>
</div>
</div>
)
}
return (
<div className="min-h-screen flex flex-col max-w-2xl mx-auto p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-bold">#{room}</h1>
<span className="text-sm text-gray-500">Signed in as {username}</span>
</div>
<div className="flex-1 overflow-y-auto space-y-2 mb-4">
{messages.map((msg, i) => msg.system ? (
<p key={i} className="text-center text-sm text-gray-400">{msg.text}</p>
) : (
<div key={i} className={msg.username === username ? 'text-right' : 'text-left'}>
<span className="text-xs text-gray-500">{msg.username}</span>
<p className="inline-block bg-gray-100 rounded-lg px-3 py-1 max-w-xs">{msg.text}</p>
</div>
))}
{typingUser && <p className="text-sm text-gray-400 italic">{typingUser} is typing...</p>}
<div ref={messagesEnd} />
</div>
<form onSubmit={sendMessage} className="flex gap-2">
<input value={input} onChange={handleTyping} placeholder="Type a message..."
className="flex-1 border rounded-lg px-4 py-2" />
<button type="submit" className="bg-black text-white px-6 py-2 rounded-lg">Send</button>
</form>
</div>
)
}
Step 4: Persist Messages With Supabase
-- Supabase SQL Editor
create table messages (
id text primary key,
room_id text not null,
username text not null,
text text not null,
created_at timestamptz default now()
);
create index idx_messages_room on messages(room_id, created_at desc);
// app/api/messages/route.js
import { supabaseAdmin } from '@/lib/supabase'
export async function GET(req) {
const roomId = new URL(req.url).searchParams.get('roomId') || 'general'
const { data } = await supabaseAdmin
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
.limit(100)
return Response.json({ messages: data })
}
export async function POST(req) {
const message = await req.json()
await supabaseAdmin.from('messages').insert({
id: message.id,
room_id: message.roomId,
username: message.username,
text: message.text
})
return Response.json({ ok: true })
}
Step 5: Deploy to Railway
npm install -g @railway/cli
railway login
railway init
railway up
Add environment variables in Railway dashboard:
| Variable | Value |
|---|---|
| NEXT_PUBLIC_SUPABASE_URL | Your Supabase URL |
| NEXT_PUBLIC_SUPABASE_ANON_KEY | Your anon key |
| SUPABASE_SERVICE_KEY | Your service key |
| NEXT_PUBLIC_SITE_URL | Your Railway URL |
Railway gives you a public URL. Your chat app is live.
Scaling Beyond Free Tier
| Users | Storage | Recommended Action | Monthly Cost |
|---|---|---|---|
| 0–500 | <500MB | Stay on free tiers | $0 |
| 500–5,000 | 500MB–5GB | Supabase Pro + Railway hobby | $30 |
| 5,000–50,000 | 5GB–50GB | Upgrade to Redis for message cache | $80 |
| 50,000+ | 50GB+ | Dedicated server + CDN | $200+ |
Common Mistakes
| Mistake | What Happens | Fix |
|---|---|---|
| Not persisting messages | All chat lost on server restart | Always save to Supabase before emitting |
| No rate limiting on messages | Spam bots flood your rooms | Limit 5 messages per user per 10 seconds |
| Using polling instead of WebSockets | Battery drain on mobile, 10x server load | Use Socket.io — it handles WebSocket fallback |
| No message pagination | Loading 100k messages kills the browser | Load last 100 messages, lazy-load the rest |
| Ignoring CORS | Connections fail in production | Configure CORS on your Socket.io server |

