Building real-time features with Socket.IO in Next.js applications — chat systems, live notifications, presence indicators, and scalable WebSocket architecture.
Raw WebSockets Socket.IO
──────────────── ────────────────
Manual reconnection Auto-reconnect with backoff
No rooms/namespaces Built-in rooms & namespaces
No fallback HTTP long-polling fallback
Manual serialization Automatic serialization
No acknowledgements Request-response pattern
Manual heartbeat Built-in heartbeat
┌──────────────────┐ ┌──────────────────┐
│ Next.js App │────▶│ WebSocket Server │
│ (Client Pages) │◀────│ (Socket.IO) │
└──────────────────┘ └──────────────────┘
│
▼
┌──────────┐
│ PostgreSQL│
│ (Prisma) │
└──────────┘
Our WebSocket server runs as a separate process alongside the Next.js application.
// server.ts
import { Server } from "socket.io";
import { createServer } from "http";
import { verifyToken } from "./lib/auth";
const httpServer = createServer();
const io = new Server(httpServer, {
cors: {
origin: process.env.NEXT_PUBLIC_APP_URL,
credentials: true,
},
pingTimeout: 60000,
pingInterval: 25000,
});
// Authentication middleware
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
if (!token) return next(new Error("Authentication required"));
const user = await verifyToken(token);
if (!user) return next(new Error("Invalid token"));
socket.data.user = user;
next();
});
// Connection handler
io.on("connection", (socket) => {
const user = socket.data.user;
console.log(`Connected: ${user.email} (${user.role})`);
// Join user-specific room
socket.join(`user:${user.userId}`);
// Join role-based room
socket.join(`role:${user.role}`);
// Handle events
socket.on("chat:message", handleChatMessage(socket, io));
socket.on("notification:read", handleNotificationRead(socket));
socket.on("presence:update", handlePresenceUpdate(socket, io));
socket.on("disconnect", () => {
io.emit("presence:offline", { userId: user.userId });
});
});
httpServer.listen(3001, () => {
console.log("WebSocket server on :3001");
});
// hooks/useSocket.ts
"use client";
import { useEffect, useRef, useCallback } from "react";
import { io, Socket } from "socket.io-client";
export function useSocket() {
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const token = document.cookie
.split("; ")
.find((c) => c.startsWith("token="))
?.split("=")[1];
if (!token) return;
const socket = io(process.env.NEXT_PUBLIC_WS_URL!, {
auth: { token },
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
socket.on("connect", () => {
console.log("Socket connected:", socket.id);
});
socket.on("connect_error", (err) => {
console.error("Socket auth error:", err.message);
});
socketRef.current = socket;
return () => {
socket.disconnect();
};
}, []);
const emit = useCallback(
(event: string, data: Record<string, unknown>) => {
socketRef.current?.emit(event, data);
},
[]
);
const on = useCallback(
(event: string, handler: (data: unknown) => void) => {
socketRef.current?.on(event, handler);
return () => {
socketRef.current?.off(event, handler);
};
},
[]
);
return { socket: socketRef.current, emit, on };
}
// Real-time chat handler
function handleChatMessage(socket: Socket, io: Server) {
return async (data: { conversationId: string; message: string }) => {
const user = socket.data.user;
// Save to database
const message = await prisma.message.create({
data: {
conversationId: data.conversationId,
senderId: user.userId,
content: data.message,
},
include: { sender: { select: { name: true, avatar: true } } },
});
// Broadcast to conversation participants
io.to(`conversation:${data.conversationId}`).emit("chat:new", message);
// Send push notification to offline users
const offlineUsers = await getOfflineParticipants(data.conversationId);
for (const userId of offlineUsers) {
await createNotification(userId, "New message", data.message);
}
};
}
"use client";
import { useSocket } from "@/hooks/useSocket";
import { useEffect, useState } from "react";
import { Bell } from "lucide-react";
interface Notification {
id: string;
title: string;
message: string;
read: boolean;
createdAt: string;
}
export function NotificationBell() {
const { on } = useSocket();
const [notifications, setNotifications] = useState<Notification[]>([]);
const unreadCount = notifications.filter((n) => !n.read).length;
useEffect(() => {
const cleanup = on("notification:new", (data) => {
setNotifications((prev) => [data as Notification, ...prev]);
});
return cleanup;
}, [on]);
return (
<button className="relative p-2">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white">
{unreadCount}
</span>
)}
</button>
);
}
// Track online users
const onlineUsers = new Map<string, { socketId: string; lastSeen: Date }>();
function handlePresenceUpdate(socket: Socket, io: Server) {
return () => {
const userId = socket.data.user.userId;
onlineUsers.set(userId, {
socketId: socket.id,
lastSeen: new Date(),
});
io.emit("presence:online", { userId });
};
}
// Broadcast online count every 30s
setInterval(() => {
io.emit("presence:count", { online: onlineUsers.size });
}, 30000);
For production with multiple servers:
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));
Redis adapter ensures messages reach all connected clients, regardless of which server they're connected to.
The Developer Portfolio Platform includes a dedicated WebSocket server (hk-websocket-server/) with:
Related reads:
Follow on X/Twitter for real-time development tips.
Real-time built-in. Our Developer Portfolio Platform includes a WebSocket server with chat, notifications, and presence — ready for production.
Get the latest articles, tutorials, and updates delivered straight to your inbox. No spam, unsubscribe at any time.