DEV Community

Atlas Whoff
Atlas Whoff

Posted on

WebSockets in Next.js: Socket.io, Real-Time Presence, and Scaling with Redis Adapter

WebSockets add real-time capabilities to your app: live collaboration, notifications, presence indicators, and streaming data. Here's how to implement them correctly in a Next.js production environment.

The Architecture Problem

Next.js serverless functions are stateless — they can't maintain a persistent WebSocket connection. You need a dedicated WebSocket server running alongside your Next.js app.

Options:

  • Socket.io server: Standalone Node.js process
  • Ably / Pusher: Managed WebSocket service
  • Partykit: Built for real-time Next.js apps
  • Supabase Realtime: If you're already on Supabase

For self-hosted control: Socket.io. For zero-ops: Ably.

Socket.io Server Setup

// server/socket.ts
import { Server } from 'socket.io'
import { createServer } from 'http'
import { verify } from 'jsonwebtoken'

const httpServer = createServer()
const io = new Server(httpServer, {
  cors: {
    origin: process.env.NEXT_PUBLIC_APP_URL,
    credentials: true,
  },
})

// Auth middleware
io.use((socket, next) => {
  const token = socket.handshake.auth.token
  try {
    const payload = verify(token, process.env.NEXTAUTH_SECRET!) as { userId: string }
    socket.data.userId = payload.userId
    next()
  } catch {
    next(new Error('Authentication failed'))
  }
})

io.on('connection', (socket) => {
  const { userId } = socket.data

  // Join user's personal room
  socket.join(`user:${userId}`)

  socket.on('join:project', (projectId: string) => {
    socket.join(`project:${projectId}`)
    socket.to(`project:${projectId}`).emit('user:joined', { userId })
  })

  socket.on('cursor:move', ({ projectId, x, y }) => {
    socket.to(`project:${projectId}`).emit('cursor:update', { userId, x, y })
  })

  socket.on('disconnect', () => {
    io.emit('user:left', { userId })
  })
})

httpServer.listen(3001)
Enter fullscreen mode Exit fullscreen mode

Client Connection

// lib/socket.ts
import { io, Socket } from 'socket.io-client'
import { getSession } from 'next-auth/react'

let socket: Socket | null = null

export async function getSocket(): Promise<Socket> {
  if (socket?.connected) return socket

  const session = await getSession()
  if (!session) throw new Error('Not authenticated')

  socket = io(process.env.NEXT_PUBLIC_SOCKET_URL!, {
    auth: { token: session.accessToken },
    reconnection: true,
    reconnectionDelay: 1000,
    reconnectionAttempts: 5,
  })

  return new Promise((resolve, reject) => {
    socket!.on('connect', () => resolve(socket!))
    socket!.on('connect_error', reject)
  })
}
Enter fullscreen mode Exit fullscreen mode

React Hook

function useProjectPresence(projectId: string) {
  const [onlineUsers, setOnlineUsers] = useState<string[]>([])

  useEffect(() => {
    let sock: Socket

    getSocket().then(s => {
      sock = s
      sock.emit('join:project', projectId)

      sock.on('user:joined', ({ userId }) => {
        setOnlineUsers(prev => [...new Set([...prev, userId])])
      })

      sock.on('user:left', ({ userId }) => {
        setOnlineUsers(prev => prev.filter(id => id !== userId))
      })
    })

    return () => {
      sock?.emit('leave:project', projectId)
      sock?.off('user:joined')
      sock?.off('user:left')
    }
  }, [projectId])

  return onlineUsers
}
Enter fullscreen mode Exit fullscreen mode

Pushing Notifications from Server

// From your Next.js API routes -- push to specific users
async function notifyUser(userId: string, event: string, data: unknown) {
  await fetch(`${process.env.SOCKET_SERVER_URL}/emit`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.SOCKET_SERVER_SECRET}`,
    },
    body: JSON.stringify({ room: `user:${userId}`, event, data }),
  })
}

// Example: notify user when their export is ready
await notifyUser(userId, 'export:ready', { downloadUrl })
Enter fullscreen mode Exit fullscreen mode

Scaling WebSockets

Multiple Socket.io instances need a shared adapter so events cross server boundaries:

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))
Enter fullscreen mode Exit fullscreen mode

The AI SaaS Starter at whoffagents.com includes Socket.io setup with auth middleware, presence hooks, and Redis adapter configuration. $99 one-time.

Top comments (0)