Building Real-Time Apps with WebSocket and Next.js
A deep dive into how WebSocket works, when to use it over polling, and how to build a production-ready real-time chat app with Next.js, TypeScript, and socket.io.
When I built Incognito Chat — a real-time anonymous messaging app — the biggest decision was choosing how to push data from the server to every connected client instantly. HTTP polling felt hacky. Server-Sent Events only go one way. WebSocket was the right answer. This post is a complete, production-oriented guide to understanding and implementing WebSocket in a Next.js + TypeScript application.
1. What Is WebSocket?
WebSocket is a full-duplex communication protocol built on top of a single TCP connection. Unlike HTTP, which follows a strict request → response cycle, WebSocket keeps the connection alive so both the client and server can push data at any time without re-establishing the connection.
The handshake starts as an HTTP request with an Upgrade header:
GET /chat HTTP/1.1 Host: chat.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13
Once the server responds with 101 Switching Protocols, the channel is open — no more HTTP overhead per message.
2. WebSocket vs Polling vs SSE
Before picking a real-time strategy, understand the trade-offs:
| Strategy | Direction | Latency | Overhead | Best For |
|---|---|---|---|---|
| Short Polling | Client → Server | High | Very High | Simple dashboards |
| Long Polling | Client → Server | Medium | High | Basic notifications |
| SSE | Server → Client only | Low | Low | Live feeds, logs |
| WebSocket | Bidirectional | Very Low | Very Low | Chat, games, collab |
Rule of thumb: if you need the client to send data back to the server in real time (messages, cursor positions, game events), use WebSocket. If it is broadcast-only (stock prices, alerts), SSE is simpler.
3. Setting Up the Next.js Project
npx create-next-app@latest incognito-chat \ --typescript --tailwind --app --src-dir cd incognito-chat npm install socket.io socket.io-client
We use socket.io instead of raw WebSocket because it adds automatic reconnection, rooms, namespaces, and a reliable fallback to long-polling when WebSocket is unavailable (useful in corporate proxies).
4. Creating the WebSocket Server
Next.js App Router does not natively expose a Node.js http.Server. The cleanest pattern is a custom server file:
📄 server.ts
import { createServer } from "http";
import { parse } from "url";
import next from "next";
import { Server as IOServer } from "socket.io";
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const httpServer = createServer((req, res) => {
const parsedUrl = parse(req.url!, true);
handle(req, res, parsedUrl);
});
const io = new IOServer(httpServer, {
cors: { origin: "*", methods: ["GET", "POST"] },
});
io.on("connection", (socket) => {
console.log("🟢 Client connected:", socket.id);
// Join a named room (channel / chat room)
socket.on("join_room", (room: string) => {
socket.join(room);
socket.to(room).emit("user_joined", { id: socket.id });
});
// Broadcast a message to the room
socket.on("send_message", (payload: {
room: string;
text: string;
username: string;
}) => {
io.to(payload.room).emit("receive_message", {
...payload,
id: crypto.randomUUID(),
timestamp: Date.now(),
});
});
socket.on("disconnect", () => {
console.log("🔴 Client disconnected:", socket.id);
});
});
httpServer.listen(3000, () => {
console.log("🚀 Server ready on http://localhost:3000");
});
});Update package.json to run the custom server:
"scripts": {
"dev": "tsx server.ts",
"build": "next build",
"start": "NODE_ENV=production tsx server.ts"
}5. Custom Hook — useSocket
Centralise all socket logic in a custom hook so any component can subscribe to events without managing the socket lifecycle individually.
📄 hooks/useSocket.ts
"use client";
import { useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
export type Message = {
id: string;
room: string;
text: string;
username: string;
timestamp: number;
};
export function useSocket(room: string, username: string) {
const socketRef = useRef<Socket | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [connected, setConnected] = useState(false);
useEffect(() => {
const socket = io(process.env.NEXT_PUBLIC_SOCKET_URL ?? "", {
transports: ["websocket"], // skip polling upgrade
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
socketRef.current = socket;
socket.on("connect", () => {
setConnected(true);
socket.emit("join_room", room);
});
socket.on("receive_message", (msg: Message) => {
setMessages((prev) => [...prev, msg]);
});
socket.on("disconnect", () => setConnected(false));
return () => {
socket.disconnect();
};
}, [room]);
const sendMessage = (text: string) => {
socketRef.current?.emit("send_message", { room, text, username });
};
return { messages, connected, sendMessage };
}useEffect that calls socket.disconnect() — otherwise you accumulate phantom connections every time React remounts the component in StrictMode.6. Building the Chat UI
📄 app/chat/[room]/page.tsx
"use client";
import { useState, useRef, useEffect } from "react";
import { useSocket } from "@/hooks/useSocket";
export default function ChatRoom({
params,
}: {
params: { room: string };
}) {
const username = useRef(
"user_" + Math.random().toString(36).slice(2, 7)
).current;
const { messages, connected, sendMessage } = useSocket(
params.room,
username
);
const [input, setInput] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to latest message
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = () => {
if (!input.trim()) return;
sendMessage(input.trim());
setInput("");
};
return (
<div className="flex h-screen flex-col bg-background text-foreground">
{/* Header */}
<header className="flex items-center gap-3 border-b px-4 py-3">
<span className={
`h-2 w-2 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`
} />
<h1 className="font-mono text-sm">
#{params.room}
</h1>
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-3">
{messages.map((msg) => (
<div key={msg.id} className={
`flex flex-col ${
msg.username === username ? "items-end" : "items-start"
}`
}>
<p className="text-xs text-muted-foreground mb-1">
{msg.username}
</p>
<div className={
`max-w-xs rounded-2xl px-4 py-2 text-sm ${
msg.username === username
? "bg-primary text-primary-foreground"
: "bg-muted"
}`
}>
{msg.text}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="border-t px-4 py-3 flex gap-2">
<input
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm outline-none"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Type a message…"
/>
<button
onClick={handleSend}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground"
>
Send
</button>
</div>
</div>
);
}7. Performance Considerations
7.1 Rooms and Namespaces
Instead of broadcasting every message to every connected socket, use socket.io rooms. Only sockets inside the room receive the event — critical at scale.
// ✅ Targeted — only users in "room-123" receive it
io.to("room-123").emit("receive_message", payload);
// ❌ Broadcast to ALL connected clients
io.emit("receive_message", payload);7.2 Throttle High-Frequency Events
Typing indicators, cursor movements, and similar events fire many times per second. Throttle them on the client before emitting:
import { throttle } from "lodash-es";
const emitTyping = throttle(() => {
socket.emit("typing", { room, username });
}, 500); // At most once every 500 ms7.3 Connection Limits & Redis Adapter
A single Node.js process can handle ~10–50k concurrent WebSocket connections depending on message frequency. When you need to scale horizontally across multiple servers, use the socket.io Redis adapter to synchronise events between instances:
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));8. Security
- JWT Authentication on connect: Validate a token in the
io.use()middleware before allowing the socket to join any room. - Input sanitization: Never trust client payloads. Validate with zod on the server before broadcasting.
- Rate limiting: Use
socket.io-rate-limiteror custom middleware to prevent spam. - CORS: Set
originto your exact domain, never"*"in production.
// Auth middleware
io.use(async (socket, next) => {
const token = socket.handshake.auth.token as string;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
socket.data.user = decoded;
next();
} catch {
next(new Error("Unauthorized"));
}
});9. Deploying to Production
A simple Dockerfile for containerised deployments:
FROM node:20-alpine AS base WORKDIR /app COPY package*.json ./ RUN npm ci FROM base AS builder COPY . . RUN npm run build FROM node:20-alpine AS runner WORKDIR /app COPY --from=builder /app . ENV NODE_ENV=production EXPOSE 3000 CMD ["node", "dist/server.js"]
Enable sticky sessions on your load balancer if you run multiple instances without the Redis adapter — otherwise a client may reconnect to a different server that doesn't have its room state.
Checklist
Low Priority
Medium Priority
High Priority