How to Build a Real-Time Chat App: Socket.io + Next.js
Build15 min read·March 1, 2026·--

How to Build a Real-Time Chat App: Socket.io + Next.js

Build a production-ready real-time chat app with Socket.io and Next.js — rooms, typing indicators, online status, and message persistence. Deploy the whole thing for free on Railway.

@
@kivorablog
March 1, 2026
Share

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


ToolPurposeFree LimitCost After
Next.js 14Frontend + API routesFree foreverFree forever
Socket.ioWebSocket libraryFree foreverFree forever
SupabaseMessage persistence + Auth500MB DB, 50k users$25/month
RailwayServer hosting$5 free credits$5/month
Tailwind CSSStylingFree foreverFree 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:


VariableValue
NEXT_PUBLIC_SUPABASE_URLYour Supabase URL
NEXT_PUBLIC_SUPABASE_ANON_KEYYour anon key
SUPABASE_SERVICE_KEYYour service key
NEXT_PUBLIC_SITE_URLYour Railway URL

Railway gives you a public URL. Your chat app is live.




Scaling Beyond Free Tier


UsersStorageRecommended ActionMonthly Cost
0–500<500MBStay on free tiers$0
500–5,000500MB–5GBSupabase Pro + Railway hobby$30
5,000–50,0005GB–50GBUpgrade to Redis for message cache$80
50,000+50GB+Dedicated server + CDN$200+



Common Mistakes


MistakeWhat HappensFix
Not persisting messagesAll chat lost on server restartAlways save to Supabase before emitting
No rate limiting on messagesSpam bots flood your roomsLimit 5 messages per user per 10 seconds
Using polling instead of WebSocketsBattery drain on mobile, 10x server loadUse Socket.io — it handles WebSocket fallback
No message paginationLoading 100k messages kills the browserLoad last 100 messages, lazy-load the rest
Ignoring CORSConnections fail in productionConfigure CORS on your Socket.io server
Read more on Kivora Blog

Read more on Kivora Blog

Get started →