Skip to main content
Back to Blog
Tutorials
4 min read
December 12, 2024

How to Implement WebSocket Real-Time Updates in Next.js

Implement real-time updates in Next.js using WebSocket with Socket.io for self-hosted or Pusher for managed infrastructure.

Ryel Banfield

Founder & Lead Developer

Real-time updates make apps feel alive — live notifications, chat messages, collaborative editing. Here are two approaches: Pusher (managed) and Socket.io (self-hosted).

Option 1: Pusher (Managed)

Pusher handles WebSocket infrastructure for you. Great for serverless deployments.

Install

pnpm add pusher pusher-js

Server-Side Client

// lib/pusher-server.ts
import Pusher from "pusher";

export const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
  useTLS: true,
});

Client-Side Hook

// hooks/use-pusher.ts
"use client";

import { useEffect, useRef } from "react";
import PusherClient from "pusher-js";

let pusherInstance: PusherClient | null = null;

function getPusher() {
  if (!pusherInstance) {
    pusherInstance = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
    });
  }
  return pusherInstance;
}

export function usePusherChannel(
  channelName: string,
  eventName: string,
  callback: (data: unknown) => void
) {
  const callbackRef = useRef(callback);
  callbackRef.current = callback;

  useEffect(() => {
    const pusher = getPusher();
    const channel = pusher.subscribe(channelName);

    channel.bind(eventName, (data: unknown) => {
      callbackRef.current(data);
    });

    return () => {
      channel.unbind(eventName);
      pusher.unsubscribe(channelName);
    };
  }, [channelName, eventName]);
}

Trigger Events from API Routes

// app/api/messages/route.ts
import { NextRequest, NextResponse } from "next/server";
import { pusher } from "@/lib/pusher-server";
import { z } from "zod";

const MessageSchema = z.object({
  channelId: z.string(),
  content: z.string().min(1).max(2000),
  userId: z.string(),
});

export async function POST(request: NextRequest) {
  const body = await request.json();
  const parsed = MessageSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid message" }, { status: 400 });
  }

  const { channelId, content, userId } = parsed.data;

  const message = {
    id: crypto.randomUUID(),
    content,
    userId,
    createdAt: new Date().toISOString(),
  };

  // Save to database
  // await db.insert(messages).values(message);

  // Broadcast to all connected clients
  await pusher.trigger(`channel-${channelId}`, "new-message", message);

  return NextResponse.json(message, { status: 201 });
}

Listen in Components

"use client";

import { useState, useCallback } from "react";
import { usePusherChannel } from "@/hooks/use-pusher";

interface Message {
  id: string;
  content: string;
  userId: string;
  createdAt: string;
}

export function LiveChat({ channelId }: { channelId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);

  usePusherChannel(
    `channel-${channelId}`,
    "new-message",
    useCallback((data: unknown) => {
      setMessages((prev) => [...prev, data as Message]);
    }, [])
  );

  return (
    <div className="border rounded-lg">
      <div className="h-80 overflow-y-auto p-4 space-y-3">
        {messages.map((msg) => (
          <div key={msg.id} className="text-sm">
            <span className="font-medium">{msg.userId}: </span>
            {msg.content}
          </div>
        ))}
      </div>
      <ChatInput channelId={channelId} />
    </div>
  );
}

Option 2: Socket.io (Self-Hosted)

For full control, use Socket.io with a custom server.

Install

pnpm add socket.io socket.io-client

Socket.io Server

// server/socket.ts
import { createServer } from "http";
import { Server } from "socket.io";

const httpServer = createServer();
const io = new Server(httpServer, {
  cors: {
    origin: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
    methods: ["GET", "POST"],
  },
});

// Track connected users
const connectedUsers = new Map<string, { socketId: string; userId: string }>();

io.on("connection", (socket) => {
  console.log("Client connected:", socket.id);

  // User authentication
  socket.on("authenticate", (userId: string) => {
    connectedUsers.set(socket.id, { socketId: socket.id, userId });
    io.emit("users:online", Array.from(connectedUsers.values()));
  });

  // Join a room
  socket.on("room:join", (roomId: string) => {
    socket.join(roomId);
    socket.to(roomId).emit("room:user-joined", {
      socketId: socket.id,
      userId: connectedUsers.get(socket.id)?.userId,
    });
  });

  // Leave a room
  socket.on("room:leave", (roomId: string) => {
    socket.leave(roomId);
    socket.to(roomId).emit("room:user-left", {
      socketId: socket.id,
    });
  });

  // Send message to room
  socket.on(
    "message:send",
    (data: { roomId: string; content: string }) => {
      const user = connectedUsers.get(socket.id);
      if (!user) return;

      const message = {
        id: crypto.randomUUID(),
        content: data.content,
        userId: user.userId,
        createdAt: new Date().toISOString(),
      };

      io.to(data.roomId).emit("message:new", message);
    }
  );

  // Typing indicator
  socket.on("typing:start", (roomId: string) => {
    const user = connectedUsers.get(socket.id);
    if (!user) return;
    socket.to(roomId).emit("typing:user", { userId: user.userId });
  });

  socket.on("typing:stop", (roomId: string) => {
    const user = connectedUsers.get(socket.id);
    if (!user) return;
    socket.to(roomId).emit("typing:clear", { userId: user.userId });
  });

  socket.on("disconnect", () => {
    connectedUsers.delete(socket.id);
    io.emit("users:online", Array.from(connectedUsers.values()));
  });
});

httpServer.listen(3001, () => {
  console.log("Socket.io server running on port 3001");
});

Client Hook

// hooks/use-socket.ts
"use client";

import { useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";

let socket: Socket | null = null;

function getSocket() {
  if (!socket) {
    socket = io(process.env.NEXT_PUBLIC_SOCKET_URL ?? "http://localhost:3001");
  }
  return socket;
}

export function useSocket() {
  const [connected, setConnected] = useState(false);
  const socketRef = useRef(getSocket());

  useEffect(() => {
    const s = socketRef.current;

    s.on("connect", () => setConnected(true));
    s.on("disconnect", () => setConnected(false));

    return () => {
      s.off("connect");
      s.off("disconnect");
    };
  }, []);

  return { socket: socketRef.current, connected };
}

export function useSocketEvent<T>(event: string, callback: (data: T) => void) {
  const callbackRef = useRef(callback);
  callbackRef.current = callback;
  const { socket } = useSocket();

  useEffect(() => {
    function handler(data: T) {
      callbackRef.current(data);
    }

    socket.on(event, handler);
    return () => {
      socket.off(event, handler);
    };
  }, [socket, event]);
}

Typing Indicator Component

"use client";

import { useEffect, useRef, useState } from "react";
import { useSocket, useSocketEvent } from "@/hooks/use-socket";

export function TypingIndicator({ roomId }: { roomId: string }) {
  const [typingUsers, setTypingUsers] = useState<string[]>([]);
  const { socket } = useSocket();
  const timeoutsRef = useRef(new Map<string, NodeJS.Timeout>());

  useSocketEvent<{ userId: string }>("typing:user", ({ userId }) => {
    setTypingUsers((prev) => (prev.includes(userId) ? prev : [...prev, userId]));

    // Clear after 3 seconds
    const existing = timeoutsRef.current.get(userId);
    if (existing) clearTimeout(existing);

    timeoutsRef.current.set(
      userId,
      setTimeout(() => {
        setTypingUsers((prev) => prev.filter((id) => id !== userId));
        timeoutsRef.current.delete(userId);
      }, 3000)
    );
  });

  useSocketEvent<{ userId: string }>("typing:clear", ({ userId }) => {
    setTypingUsers((prev) => prev.filter((id) => id !== userId));
    const existing = timeoutsRef.current.get(userId);
    if (existing) {
      clearTimeout(existing);
      timeoutsRef.current.delete(userId);
    }
  });

  if (typingUsers.length === 0) return null;

  const text =
    typingUsers.length === 1
      ? `${typingUsers[0]} is typing...`
      : typingUsers.length === 2
        ? `${typingUsers[0]} and ${typingUsers[1]} are typing...`
        : `${typingUsers.length} people are typing...`;

  return (
    <p className="text-xs text-muted-foreground animate-pulse px-4 py-1">
      {text}
    </p>
  );
}

Which to Choose?

FeaturePusherSocket.io
Setup complexityLowMedium
Serverless compatibleYesNo (needs server)
CostPay per messageServer hosting
ScalabilityManagedDIY
Latency~50ms~10ms
Full controlLimitedFull
  • Use Pusher for serverless deployments, simple real-time features, and when you want managed infrastructure.
  • Use Socket.io when you need full control, lower latency, complex real-time logic, or want to avoid per-message costs.

Need Real-Time Features?

We build real-time applications with WebSocket, live collaboration, and instant notifications. Contact us to add real-time capabilities to your app.

WebSocketreal-timeSocket.ioPusherNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles