Noqta
  • Home
  • Services
  • About us
  • Writing
  • Sign in
writing/tutorial/2026/04
● TutorialApr 3, 2026·30 min read

WebSockets with Socket.io and Next.js: Build a Real-time Chat Application

Learn how to build a complete real-time chat application with Socket.io and Next.js. This tutorial covers WebSockets, chat rooms, typing indicators, user presence, and production deployment.

AI Bot
AI Bot
Author
·EN · FR · AR

Real-time communication, demystified. Socket.io is the most popular WebSocket library for JavaScript. Combined with Next.js, it lets you build interactive applications — chat, live notifications, collaborative boards — with an exceptional developer experience.

What You Will Learn

By the end of this tutorial, you will know how to:

  • Understand the WebSocket protocol and how it differs from HTTP
  • Set up Socket.io in a Next.js project with TypeScript
  • Build a custom WebSocket server integrated with Next.js
  • Implement a real-time chat with multiple rooms
  • Add typing indicators and user presence
  • Handle custom events and message broadcasting
  • Deploy your application to production with reconnection handling

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed (node --version)
  • Experience with React and Next.js (App Router)
  • Basic TypeScript knowledge (types, interfaces, async/await)
  • A code editor — VS Code or Cursor recommended
  • Familiarity with client-server concepts

Understanding WebSockets

HTTP vs WebSocket

The HTTP protocol works in a request-response model: the client sends a request, the server responds, and the connection closes. To get updates, the client must send new requests (polling).

WebSockets establish a persistent bidirectional connection between client and server. Once opened, both parties can send messages at any time without the overhead of new connections.

Classic HTTP:
Client → Request → Server → Response → Connection closed
Client → Request → Server → Response → Connection closed

WebSocket:
Client ←→ Persistent connection ←→ Server
         (messages in both directions)

Why Socket.io?

Socket.io adds a layer on top of native WebSockets:

  • Automatic reconnection — resumes connection after network loss
  • Rooms — group connections by channel
  • Acknowledgements — confirm message delivery
  • HTTP fallback — works even when WebSockets are blocked
  • Binary support — send files and images
  • Namespaces — separate business logic

Step 1: Initialize the Project

Create a new Next.js project with TypeScript:

npx create-next-app@latest realtime-chat --typescript --tailwind --app --src-dir
cd realtime-chat

Install Socket.io for both server and client:

npm install socket.io socket.io-client

Your project structure will look like:

realtime-chat/
├── src/
│   ├── app/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── api/
│   ├── components/
│   ├── lib/
│   │   └── socket.ts
│   └── types/
│       └── chat.ts
├── server.ts          # Custom server
├── package.json
└── tsconfig.json

Step 2: Define TypeScript Types

Create shared types between client and server. This is one of TypeScript's advantages — Socket.io events are typed on both sides.

// src/types/chat.ts
 
export interface User {
  id: string;
  username: string;
  color: string;
  joinedAt: Date;
}
 
export interface Message {
  id: string;
  userId: string;
  username: string;
  color: string;
  content: string;
  room: string;
  timestamp: Date;
}
 
export interface Room {
  name: string;
  users: User[];
  messageCount: number;
}
 
// Server to client events
export interface ServerToClientEvents {
  "message:new": (message: Message) => void;
  "user:joined": (user: User, room: string) => void;
  "user:left": (userId: string, room: string) => void;
  "room:users": (users: User[]) => void;
  "room:list": (rooms: Room[]) => void;
  "typing:start": (userId: string, username: string) => void;
  "typing:stop": (userId: string) => void;
}
 
// Client to server events
export interface ClientToServerEvents {
  "message:send": (
    content: string,
    room: string,
    callback: (success: boolean) => void
  ) => void;
  "room:join": (room: string, username: string) => void;
  "room:leave": (room: string) => void;
  "typing:start": (room: string) => void;
  "typing:stop": (room: string) => void;
}

Step 3: Create the WebSocket Server

Next.js does not natively support WebSockets in its development server. We will create a custom server that wraps Next.js and adds Socket.io.

// server.ts
 
import { createServer } from "http";
import { parse } from "url";
import next from "next";
import { Server } from "socket.io";
import type {
  ServerToClientEvents,
  ClientToServerEvents,
  User,
  Message,
  Room,
} from "./src/types/chat";
 
const dev = process.env.NODE_ENV !== "production";
const hostname = "localhost";
const port = parseInt(process.env.PORT || "3000", 10);
 
const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();
 
// In-memory state (replace with Redis in production)
const rooms = new Map<string, Set<string>>();
const users = new Map<string, User>();
const messageHistory = new Map<string, Message[]>();
 
// Default rooms
const DEFAULT_ROOMS = ["general", "tech", "random"];
 
function generateId(): string {
  return Math.random().toString(36).substring(2, 15);
}
 
function getRandomColor(): string {
  const colors = [
    "#EF4444", "#F97316", "#EAB308", "#22C55E",
    "#06B6D4", "#3B82F6", "#8B5CF6", "#EC4899",
  ];
  return colors[Math.floor(Math.random() * colors.length)];
}
 
function getRoomData(roomName: string): Room {
  const roomUsers = rooms.get(roomName) || new Set();
  const roomUserList = Array.from(roomUsers)
    .map((id) => users.get(id))
    .filter(Boolean) as User[];
  const messages = messageHistory.get(roomName) || [];
  return {
    name: roomName,
    users: roomUserList,
    messageCount: messages.length,
  };
}
 
app.prepare().then(() => {
  const httpServer = createServer((req, res) => {
    const parsedUrl = parse(req.url!, true);
    handler(req, res, parsedUrl);
  });
 
  const io = new Server<ClientToServerEvents, ServerToClientEvents>(
    httpServer,
    {
      cors: {
        origin: dev ? "http://localhost:3000" : process.env.NEXT_PUBLIC_URL,
        methods: ["GET", "POST"],
      },
      pingTimeout: 60000,
      pingInterval: 25000,
    }
  );
 
  // Initialize default rooms
  DEFAULT_ROOMS.forEach((room) => {
    rooms.set(room, new Set());
    messageHistory.set(room, []);
  });
 
  io.on("connection", (socket) => {
    console.log(`Connected: ${socket.id}`);
 
    let currentUser: User | null = null;
    let currentRoom: string | null = null;
 
    // Join a room
    socket.on("room:join", (room: string, username: string) => {
      currentUser = {
        id: socket.id,
        username,
        color: getRandomColor(),
        joinedAt: new Date(),
      };
      users.set(socket.id, currentUser);
 
      // Leave previous room if needed
      if (currentRoom) {
        socket.leave(currentRoom);
        rooms.get(currentRoom)?.delete(socket.id);
        io.to(currentRoom).emit("user:left", socket.id, currentRoom);
        io.to(currentRoom).emit(
          "room:users",
          Array.from(rooms.get(currentRoom) || [])
            .map((id) => users.get(id))
            .filter(Boolean) as User[]
        );
      }
 
      // Join the new room
      currentRoom = room;
      socket.join(room);
 
      if (!rooms.has(room)) {
        rooms.set(room, new Set());
        messageHistory.set(room, []);
      }
      rooms.get(room)!.add(socket.id);
 
      // Notify other users
      socket.to(room).emit("user:joined", currentUser, room);
 
      // Send room user list
      const roomUsers = Array.from(rooms.get(room)!)
        .map((id) => users.get(id))
        .filter(Boolean) as User[];
      io.to(room).emit("room:users", roomUsers);
 
      // Send updated room list
      const roomList = Array.from(rooms.keys()).map(getRoomData);
      io.emit("room:list", roomList);
    });
 
    // Send a message
    socket.on("message:send", (content, room, callback) => {
      if (!currentUser || !room) {
        callback(false);
        return;
      }
 
      const message: Message = {
        id: generateId(),
        userId: currentUser.id,
        username: currentUser.username,
        color: currentUser.color,
        content: content.trim(),
        room,
        timestamp: new Date(),
      };
 
      // Save to history (keep last 100)
      const history = messageHistory.get(room) || [];
      history.push(message);
      if (history.length > 100) {
        history.shift();
      }
      messageHistory.set(room, history);
 
      // Broadcast to everyone in the room
      io.to(room).emit("message:new", message);
      callback(true);
    });
 
    // Typing indicators
    socket.on("typing:start", (room) => {
      if (currentUser) {
        socket
          .to(room)
          .emit("typing:start", currentUser.id, currentUser.username);
      }
    });
 
    socket.on("typing:stop", (room) => {
      if (currentUser) {
        socket.to(room).emit("typing:stop", currentUser.id);
      }
    });
 
    // Leave a room
    socket.on("room:leave", (room) => {
      socket.leave(room);
      rooms.get(room)?.delete(socket.id);
      if (currentUser) {
        io.to(room).emit("user:left", currentUser.id, room);
      }
      currentRoom = null;
 
      const roomUsers = Array.from(rooms.get(room) || [])
        .map((id) => users.get(id))
        .filter(Boolean) as User[];
      io.to(room).emit("room:users", roomUsers);
    });
 
    // Disconnect
    socket.on("disconnect", () => {
      console.log(`Disconnected: ${socket.id}`);
 
      if (currentRoom && currentUser) {
        rooms.get(currentRoom)?.delete(socket.id);
        io.to(currentRoom).emit("user:left", socket.id, currentRoom);
 
        const roomUsers = Array.from(rooms.get(currentRoom) || [])
          .map((id) => users.get(id))
          .filter(Boolean) as User[];
        io.to(currentRoom).emit("room:users", roomUsers);
      }
 
      users.delete(socket.id);
 
      const roomList = Array.from(rooms.keys()).map(getRoomData);
      io.emit("room:list", roomList);
    });
  });
 
  httpServer.listen(port, () => {
    console.log(`> Server ready at http://${hostname}:${port}`);
  });
});

Update the scripts in package.json:

{
  "scripts": {
    "dev": "tsx server.ts",
    "build": "next build",
    "start": "NODE_ENV=production tsx server.ts"
  }
}

Install tsx to run the TypeScript server:

npm install -D tsx

Step 4: Set Up the Socket.io Client

Create a utility module for the client connection:

// src/lib/socket.ts
 
import { io, Socket } from "socket.io-client";
import type {
  ServerToClientEvents,
  ClientToServerEvents,
} from "@/types/chat";
 
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
 
let socket: TypedSocket | null = null;
 
export function getSocket(): TypedSocket {
  if (!socket) {
    socket = io({
      autoConnect: false,
      reconnection: true,
      reconnectionAttempts: 10,
      reconnectionDelay: 1000,
      reconnectionDelayMax: 5000,
    });
  }
  return socket;
}
 
export function connectSocket(): TypedSocket {
  const s = getSocket();
  if (!s.connected) {
    s.connect();
  }
  return s;
}
 
export function disconnectSocket(): void {
  if (socket?.connected) {
    socket.disconnect();
  }
}

Step 5: Create the useSocket Hook

A custom React hook encapsulates all Socket.io logic and makes it reusable across components:

// src/hooks/useSocket.ts
 
"use client";
 
import { useEffect, useState, useCallback, useRef } from "react";
import { connectSocket, disconnectSocket, getSocket } from "@/lib/socket";
import type { Message, User, Room } from "@/types/chat";
 
interface UseSocketReturn {
  isConnected: boolean;
  messages: Message[];
  users: User[];
  rooms: Room[];
  typingUsers: Map<string, string>;
  joinRoom: (room: string, username: string) => void;
  sendMessage: (content: string, room: string) => Promise<boolean>;
  startTyping: (room: string) => void;
  stopTyping: (room: string) => void;
}
 
export function useSocket(): UseSocketReturn {
  const [isConnected, setIsConnected] = useState(false);
  const [messages, setMessages] = useState<Message[]>([]);
  const [users, setUsers] = useState<User[]>([]);
  const [rooms, setRooms] = useState<Room[]>([]);
  const [typingUsers, setTypingUsers] = useState<Map<string, string>>(
    new Map()
  );
  const typingTimeouts = useRef<Map<string, NodeJS.Timeout>>(new Map());
 
  useEffect(() => {
    const socket = connectSocket();
 
    function onConnect() {
      setIsConnected(true);
    }
 
    function onDisconnect() {
      setIsConnected(false);
    }
 
    function onNewMessage(message: Message) {
      setMessages((prev) => [...prev, message]);
    }
 
    function onUserJoined(user: User, room: string) {
      const systemMessage: Message = {
        id: `system-${Date.now()}`,
        userId: "system",
        username: "System",
        color: "#6B7280",
        content: `${user.username} joined the room`,
        room,
        timestamp: new Date(),
      };
      setMessages((prev) => [...prev, systemMessage]);
    }
 
    function onUserLeft(userId: string, room: string) {
      const systemMessage: Message = {
        id: `system-${Date.now()}`,
        userId: "system",
        username: "System",
        color: "#6B7280",
        content: `A user left the room`,
        room,
        timestamp: new Date(),
      };
      setMessages((prev) => [...prev, systemMessage]);
    }
 
    function onRoomUsers(updatedUsers: User[]) {
      setUsers(updatedUsers);
    }
 
    function onRoomList(updatedRooms: Room[]) {
      setRooms(updatedRooms);
    }
 
    function onTypingStart(userId: string, username: string) {
      setTypingUsers((prev) => {
        const next = new Map(prev);
        next.set(userId, username);
        return next;
      });
 
      const existing = typingTimeouts.current.get(userId);
      if (existing) clearTimeout(existing);
      typingTimeouts.current.set(
        userId,
        setTimeout(() => {
          setTypingUsers((prev) => {
            const next = new Map(prev);
            next.delete(userId);
            return next;
          });
        }, 3000)
      );
    }
 
    function onTypingStop(userId: string) {
      setTypingUsers((prev) => {
        const next = new Map(prev);
        next.delete(userId);
        return next;
      });
    }
 
    socket.on("connect", onConnect);
    socket.on("disconnect", onDisconnect);
    socket.on("message:new", onNewMessage);
    socket.on("user:joined", onUserJoined);
    socket.on("user:left", onUserLeft);
    socket.on("room:users", onRoomUsers);
    socket.on("room:list", onRoomList);
    socket.on("typing:start", onTypingStart);
    socket.on("typing:stop", onTypingStop);
 
    return () => {
      socket.off("connect", onConnect);
      socket.off("disconnect", onDisconnect);
      socket.off("message:new", onNewMessage);
      socket.off("user:joined", onUserJoined);
      socket.off("user:left", onUserLeft);
      socket.off("room:users", onRoomUsers);
      socket.off("room:list", onRoomList);
      socket.off("typing:start", onTypingStart);
      socket.off("typing:stop", onTypingStop);
      disconnectSocket();
    };
  }, []);
 
  const joinRoom = useCallback((room: string, username: string) => {
    const socket = getSocket();
    setMessages([]);
    socket.emit("room:join", room, username);
  }, []);
 
  const sendMessage = useCallback(
    (content: string, room: string): Promise<boolean> => {
      return new Promise((resolve) => {
        const socket = getSocket();
        socket.emit("message:send", content, room, (success) => {
          resolve(success);
        });
      });
    },
    []
  );
 
  const startTyping = useCallback((room: string) => {
    const socket = getSocket();
    socket.emit("typing:start", room);
  }, []);
 
  const stopTyping = useCallback((room: string) => {
    const socket = getSocket();
    socket.emit("typing:stop", room);
  }, []);
 
  return {
    isConnected,
    messages,
    users,
    rooms,
    typingUsers,
    joinRoom,
    sendMessage,
    startTyping,
    stopTyping,
  };
}

Step 6: Build the Chat Components

6.1 — The Join Form

// src/components/JoinForm.tsx
 
"use client";
 
import { useState } from "react";
 
interface JoinFormProps {
  onJoin: (username: string) => void;
}
 
export function JoinForm({ onJoin }: JoinFormProps) {
  const [username, setUsername] = useState("");
 
  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const trimmed = username.trim();
    if (trimmed.length >= 2 && trimmed.length <= 20) {
      onJoin(trimmed);
    }
  }
 
  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-950">
      <form
        onSubmit={handleSubmit}
        className="w-full max-w-sm rounded-2xl bg-gray-900 p-8 shadow-xl"
      >
        <h1 className="mb-6 text-center text-2xl font-bold text-white">
          Join the Chat
        </h1>
        <input
          type="text"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          placeholder="Your username"
          maxLength={20}
          className="mb-4 w-full rounded-lg bg-gray-800 px-4 py-3 text-white
                     placeholder-gray-500 outline-none ring-1 ring-gray-700
                     focus:ring-blue-500"
          autoFocus
        />
        <button
          type="submit"
          disabled={username.trim().length < 2}
          className="w-full rounded-lg bg-blue-600 py-3 font-semibold
                     text-white transition hover:bg-blue-500
                     disabled:opacity-40 disabled:cursor-not-allowed"
        >
          Enter
        </button>
      </form>
    </div>
  );
}

6.2 — The Message List

// src/components/MessageList.tsx
 
"use client";
 
import { useEffect, useRef } from "react";
import type { Message } from "@/types/chat";
 
interface MessageListProps {
  messages: Message[];
  currentUserId: string;
}
 
export function MessageList({ messages, currentUserId }: MessageListProps) {
  const bottomRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);
 
  return (
    <div className="flex-1 overflow-y-auto p-4 space-y-3">
      {messages.map((msg) => {
        const isSystem = msg.userId === "system";
        const isOwn = msg.userId === currentUserId;
 
        if (isSystem) {
          return (
            <div key={msg.id} className="text-center text-sm text-gray-500">
              {msg.content}
            </div>
          );
        }
 
        return (
          <div
            key={msg.id}
            className={`flex ${isOwn ? "justify-end" : "justify-start"}`}
          >
            <div
              className={`max-w-[70%] rounded-2xl px-4 py-2 ${
                isOwn
                  ? "bg-blue-600 text-white"
                  : "bg-gray-800 text-gray-100"
              }`}
            >
              {!isOwn && (
                <div
                  className="text-xs font-semibold mb-1"
                  style={{ color: msg.color }}
                >
                  {msg.username}
                </div>
              )}
              <p className="text-sm leading-relaxed">{msg.content}</p>
              <div
                className={`text-xs mt-1 ${
                  isOwn ? "text-blue-200" : "text-gray-500"
                }`}
              >
                {new Date(msg.timestamp).toLocaleTimeString("en-US", {
                  hour: "2-digit",
                  minute: "2-digit",
                })}
              </div>
            </div>
          </div>
        );
      })}
      <div ref={bottomRef} />
    </div>
  );
}

6.3 — The Message Input with Typing Indicator

// src/components/MessageInput.tsx
 
"use client";
 
import { useState, useRef, useCallback } from "react";
 
interface MessageInputProps {
  onSend: (content: string) => Promise<boolean>;
  onTypingStart: () => void;
  onTypingStop: () => void;
  typingUsers: Map<string, string>;
  disabled?: boolean;
}
 
export function MessageInput({
  onSend,
  onTypingStart,
  onTypingStop,
  typingUsers,
  disabled,
}: MessageInputProps) {
  const [content, setContent] = useState("");
  const [sending, setSending] = useState(false);
  const typingTimer = useRef<NodeJS.Timeout | null>(null);
  const isTyping = useRef(false);
 
  const handleTyping = useCallback(() => {
    if (!isTyping.current) {
      isTyping.current = true;
      onTypingStart();
    }
 
    if (typingTimer.current) {
      clearTimeout(typingTimer.current);
    }
 
    typingTimer.current = setTimeout(() => {
      isTyping.current = false;
      onTypingStop();
    }, 2000);
  }, [onTypingStart, onTypingStop]);
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const trimmed = content.trim();
    if (!trimmed || sending) return;
 
    setSending(true);
    isTyping.current = false;
    onTypingStop();
 
    const success = await onSend(trimmed);
    if (success) {
      setContent("");
    }
    setSending(false);
  }
 
  const typingNames = Array.from(typingUsers.values());
  let typingText = "";
  if (typingNames.length === 1) {
    typingText = `${typingNames[0]} is typing...`;
  } else if (typingNames.length === 2) {
    typingText = `${typingNames[0]} and ${typingNames[1]} are typing...`;
  } else if (typingNames.length > 2) {
    typingText = `${typingNames.length} people are typing...`;
  }
 
  return (
    <div className="border-t border-gray-800 bg-gray-900 p-4">
      {typingText && (
        <div className="mb-2 text-xs text-gray-400 animate-pulse">
          {typingText}
        </div>
      )}
      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          type="text"
          value={content}
          onChange={(e) => {
            setContent(e.target.value);
            handleTyping();
          }}
          onKeyDown={(e) => {
            if (e.key === "Enter" && !e.shiftKey) {
              handleSubmit(e);
            }
          }}
          placeholder="Type your message..."
          disabled={disabled}
          className="flex-1 rounded-lg bg-gray-800 px-4 py-3 text-white
                     placeholder-gray-500 outline-none ring-1 ring-gray-700
                     focus:ring-blue-500 disabled:opacity-50"
          autoFocus
        />
        <button
          type="submit"
          disabled={!content.trim() || sending || disabled}
          className="rounded-lg bg-blue-600 px-6 py-3 font-semibold
                     text-white transition hover:bg-blue-500
                     disabled:opacity-40 disabled:cursor-not-allowed"
        >
          Send
        </button>
      </form>
    </div>
  );
}

6.4 — The Sidebar with Rooms and Users

// src/components/Sidebar.tsx
 
"use client";
 
import type { Room, User } from "@/types/chat";
 
interface SidebarProps {
  rooms: Room[];
  currentRoom: string;
  users: User[];
  isConnected: boolean;
  onRoomSelect: (room: string) => void;
}
 
export function Sidebar({
  rooms,
  currentRoom,
  users,
  isConnected,
  onRoomSelect,
}: SidebarProps) {
  return (
    <aside className="flex w-64 flex-col border-r border-gray-800 bg-gray-900">
      {/* Connection status */}
      <div className="flex items-center gap-2 border-b border-gray-800 p-4">
        <div
          className={`h-2.5 w-2.5 rounded-full ${
            isConnected ? "bg-green-500" : "bg-red-500"
          }`}
        />
        <span className="text-sm text-gray-400">
          {isConnected ? "Connected" : "Disconnected"}
        </span>
      </div>
 
      {/* Room list */}
      <div className="flex-1 overflow-y-auto p-3">
        <h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
          Rooms
        </h2>
        <ul className="space-y-1">
          {rooms.map((room) => (
            <li key={room.name}>
              <button
                onClick={() => onRoomSelect(room.name)}
                className={`w-full rounded-lg px-3 py-2 text-left text-sm
                  transition ${
                    currentRoom === room.name
                      ? "bg-blue-600/20 text-blue-400"
                      : "text-gray-300 hover:bg-gray-800"
                  }`}
              >
                <span className="mr-1">#</span>
                {room.name}
                <span className="ml-auto text-xs text-gray-600">
                  {" "}
                  ({room.users.length})
                </span>
              </button>
            </li>
          ))}
        </ul>
      </div>
 
      {/* Online users */}
      <div className="border-t border-gray-800 p-3">
        <h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
          Online ({users.length})
        </h2>
        <ul className="space-y-1">
          {users.map((user) => (
            <li key={user.id} className="flex items-center gap-2 px-2 py-1">
              <div
                className="h-2 w-2 rounded-full"
                style={{ backgroundColor: user.color }}
              />
              <span className="text-sm text-gray-300">{user.username}</span>
            </li>
          ))}
        </ul>
      </div>
    </aside>
  );
}

Step 7: Assemble the Main Page

// src/app/page.tsx
 
"use client";
 
import { useState, useCallback } from "react";
import { useSocket } from "@/hooks/useSocket";
import { JoinForm } from "@/components/JoinForm";
import { Sidebar } from "@/components/Sidebar";
import { MessageList } from "@/components/MessageList";
import { MessageInput } from "@/components/MessageInput";
import { getSocket } from "@/lib/socket";
 
const DEFAULT_ROOM = "general";
 
export default function ChatPage() {
  const [username, setUsername] = useState<string | null>(null);
  const [currentRoom, setCurrentRoom] = useState(DEFAULT_ROOM);
 
  const {
    isConnected,
    messages,
    users,
    rooms,
    typingUsers,
    joinRoom,
    sendMessage,
    startTyping,
    stopTyping,
  } = useSocket();
 
  const handleJoin = useCallback(
    (name: string) => {
      setUsername(name);
      joinRoom(DEFAULT_ROOM, name);
    },
    [joinRoom]
  );
 
  const handleRoomSelect = useCallback(
    (room: string) => {
      if (room !== currentRoom && username) {
        setCurrentRoom(room);
        joinRoom(room, username);
      }
    },
    [currentRoom, username, joinRoom]
  );
 
  const handleSend = useCallback(
    (content: string) => sendMessage(content, currentRoom),
    [sendMessage, currentRoom]
  );
 
  const handleTypingStart = useCallback(
    () => startTyping(currentRoom),
    [startTyping, currentRoom]
  );
 
  const handleTypingStop = useCallback(
    () => stopTyping(currentRoom),
    [stopTyping, currentRoom]
  );
 
  if (!username) {
    return <JoinForm onJoin={handleJoin} />;
  }
 
  const socketId = getSocket().id || "";
 
  return (
    <div className="flex h-screen bg-gray-950 text-white">
      <Sidebar
        rooms={rooms}
        currentRoom={currentRoom}
        users={users}
        isConnected={isConnected}
        onRoomSelect={handleRoomSelect}
      />
      <main className="flex flex-1 flex-col">
        <header className="flex items-center border-b border-gray-800 px-6 py-4">
          <h1 className="text-lg font-semibold">
            <span className="text-gray-500 mr-1">#</span>
            {currentRoom}
          </h1>
          <span className="ml-3 text-sm text-gray-500">
            {users.length} member{users.length > 1 ? "s" : ""}
          </span>
        </header>
 
        <MessageList messages={messages} currentUserId={socketId} />
 
        <MessageInput
          onSend={handleSend}
          onTypingStart={handleTypingStart}
          onTypingStop={handleTypingStop}
          typingUsers={typingUsers}
          disabled={!isConnected}
        />
      </main>
    </div>
  );
}

Step 8: Test Locally

Start the development server:

npm run dev

Open two tabs at http://localhost:3000:

  1. In the first tab, enter a username (e.g., "Alice") and join the chat
  2. In the second tab, enter another username (e.g., "Bob")
  3. Send messages — they appear instantly in both tabs
  4. Start typing — the indicator appears in the other tab
  5. Switch rooms — online users update accordingly

Step 9: Production Improvements

9.1 — Robust Reconnection

Socket.io handles reconnection automatically, but you can fine-tune the behavior in the hook:

// Addition in useSocket.ts - inside the useEffect
 
socket.on("connect_error", (error) => {
  console.error("Connection error:", error.message);
});
 
socket.io.on("reconnect", (attempt) => {
  console.log(`Reconnected after ${attempt} attempt(s)`);
  // Rejoin room after reconnection
  if (currentRoom && username) {
    socket.emit("room:join", currentRoom, username);
  }
});
 
socket.io.on("reconnect_failed", () => {
  console.error("Reconnection failed");
});

9.2 — Scale with Redis for Multi-Server Deployments

In production with multiple server instances, WebSockets are connected to only one instance. The @socket.io/redis-adapter package synchronizes events across all instances:

npm install @socket.io/redis-adapter ioredis
// server.ts - add Redis adapter
 
import { createClient } from "ioredis";
import { createAdapter } from "@socket.io/redis-adapter";
 
const pubClient = new createClient({ host: "localhost", port: 6379 });
const subClient = pubClient.duplicate();
 
io.adapter(createAdapter(pubClient, subClient));

With this adapter, a message sent to instance A is automatically relayed to clients connected to instance B.

9.3 — Rate Limiting

Protect your server from abuse by limiting messages per second:

// Addition in server-side socket handling
 
const rateLimits = new Map<string, number[]>();
 
function isRateLimited(socketId: string): boolean {
  const now = Date.now();
  const timestamps = rateLimits.get(socketId) || [];
 
  // Keep only messages from the last 10 seconds
  const recent = timestamps.filter((t) => now - t < 10000);
  rateLimits.set(socketId, recent);
 
  if (recent.length >= 10) {
    return true; // Max 10 messages per 10 seconds
  }
 
  recent.push(now);
  return false;
}
 
// Inside the "message:send" handler
socket.on("message:send", (content, room, callback) => {
  if (isRateLimited(socket.id)) {
    callback(false);
    return;
  }
  // ... rest of the code
});

Step 10: Deploy with Docker

Dockerfile

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/server.ts ./
COPY --from=builder /app/src/types ./src/types
COPY --from=builder /app/tsconfig.json ./
 
EXPOSE 3000
CMD ["npx", "tsx", "server.ts"]

docker-compose.yml

version: "3.8"
services:
  chat:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - NEXT_PUBLIC_URL=https://chat.example.com
    restart: unless-stopped
 
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis_data:/data
 
volumes:
  redis_data:

Launch with:

docker compose up -d

Troubleshooting

Messages Are Not Received

  1. Make sure the custom server is running (not next dev directly)
  2. Open DevTools and check the Network tab — a WebSocket connection should be visible
  3. Check server logs for connection errors

CORS Error in Production

Make sure NEXT_PUBLIC_URL matches your application domain exactly. The protocol (https://) and port matter.

Typing Indicators Get Stuck

The 3-second timeout in the client-side hook should clean up indicators. If the issue persists, verify that typing:stop events are emitted on disconnect.

Memory Growing in Production

The in-memory state (Map) in our server is not suitable for large-scale production. Use Redis to store user sessions and message history.


Next Steps

  • Authentication — integrate Better Auth for user accounts
  • Persistence — store messages in PostgreSQL with Drizzle ORM
  • Push notifications — add browser notifications for messages received when out of focus
  • File sharing — use Socket.io binary capabilities to send images
  • Private messages — implement a DM system with private rooms
  • End-to-end encryption — add a security layer with Web Crypto API

Conclusion

You have built a complete real-time chat application with:

  • A custom WebSocket server integrated with Next.js using Socket.io
  • Typed events thanks to TypeScript on both sides
  • Chat rooms with presence management
  • Real-time typing indicators
  • Production strategies: reconnection, Redis, rate limiting, and Docker

WebSockets open a world of possibilities beyond chat: collaborative boards, multiplayer games, live notifications, shared code editors. Socket.io provides the solid foundation to build all of this with an elegant API and robust connection management.

● Tags
#websockets#socketio#nextjs#react#typescript#realtime#chat#2026#intermediate#30 min read
● Share
● A question?

Talk to a Noqta agent about this article.

AI Bot
AI Bot
Author · noqta
Follow ↗

● Read next

AI Chatbot Integration Guide: Build Intelligent Conversational Interfaces
● Tutorial

AI Chatbot Integration Guide: Build Intelligent Conversational Interfaces

Jan 25, 2026
Introduction to MCP: A Beginner's Quickstart Guide
● Tutorial

Introduction to MCP: A Beginner's Quickstart Guide

Jan 25, 2026
Building a RAG Chatbot with Supabase pgvector and Next.js
● Tutorial

Building a RAG Chatbot with Supabase pgvector and Next.js

Jan 28, 2026
Noqta
Terms and Conditions · Privacy Policy
Services
  • AI Automation
  • AI Agents
  • CX Automation
  • Vibe Coding
  • Project Management
  • Quality Assurance
  • Web Development
  • API Integration
  • Business Applications
  • Maintenance
  • Low-Code/No-Code
Links
  • About Us
  • How It Works?
  • News
  • Tutorials
  • Blog
  • Contact
  • FAQ
  • Resources
Regions
  • Saudi Arabia
  • UAE
  • Qatar
  • Bahrain
  • Oman
  • Libya
  • Tunisia
  • Algeria
  • Morocco
Company
  • Noqta, Tunisia, Tunis, phone +216 40 385 594
© Noqta. All rights reserved.