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 |

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

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 };
}

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.

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.
