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

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.
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 Full-Stack App with Firebase and Next.js 15: Auth, Firestore & Real-Time
Learn how to build a full-stack app with Next.js 15 and Firebase. This guide covers authentication, Firestore, real-time updates, Server Actions, and deployment to Vercel.

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

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.