How to Implement WebSockets in a Next.js App with Socket.io: A Practical Guide

How to Implement WebSockets in a Next.js App with Socket.io: A Practical Guide

by | Aug 28, 2026 | Uncategorized | 0 comments

Adding real-time features like chat, notifications, or live dashboards to a Next.js application requires more than just standard API routes. If you want true bidirectional communication, you need WebSockets. In this practical guide, we walk through implementing a Next.js WebSocket setup using Socket.io, complete with working code, deployment tips, and solutions to the reconnection issues most tutorials skip.

At Box Software, we build real-time features for our clients every week, and we’ve hit every pitfall so you don’t have to.

Why WebSockets Instead of Polling or Server-Sent Events?

Before jumping into code, it’s worth understanding when WebSockets are the right choice. Here’s a quick comparison:

Method Direction Best For Overhead
Polling Client to server Occasional updates High
SSE Server to client Notifications, feeds Low
WebSockets Bidirectional Chat, games, collaboration Low
websocket code laptop

The Hosting Reality Check

Before writing a single line, know this: Vercel and other serverless platforms do not support long-lived WebSocket connections. If you plan to deploy your Next.js app with WebSockets, you’ll need one of these options:

  • A traditional Node.js host such as Railway, Render, Fly.io, or a VPS
  • A separate WebSocket server deployed independently
  • A managed real-time service like Pusher or Ably (bypasses the problem entirely)

For this tutorial, we’ll build a self-hosted solution with a custom Node.js server. This guide goes deeper on it.

Step 1: Project Setup

Create a fresh Next.js project and install Socket.io:

npx create-next-app@latest realtime-app
cd realtime-app
npm install socket.io socket.io-client
websocket code laptop

Step 2: Create a Custom Next.js Server

To attach Socket.io to Next.js, we need a custom server that shares the same HTTP instance. Create server.js at the project root:

const { createServer } = require('http');
const next = require('next');
const { Server } = require('socket.io');

const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = parseInt(process.env.PORT || '3000', 10);

const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();

app.prepare().then(() => {
  const httpServer = createServer(handler);

  const io = new Server(httpServer, {
    cors: { origin: '*' }
  });

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

    socket.on('message', (data) => {
      io.emit('message', {
        id: socket.id,
        text: data.text,
        timestamp: Date.now()
      });
    });

    socket.on('disconnect', () => {
      console.log('Client disconnected:', socket.id);
    });
  });

  httpServer.listen(port, () => {
    console.log(`Server running on http://${hostname}:${port}`);
  });
});

Update your package.json scripts:

"scripts": {
  "dev": "node server.js",
  "build": "next build",
  "start": "NODE_ENV=production node server.js"
}

Step 3: Build a Reusable Socket Hook on the Client

Rather than creating multiple connections across components, centralize the socket instance with a custom React hook. Create hooks/useSocket.js:

'use client';
import { useEffect, useRef, useState } from 'react';
import { io } from 'socket.io-client';

let socketInstance = null;

export function useSocket() {
  const [isConnected, setIsConnected] = useState(false);
  const socketRef = useRef(null);

  useEffect(() => {
    if (!socketInstance) {
      socketInstance = io({
        reconnection: true,
        reconnectionAttempts: 10,
        reconnectionDelay: 1000,
        reconnectionDelayMax: 5000,
        timeout: 20000
      });
    }

    socketRef.current = socketInstance;

    const onConnect = () => setIsConnected(true);
    const onDisconnect = () => setIsConnected(false);

    socketInstance.on('connect', onConnect);
    socketInstance.on('disconnect', onDisconnect);

    if (socketInstance.connected) setIsConnected(true);

    return () => {
      socketInstance.off('connect', onConnect);
      socketInstance.off('disconnect', onDisconnect);
    };
  }, []);

  return { socket: socketRef.current, isConnected };
}
websocket code laptop

Step 4: A Working Chat Component

Now let’s use the hook. Create app/page.js (or update your existing one):

'use client';
import { useEffect, useState } from 'react';
import { useSocket } from '../hooks/useSocket';

export default function Home() {
  const { socket, isConnected } = useSocket();
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState('');

  useEffect(() => {
    if (!socket) return;

    const handleMessage = (msg) => {
      setMessages((prev) => [...prev, msg]);
    };

    socket.on('message', handleMessage);
    return () => socket.off('message', handleMessage);
  }, [socket]);

  const sendMessage = (e) => {
    e.preventDefault();
    if (!input.trim() || !socket) return;
    socket.emit('message', { text: input });
    setInput('');
  };

  return (
    <div style={{ padding: 20 }}>
      <h1>Realtime Chat</h1>
      <p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
      <ul>
        {messages.map((m, i) => (
          <li key={i}><strong>{m.id.slice(0, 5)}:</strong> {m.text}</li>
        ))}
      </ul>
      <form onSubmit={sendMessage}>
        <input value={input} onChange={(e) => setInput(e.target.value)} />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

Run npm run dev and open two browser tabs. Messages sent from one appear instantly in the other. That’s real-time bidirectional communication in about 100 lines of code.

Common Pitfalls and How to Solve Them

1. Multiple Socket Connections

React Strict Mode double-renders components in development, and each render can create a new socket. Always use a module-level singleton (as shown above with socketInstance) or a React Context provider.

2. Reconnection After Server Restart

Socket.io handles reconnection automatically, but your application state won’t restore itself. Add a reconnect handler that refetches missed data:

socket.on('reconnect', (attempt) => {
  console.log('Reconnected after', attempt, 'attempts');
  socket.emit('sync', { lastMessageId: getLastId() });
});

3. Authentication

Never trust the socket ID alone. Pass a token during handshake:

// client
io({ auth: { token: userToken } });

// server
io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  if (!isValid(token)) return next(new Error('unauthorized'));
  socket.userId = decode(token).userId;
  next();
});

4. Scaling Beyond One Server

When you deploy multiple instances, sockets connected to server A can’t broadcast to clients on server B. Fix this with the Redis adapter:

npm install @socket.io/redis-adapter redis
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('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));

5. Memory Leaks From Missing Cleanup

Every socket.on() in a component must have a matching socket.off() in the cleanup function. Otherwise, listeners stack up on every remount.

websocket code laptop

Deployment Options in 2026

  • Railway / Render: Push your repo, set the start command to node server.js, and you’re live. Both support WebSockets natively.
  • Fly.io: Great for global edge deployment, with full WebSocket support and generous free tier.
  • Self-hosted VPS (Hetzner, DigitalOcean): Maximum control, lowest cost at scale. Pair with a reverse proxy like Caddy or Nginx configured for WebSocket upgrades.
  • Hybrid Vercel setup: Deploy the Next.js frontend on Vercel and the Socket.io server separately. Point the client to the WebSocket server via NEXT_PUBLIC_WS_URL.

Alternative: The next-ws Package

If you want to avoid a custom server entirely, the next-ws package adds WebSocket route handlers directly to the App Router. It’s simpler but less flexible than Socket.io, lacks built-in reconnection, rooms, and broadcasting. For anything beyond a proof of concept, we still recommend the Socket.io approach shown here.

FAQ

Can I use WebSockets with Next.js on Vercel?

Not directly. Vercel’s serverless functions have short execution limits and don’t support persistent connections. You need a separate long-running server for the WebSocket layer, or a managed service like Pusher or Ably.

Do I need Socket.io or can I use the native WebSocket API?

Native WebSockets work fine for simple cases. Socket.io adds automatic reconnection, fallback transports, rooms, namespaces, and acknowledgements. For production apps, the extra features usually justify the small overhead.

Does this work with the App Router?

Yes. The custom server approach shown here is compatible with both the App Router and the Pages Router. The client hook works inside any client component.

How many concurrent WebSocket connections can one server handle?

A single Node.js instance can comfortably handle 10,000 to 50,000 connections depending on message frequency and payload size. Beyond that, scale horizontally with the Redis adapter.

What about TypeScript?

Everything shown works with TypeScript. Install @types/node and type your events with Socket.io’s generics: Server<ClientToServerEvents, ServerToClientEvents>.

Need Help Building Real-Time Features?

Adding WebSockets to production apps is straightforward until you hit scaling, authentication, and reconnection edge cases. If your team needs a hand shipping real-time functionality in Next.js, get in touch with Box Software. We build these systems every day.