All Posts
WebSocketNext.jsTypeScriptReal-Time

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.

19 December 202512 min read

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:

StrategyDirectionLatencyOverheadBest For
Short PollingClient → ServerHighVery HighSimple dashboards
Long PollingClient → ServerMediumHighBasic notifications
SSEServer → Client onlyLowLowLive feeds, logs
WebSocketBidirectionalVery LowVery LowChat, 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 };
}
💡 Always return a cleanup function from 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 ms

7.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-limiter or custom middleware to prevent spam.
  • CORS: Set origin to 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

ℹ️ Vercel Serverless Functions do NOT support persistent WebSocket connections. Deploy to a platform that gives you a long-running Node process: Railway, Render, Fly.io, DigitalOcean, or a VPS.

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

Links / Resources / References

Quotes #

You have the right to work, but for the work's sake only. You have no right to the fruits of work.

— Bhagavad Gita