Skip to content

Sockets in Next.js

Some activations requires multiple browsers to be synced, the following is a step by step on how to get your sockets as a separate Express app working

INFO

The following pattern ensures there is only one connection per browser tab, and that the connection is reused across components.

Install some dependencies

bash
npm i express socket.io socket.io-client
bash
npm i --save-dev @types/express

You may want to load some env vars in your app, so you can use the dotenv package to load them from a .env file.

bash
npm i dotenv

Setting up the server

Create a new file under ./src/server/index.ts and paste the following code. This file will be used to create a socket server that listens for incoming connections and emits events to connected clients.

typescript
import * as dotenv from "dotenv";
import * as express from "express";
import { createServer } from "node:http";
import { Server, Socket } from "socket.io";
import { SOCKET_EVENTS } from "../components/SocketConnect/socketConst";
dotenv.config();

const app = express();
const server = createServer(app);
const io = new Server(server, {
  cors: {
    origin: "*",
  },
});
const port = 3010;

app.get("/", (req, res) => {
  res.send("Hello, world!");
});

const attachEvent = (
  event: (typeof SOCKET_EVENTS)[keyof typeof SOCKET_EVENTS],
  socket: Socket
) => {
  socket.on(event, ({ room, data }: { room: string; data: any }) => {
    console.log("Socket Event:", event, room, data);
    socket.to(room).emit(event, data);
  });
};

io.on("connection", (socket) => {
  console.log("a user connected");
  socket.on("disconnect", () => {
    console.log("user disconnected");
  });

  socket.on(SOCKET_EVENTS.JOIN_ROOM, (room) => {
    console.log("Joinig room:", room);
    socket.join(room);
  });
  socket.on(SOCKET_EVENTS.LEAVE_ROOM, (room) => {
    console.log("Leaving room:", room);
    socket.leave(room);
  });
  attachEvent(SOCKET_EVENTS.REFRESH, socket);
  // your other custom events
});

server.listen(port, () => {
  console.log(`Server is running on port ${port}`);
});

Lets add some quick scripts to the package json

json
  "server:build": "tsc ./src/server/index.ts --outDir ./.server",
  "server:start": "npm run server:build && node ./.server/server/index.js"

Create a ./tsconfig.server.json file to compile the new server and add the following content

json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "outDir": "dist",
    "target": "es2017",
    "isolatedModules": false,
    "noEmit": false
  },
  "include": ["src/server/**/*.ts"]
}

Add the server files to the ignore list of the main ts config file tsconfig.json so it looks as follows

json
{
  "exclude": ["node_modules", "src/server"]
}

Add the pm2 app to the config

json
module.exports = {
  apps: [
    // other apps
    {
      name: "node-helper-server",
      script: "./.server/server/index.js",
      log_date_format: "DD-MM HH:mm:ss.SSS",
    },
  ],
};

Add the built folder to the .bitignore

bash
# node server
.server

Next.js / React integration

You can structure your folders as you prefer. Below is an example setup for socket-related components and hooks. If possible use the plop generator to create the files.

src
├── components
│   └── SocketConnect
│       ├── socket.ts
│       ├── socketConst.ts
│       └── useSocket.ts
  1. socket.ts Socket Initialization: Create a file at ./src/components/SocketConnect/socket.ts

TODO this is probably better as a factory function.

typescript
"use client";
import { io } from "socket.io-client";
export const socket = process.env.NEXT_PUBLIC_SOCKET_URL
  ? io(process.env.NEXT_PUBLIC_SOCKET_URL, {
      reconnection: true,
      reconnectionDelay: 500,
    })
  : null;
  1. socketConst.ts Socket Events: Create a file at ./src/components/SocketConnect/socketConst.ts:
typescript
export const SOCKET_EVENTS = {
  JOIN_ROOM: "JOIN_ROOM",
  LEAVE_ROOM: "LEAVE_ROOM",
  REFRESH: "REFRESH",
  NAVIGATE_TO: "NAVIGATE_TO",
  // add your custom events here
} as const;
  1. SocketConnect.ts Socket Context and Hooks: Create a file at ./src/components/SocketConnect/SocketConnect.ts:
tsx
"use client";
import {
  ReactNode,
  createContext,
  useContext,
  useEffect,
  useState,
} from "react";
import { Socket } from "socket.io-client";
import { socket as newSocket } from "./socket";
import { SOCKET_EVENTS } from "./socketConst";

// Create a context with a default value of null for the socket
const SocketContext = createContext<Socket | null>(null);

export const useSocket = () => useContext(SocketContext);

/**
 * A custom hook that allows components to listen for socket events and execute a callback function when the event occurs.
 * @param eventName - The name of the socket event to listen for.
 * @param callback - The function to be executed when the socket event occurs. It takes one argument, `arg1`, which represents the data received from the event.
 */
export function useSocketOn<Arg1>(
  eventName: keyof typeof SOCKET_EVENTS,
  callback: (arg1: Arg1) => void
) {
  const socket = useSocket();

  useEffect(() => {
    if (socket) {
      socket.on(eventName, callback);
    }

    return () => {
      if (socket) {
        socket.off(eventName, callback);
      }
    };
  }, [socket, eventName, callback]);
}

export const useSocketEmit = () => {
  const socket = useSocket();
  const cb = (eventName: keyof typeof SOCKET_EVENTS, data: any) => {
    ``;
    if (socket) {
      socket.emit(eventName, data);
    }
  };
  return cb;
};

export const SocketProvider = ({
  children,
  room,
}: {
  children: ReactNode;
  room: string;
}) => {
  // const { room } = useParams();
  const [connected, setConnected] = useState(false);
  const [socket] = useState<Socket | null>(newSocket);

  useEffect(() => {
    const onConnect = () => {
      if (room) {
        newSocket?.emit(SOCKET_EVENTS.JOIN_ROOM, room);
      } else {
        console.warn("No room provided. Cannot join room.");
      }
      setConnected(true);
    };
    const onDisconnect = () => {
      console.log("disconnecting socket");
    };

    if (newSocket?.connected) {
      onConnect();
    }

    newSocket?.on("connect", onConnect);
    newSocket?.on("disconnect", onDisconnect);

    if (!connected) {
      newSocket?.connect();
    }

    return () => {
      newSocket?.off("connect", onConnect);
      newSocket?.off("disconnect", onDisconnect);
    };
  }, [connected, room]);

  return (
    <SocketContext.Provider value={socket}>{children}</SocketContext.Provider>
  );
};

Implementing the SocketProvider

Wrap your template.tsx with the SocketProvider component

tsx
import SocketProvider from "@/components/atoms/SocketConnect";

export default function Template({ children }: { children: React.ReactNode }) {
  return (
    <SocketProvider room={"some-room"}>
      <main>{children}</main>
    </SocketProvider>
  );
}

And now from within your components, you can use the useSocketOn and useSocketEmit hooks to listen for and emit socket events.

tsx
import {
  useSocketOn,
  useSocketEmit,
} from "@/components/SocketConnect/SocketConnect";
import { SOCKET_EVENTS } from "@/lib/const";

export default function MyComponent() {
  const emit = useSocketEmit();
  const onEvent = (data: any) => {
    console.log("Received data: ", data);
  };

  useSocketOn(SOCKET_EVENTS.MY_EVENT, onEvent);

  return (
    <button onClick={() => emit(SOCKET_EVENTS.MY_EVENT, { data: "Hello" })}>
      Emit Event
    </button>
  );
}