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-chatInstall Socket.io for both server and client:
npm install socket.io socket.io-clientYour 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 tsxStep 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 devOpen two tabs at http://localhost:3000:
- In the first tab, enter a username (e.g., "Alice") and join the chat
- In the second tab, enter another username (e.g., "Bob")
- Send messages — they appear instantly in both tabs
- Start typing — the indicator appears in the other tab
- 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 -dTroubleshooting
Messages Are Not Received
- Make sure the custom server is running (not
next devdirectly) - Open DevTools and check the Network tab — a WebSocket connection should be visible
- 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.