This walks through a complete live-cursors stage in a Next.js App Router project: one shared socket, a hook over the built-in cursor events, and a component that renders everyone's pointer. It's the same approach as the Live Cursors demo, split into the files a real app would have.
npm install socket.io-clientPoint the app at your server. Use the address from the Overview tab in Slash Socket — localhost while you're developing, your LAN IP or forwarded domain for other devices.
1NEXT_PUBLIC_SLASH_SOCKET_URL=http://localhost:3001Socket.IO only runs in the browser, so the connection lives in a client-only module. Keeping a single instance means every component shares one connection instead of opening a new one per render.
1234567891011import { io, type Socket } from "socket.io-client"
let socket: Socket | null = null
/** One shared connection per browser tab, opened on first use. */export function getSocket(): Socket { socket ??= io(process.env.NEXT_PUBLIC_SLASH_SOCKET_URL!, { transports: ["websocket"], }) return socket}The hook reports your pointer with cursor_move and turns cursor_update broadcasts into a list of cursors. Positions are sent in pixels together with the stage size (vw, vh), so each client can convert them to fractions and draw them in the same relative spot on a stage of any size. Sending x: -1 is the convention for "my cursor left the stage".
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970"use client"
import { useCallback, useEffect, useState } from "react"import { getSocket } from "@/lib/socket"
export interface Cursor { id: string color: string username: string | null x: number // 0–1 across the stage y: number // 0–1 down the stage}
interface CursorUpdate { id: string color: string username: string | null x: number y: number vw: number vh: number}
export function useLiveCursors(username: string) { const [cursors, setCursors] = useState<Record<string, Cursor>>({})
useEffect(() => { const socket = getSocket()
const onConnect = () => socket.emit("set_username", { username })
const onCursor = ({ id, color, username, x, y, vw, vh }: CursorUpdate) => setCursors((prev) => { const next = { ...prev } if (x < 0) delete next[id] // they left the stage else next[id] = { id, color, username, x: x / vw, y: y / vh } return next })
// Drop cursors of anyone who disconnected. const onUsers = (users: { id: string }[]) => { const ids = new Set(users.map((u) => u.id)) setCursors((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))) ) }
if (socket.connected) onConnect() socket.on("connect", onConnect) socket.on("cursor_update", onCursor) socket.on("users_update", onUsers)
return () => { socket.off("connect", onConnect) socket.off("cursor_update", onCursor) socket.off("users_update", onUsers) } }, [username])
const move = useCallback((x: number, y: number, vw: number, vh: number) => { // volatile: drop the move if the socket is busy instead of queueing it getSocket().volatile.emit("cursor_move", { x, y, vw, vh }) }, [])
const leave = useCallback(() => { getSocket().emit("cursor_move", { x: -1, y: -1, vw: 1, vh: 1 }) }, [])
return { cursors: Object.values(cursors), move, leave }}The component is the stage: it measures pointer positions relative to itself and draws the other cursors with percentage offsets. The server never echoes your own cursor_move back to you, so only other people's pointers show up. Don't put a CSS transition on the cursor position — it only adds delay. If you have many people on one stage, write positions straight to each cursor's DOM node instead of React state, the way the Live Cursors demo does, so the page doesn't re-render on every move.
12345678910111213141516171819202122232425262728293031323334353637383940414243"use client"
import { useRef } from "react"import { useLiveCursors } from "@/hooks/use-live-cursors"
export function LiveCursors({ username }: { username: string }) { const stageRef = useRef<HTMLDivElement>(null) const { cursors, move, leave } = useLiveCursors(username)
function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) { const rect = stageRef.current!.getBoundingClientRect() move(event.clientX - rect.left, event.clientY - rect.top, rect.width, rect.height) }
return ( <div ref={stageRef} onPointerMove={handlePointerMove} onPointerLeave={leave} style={{ position: "relative", height: 420, overflow: "hidden" }} > {cursors.map((cursor) => ( <div key={cursor.id} style={{ position: "absolute", left: `${cursor.x * 100}%`, top: `${cursor.y * 100}%`, color: cursor.color, pointerEvents: "none", }} > <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <path d="M4 2.5 19.5 12 12 13.5 8.5 20.5Z" /> </svg> <span style={{ background: cursor.color, color: "#000", padding: "2px 8px", borderRadius: 6, fontSize: 12 }}> {cursor.username ?? "Guest"} </span> </div> ))} </div> )}12345import { LiveCursors } from "@/components/live-cursors"
export default function Page() { return <LiveCursors username="Ada" />}- Start the server in Slash Socket.
- Run your app with next dev and open it in two browser windows.
- Move the pointer in one window — it appears in the other, and both show up in the Clients tab with their live positions.
Want to see it against the slash.tools demo? Open slash.tools/socket?socket=http://localhost:3001 next to your app while the server is running, and cursors from both pages share the same stage. Browsers may ask for permission before a public page can reach localhost.
Slash Socket serves plain HTTP. A page loaded over HTTPS can reach http://localhost, but not a plain-HTTP server anywhere else, because browsers block mixed content. To use it from a deployed HTTPS site, put the server behind something that terminates TLS (a reverse proxy or a tunnel) and point NEXT_PUBLIC_SLASH_SOCKET_URL at that https:// address.