Back to blog
Building Real-Time Features with WebSockets in Next.js
How to implement WebSocket communication for live notifications, collaborative editing, and real-time dashboards in modern web applications.
8 min read·Talha Bilal
Share:

Introduction
Real-time features—live notifications, collaborative editing, instant updates—have become table stakes for modern web applications. Users expect changes to appear instantly without refreshing the page.
While REST APIs work great for request-response patterns, they fall short for bidirectional, event-driven communication. That's where WebSockets come in.
In this post, I'll walk through implementing WebSocket-based real-time features in a Next.js application, covering connection management, authentication, event handling, and scaling considerations.
Why WebSockets Over Alternatives?
Before diving into implementation, let's understand when to use WebSockets:
When to use WebSockets:
- Real-time notifications (chat, alerts)
- Collaborative features (shared cursors, live editing)
- Live dashboards with frequent updates
- Gaming or interactive experiences
When NOT to use WebSockets:
- Simple periodic updates → use polling or Server-Sent Events (SSE)
- One-way server updates → SSE is simpler
- Infrequent changes → regular API calls are fine
Trade-offs:
- ✅ True bidirectional communication
- ✅ Lower latency than polling
- ✅ Efficient for frequent updates
- ❌ More complex than HTTP
- ❌ Requires stateful server connections
- ❌ Harder to scale horizontally
Architecture Overview
Here's the stack I use for WebSocket features:
- Next.js - Frontend and API routes
- Socket.IO - WebSocket library with fallbacks
- Redis - Pub/sub for multi-server scaling
- PostgreSQL - Persistent storage for messages/events
Rendering diagram...
Setting Up Socket.IO in Next.js
Next.js doesn't natively support WebSockets, but we can extend the server:
1. Install Dependencies
bash
1pnpm add socket.io socket.io-client2pnpm add -D @types/socket.io2. Create Custom Server
typescript
1// server.ts2import { createServer } from "http";3import { parse } from "url";4import next from "next";5import { Server as SocketServer } from "socket.io";6import { initializeSocketHandlers } from "./lib/socket/handlers";7
8const dev = process.env.NODE_ENV !== "production";9const app = next({ dev });10const handle = app.getRequestHandler();11
12app.prepare().then(() => {13 const server = createServer((req, res) => {14 const parsedUrl = parse(req.url!, true);15 handle(req, res, parsedUrl);16 });17
18 // Initialize Socket.IO19 const io = new SocketServer(server, {20 cors: {21 origin: process.env.NEXT_PUBLIC_APP_URL,22 credentials: true,23 },24 });25
26 initializeSocketHandlers(io);27
28 const port = process.env.PORT || 3000;29 server.listen(port, () => {30 console.log(`> Ready on http://localhost:${port}`);31 });32});3. Update package.json
json
1{2 "scripts": {3 "dev": "tsx server.ts",4 "build": "next build",5 "start": "NODE_ENV=production tsx server.ts"6 }7}Authentication & Authorization
Never trust client connections. Always authenticate:
typescript
1// lib/socket/handlers.ts2import { Server, Socket } from "socket.io";3import { verifyJWT } from "@/lib/auth";4
5interface AuthenticatedSocket extends Socket {6 userId: string;7 tenantId: string;8}9
10export function initializeSocketHandlers(io: Server) {11 // Authentication middleware12 io.use(async (socket, next) => {13 try {14 const token = socket.handshake.auth.token;15 16 if (!token) {17 return next(new Error("Authentication required"));18 }19
20 // Verify JWT token21 const payload = await verifyJWT(token);22 23 // Attach user info to socket24 (socket as AuthenticatedSocket).userId = payload.userId;25 (socket as AuthenticatedSocket).tenantId = payload.tenantId;26 27 next();28 } catch (error) {29 next(new Error("Invalid token"));30 }31 });32
33 io.on("connection", (socket: Socket) => {34 const authSocket = socket as AuthenticatedSocket;35 36 console.log("Client connected", {37 userId: authSocket.userId,38 socketId: authSocket.id,39 });40
41 // Join user to their personal room42 socket.join(`user:${authSocket.userId}`);43 44 // Join user to their tenant room45 socket.join(`tenant:${authSocket.tenantId}`);46
47 handleEvents(authSocket);48 });49}Client-Side Connection
typescript
1// lib/socket/client.ts2"use client";3
4import { io, Socket } from "socket.io-client";5
6let socket: Socket | null = null;7
8export function connectSocket(token: string): Socket {9 if (socket?.connected) {10 return socket;11 }12
13 socket = io(process.env.NEXT_PUBLIC_API_URL || "", {14 auth: { token },15 reconnection: true,16 reconnectionDelay: 1000,17 reconnectionDelayMax: 5000,18 reconnectionAttempts: 5,19 });20
21 socket.on("connect", () => {22 console.log("WebSocket connected");23 });24
25 socket.on("disconnect", (reason) => {26 console.log("WebSocket disconnected:", reason);27 });28
29 socket.on("connect_error", (error) => {30 console.error("Connection error:", error.message);31 });32
33 return socket;34}35
36export function disconnectSocket() {37 socket?.disconnect();38 socket = null;39}40
41export function getSocket(): Socket | null {42 return socket;43}React Hook for Socket Connection
typescript
1// hooks/useSocket.ts2"use client";3
4import { useEffect, useState } from "react";5import { Socket } from "socket.io-client";6import { connectSocket, disconnectSocket, getSocket } from "@/lib/socket/client";7import { useAuth } from "@/hooks/useAuth";8
9export function useSocket() {10 const { token } = useAuth();11 const [socket, setSocket] = useState<Socket | null>(null);12 const [isConnected, setIsConnected] = useState(false);13
14 useEffect(() => {15 if (!token) return;16
17 const socketInstance = connectSocket(token);18 setSocket(socketInstance);19
20 function onConnect() {21 setIsConnected(true);22 }23
24 function onDisconnect() {25 setIsConnected(false);26 }27
28 socketInstance.on("connect", onConnect);29 socketInstance.on("disconnect", onDisconnect);30
31 return () => {32 socketInstance.off("connect", onConnect);33 socketInstance.off("disconnect", onDisconnect);34 disconnectSocket();35 };36 }, [token]);37
38 return { socket, isConnected };39}Implementing Real-Time Notifications
Let's build a notification system:
Server-Side Event Handler
typescript
1// lib/socket/handlers.ts2function handleEvents(socket: AuthenticatedSocket) {3 // Mark notification as read4 socket.on("notification:read", async (notificationId: string) => {5 try {6 await db.notification.update({7 where: {8 id: notificationId,9 userId: socket.userId, // Authorization check10 },11 data: { read: true },12 });13
14 // Broadcast to user's other devices15 socket.to(`user:${socket.userId}`).emit("notification:updated", {16 id: notificationId,17 read: true,18 });19 } catch (error) {20 socket.emit("error", { message: "Failed to mark notification as read" });21 }22 });23
24 // Handle disconnect25 socket.on("disconnect", () => {26 console.log("Client disconnected", { userId: socket.userId });27 });28}Client-Side Notification Component
typescript
1// components/notifications.tsx2"use client";3
4import { useEffect, useState } from "react";5import { useSocket } from "@/hooks/useSocket";6
7interface Notification {8 id: string;9 title: string;10 message: string;11 read: boolean;12 createdAt: string;13}14
15export function NotificationList() {16 const { socket, isConnected } = useSocket();17 const [notifications, setNotifications] = useState<Notification[]>([]);18
19 useEffect(() => {20 if (!socket) return;21
22 // Listen for new notifications23 socket.on("notification:new", (notification: Notification) => {24 setNotifications((prev) => [notification, ...prev]);25 26 // Show browser notification27 if (Notification.permission === "granted") {28 new Notification(notification.title, {29 body: notification.message,30 });31 }32 });33
34 // Listen for notification updates35 socket.on("notification:updated", ({ id, read }) => {36 setNotifications((prev) =>37 prev.map((n) => (n.id === id ? { ...n, read } : n))38 );39 });40
41 return () => {42 socket.off("notification:new");43 socket.off("notification:updated");44 };45 }, [socket]);46
47 const markAsRead = (id: string) => {48 socket?.emit("notification:read", id);49 };50
51 return (52 <div>53 <h2>Notifications {!isConnected && "(Offline)"}</h2>54 {notifications.map((notification) => (55 <div56 key={notification.id}57 className={notification.read ? "opacity-50" : ""}58 onClick={() => markAsRead(notification.id)}59 >60 <h3>{notification.title}</h3>61 <p>{notification.message}</p>62 </div>63 ))}64 </div>65 );66}Broadcasting Events
When a server action happens, broadcast to relevant clients:
typescript
1// lib/socket/broadcast.ts2import { getIO } from "./server";3
4export async function notifyUser(userId: string, notification: Notification) {5 const io = getIO();6 7 // Send to all sockets in user's room8 io.to(`user:${userId}`).emit("notification:new", notification);9}10
11export async function notifyTenant(tenantId: string, event: TenantEvent) {12 const io = getIO();13 14 // Broadcast to all users in tenant15 io.to(`tenant:${tenantId}`).emit("tenant:event", event);16}17
18// Usage in API route19export async function POST(req: Request) {20 const ticket = await createTicket(req.body);21 22 // Broadcast to assignee23 await notifyUser(ticket.assigneeId, {24 id: generateId(),25 title: "New Ticket Assigned",26 message: `You've been assigned ticket #${ticket.number}`,27 read: false,28 createdAt: new Date().toISOString(),29 });30 31 return Response.json({ ticket });32}Scaling with Redis Pub/Sub
When you have multiple server instances, use Redis to broadcast events:
typescript
1// lib/socket/redis-adapter.ts2import { createAdapter } from "@socket.io/redis-adapter";3import { createClient } from "redis";4
5export async function setupRedisAdapter(io: Server) {6 const pubClient = createClient({ url: process.env.REDIS_URL });7 const subClient = pubClient.duplicate();8
9 await Promise.all([pubClient.connect(), subClient.connect()]);10
11 io.adapter(createAdapter(pubClient, subClient));12
13 console.log("Redis adapter initialized");14}15
16// In server.ts17app.prepare().then(async () => {18 const server = createServer(/* ... */);19 const io = new SocketServer(server);20
21 // Set up Redis adapter for multi-server scaling22 await setupRedisAdapter(io);23
24 initializeSocketHandlers(io);25 26 server.listen(port);27});Now events broadcast across all server instances automatically.
Performance Optimization
1. Room-Based Broadcasting
Don't emit to all clients—use rooms:
typescript
1// ❌ Bad: Broadcasts to ALL connected clients2io.emit("ticket:updated", ticket);3
4// ✅ Good: Only to relevant users5io.to(`tenant:${ticket.tenantId}`).emit("ticket:updated", ticket);6
7// ✅ Better: Only to users viewing this ticket8io.to(`ticket:${ticket.id}`).emit("ticket:updated", ticket);2. Throttling Updates
For rapidly changing data (dashboards, analytics), throttle updates:
typescript
1import { throttle } from "lodash";2
3const broadcastMetrics = throttle((metrics: Metrics) => {4 io.to("dashboard").emit("metrics:update", metrics);5}, 1000); // Max once per second6
7// Usage8export async function updateMetrics() {9 const metrics = await calculateMetrics();10 broadcastMetrics(metrics);11}3. Connection Limits
Limit connections per user:
typescript
1const connectionCounts = new Map<string, number>();2
3io.use((socket, next) => {4 const userId = socket.userId;5 const count = connectionCounts.get(userId) || 0;6
7 if (count >= 5) {8 return next(new Error("Too many connections"));9 }10
11 connectionCounts.set(userId, count + 1);12 13 socket.on("disconnect", () => {14 connectionCounts.set(userId, count);15 });16
17 next();18});Error Handling
Handle errors gracefully on both sides:
Server-Side
typescript
1function handleEvents(socket: AuthenticatedSocket) {2 socket.on("ticket:create", async (data) => {3 try {4 const ticket = await createTicket(data, socket.userId);5 socket.emit("ticket:created", ticket);6 } catch (error) {7 socket.emit("error", {8 event: "ticket:create",9 message: error.message,10 });11 }12 });13}Client-Side
typescript
1useEffect(() => {2 if (!socket) return;3
4 socket.on("error", ({ event, message }) => {5 toast.error(`Error: ${message}`);6 console.error(`Socket error on ${event}:`, message);7 });8
9 return () => {10 socket.off("error");11 };12}, [socket]);Monitoring & Debugging
Track WebSocket health:
typescript
1// Log connection stats2io.on("connection", (socket) => {3 const connectionCount = io.sockets.sockets.size;4 5 logger.info("socket_connected", {6 userId: socket.userId,7 totalConnections: connectionCount,8 });9});10
11// Monitor room sizes12setInterval(() => {13 const rooms = io.sockets.adapter.rooms;14 15 for (const [room, sockets] of rooms.entries()) {16 if (!room.startsWith("user:") && !room.startsWith("tenant:")) continue;17 18 logger.info("room_stats", {19 room,20 connectionCount: sockets.size,21 });22 }23}, 60000); // Every minuteKey Takeaways
- Authenticate every connection - never trust the client
- Use rooms for targeted broadcasting - don't spam all clients
- Scale with Redis - required for multi-server deployments
- Throttle rapid updates - balance real-time vs. performance
- Handle disconnections gracefully - clients will disconnect often
- Monitor connection health - track room sizes and message rates
WebSockets enable powerful real-time features, but they require careful engineering. Follow these patterns and you'll build reliable, scalable real-time experiences.
Need help implementing real-time features? Let's talk.
Related Articles

TypeScript Patterns for Scalable Frontend Applications
Advanced TypeScript techniques for building type-safe, maintainable React applications with real-world examples.
Read more

Backend Architecture for Modern SaaS Applications
A deep dive into scalable backend patterns, database design, and API architecture that power production SaaS platforms.
Read more

Database Schema Design: Lessons from Production Systems
Practical patterns for designing maintainable, scalable PostgreSQL schemas for SaaS applications with real-world examples.
Read more
