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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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.


Want to read more tutorials? Check out our latest tutorial on 1 Laravel 11 Basics: PHP in 15 minutes.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build a Local AI Chatbot with Ollama and Next.js: Complete Guide

Build a private, fully local AI chatbot using Ollama and Next.js. This hands-on tutorial covers installation, streaming responses, model selection, and deploying a production-ready chat interface — all without sending data to the cloud.

25 min read·