50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 20, 2026·8 min read

Next.js and WebSockets: Real-Time Features Without Losing Your Mind

Next.js is server-first, but your users want live updates. Here's how WebSockets actually work in App Router — and when to use something else.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Next.js and WebSockets: Real-Time Features Without Losing Your Mind

Next.js is a fantastic framework until you need real-time features. Then you hit a wall. The App Router is built around request-response cycles, server components render once, and suddenly you're asking: where does the WebSocket even live? We spent a weekend figuring this out for a dashboard feature, and the answer is less obvious than it should be.

The short version: Next.js doesn't have native WebSocket support in the same way Express does. But you can absolutely build real-time features — you just need to understand where the boundaries are and pick the right tool for each job. Let's go through it properly.

Why Next.js Makes WebSockets Awkward

The App Router runs on an edge-friendly, serverless-compatible model. Route Handlers (those `route.ts` files) handle HTTP requests — they receive a request, return a response, and that's it. There's no persistent connection. No long-lived process holding state. When you're on Vercel, your functions spin up per request and die. That's great for scalability and terrible for WebSockets.

WebSockets need a persistent server. They need something that's running continuously, listening for connections, and maintaining state about who's connected. That's the fundamental tension. You're trying to bolt a stateful, long-lived protocol onto a stateless, serverless architecture.

This isn't a criticism of Next.js — it's just a different mental model. Once you accept that real-time needs a different layer, everything clicks.

Option 1: A Separate WebSocket Server

The cleanest architecture for serious real-time features is running a separate WebSocket server alongside your Next.js app. This can be an Express server with `ws`, a Fastify instance, or even a tiny Node.js server. Your Next.js frontend connects to it directly. Your Next.js API routes can talk to it over HTTP when they need to broadcast messages.

// ws-server.ts — runs as a separate process
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';

const server = createServer();
const wss = new WebSocketServer({ server });

// Track connected clients by room
const rooms = new Map<string, Set<WebSocket>>();

wss.on('connection', (ws, req) => {
  const url = new URL(req.url!, `http://localhost`);
  const roomId = url.searchParams.get('room');

  if (!roomId) {
    ws.close(1008, 'Missing room ID');
    return;
  }

  // Join room
  if (!rooms.has(roomId)) rooms.set(roomId, new Set());
  rooms.get(roomId)!.add(ws);

  ws.on('message', (data) => {
    // Broadcast to everyone in the room
    const message = data.toString();
    rooms.get(roomId)?.forEach((client) => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(message);
      }
    });
  });

  ws.on('close', () => {
    rooms.get(roomId)?.delete(ws);
    if (rooms.get(roomId)?.size === 0) rooms.delete(roomId);
  });
});

server.listen(3001, () => {
  console.log('WebSocket server running on port 3001');
});

On the client side, connecting to this from a Next.js component is straightforward. You create the WebSocket connection in a `useEffect`, clean it up when the component unmounts, and you're done. The tricky part is authentication — you need to make sure random people can't connect to rooms they don't belong to. Pass a short-lived token in the query string, validate it on connection.

// hooks/useRoomWebSocket.ts
import { useEffect, useRef, useState } from 'react';

type Message = {
  userId: string;
  content: string;
  timestamp: number;
};

export function useRoomWebSocket(roomId: string, token: string) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [connected, setConnected] = useState(false);
  const wsRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    const wsUrl = `${process.env.NEXT_PUBLIC_WS_URL}?room=${roomId}&token=${token}`;
    const ws = new WebSocket(wsUrl);
    wsRef.current = ws;

    ws.onopen = () => setConnected(true);
    ws.onclose = () => setConnected(false);

    ws.onmessage = (event) => {
      try {
        const message = JSON.parse(event.data) as Message;
        setMessages((prev) => [...prev, message]);
      } catch {
        console.error('Failed to parse message', event.data);
      }
    };

    return () => {
      ws.close();
    };
  }, [roomId, token]);

  const sendMessage = (content: string) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify({ content }));
    }
  };

  return { messages, connected, sendMessage };
}

This architecture scales well and keeps concerns separate. Your Next.js app handles auth, routing, and rendering. Your WebSocket server handles real-time communication. The downside: now you're deploying and maintaining two services. For a weekend project, that's annoying. For production SaaS, it's completely normal.

Option 2: Next.js Custom Server (The Escape Hatch)

If you really want WebSockets inside your Next.js app — same process, same port — you can use a custom server. This is Next.js's official escape hatch for when the framework's defaults don't cut it. You replace the default Next.js server with your own Node.js server that also handles WebSocket upgrades.

// server.ts — replaces next start
import { createServer } from 'http';
import { parse } from 'url';
import next from 'next';
import { WebSocketServer } from 'ws';

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  const server = createServer((req, res) => {
    const parsedUrl = parse(req.url!, true);
    handle(req, res, parsedUrl);
  });

  // Attach WebSocket server to the same HTTP server
  const wss = new WebSocketServer({ noServer: true });

  server.on('upgrade', (req, socket, head) => {
    const { pathname } = parse(req.url!);

    // Only handle upgrades to /ws path
    if (pathname === '/ws') {
      wss.handleUpgrade(req, socket, head, (ws) => {
        wss.emit('connection', ws, req);
      });
    } else {
      socket.destroy();
    }
  });

  wss.on('connection', (ws) => {
    ws.send(JSON.stringify({ type: 'connected' }));

    ws.on('message', (data) => {
      // Handle messages
      console.log('Received:', data.toString());
    });
  });

  server.listen(3000, () => {
    console.log('> Ready on http://localhost:3000');
  });
});

Then update your `package.json` to run this instead of `next start`. The big caveat: custom servers are explicitly not supported on Vercel. You'll need to deploy to a VPS, Railway, Render, or anywhere that runs a persistent Node.js process. We use Railway for exactly this kind of thing — cheap, fast deploys, no nonsense.

Option 3: Managed Real-Time Services (The Sane Default)

Honestly? For most use cases, you should just use a managed service. Pusher, Ably, PartyKit, Soketi (self-hosted Pusher-compatible) — these exist so you don't have to think about WebSocket infrastructure. Your Next.js Route Handlers trigger events via HTTP, and the managed service handles delivery to connected clients.

The pattern is dead simple. A user does something → your Route Handler fires → you call `pusher.trigger('channel', 'event', data)` → connected clients receive the message. No persistent server, works on Vercel, scales automatically. You're paying for convenience, but for 90% of apps, it's worth it.

// app/api/messages/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Pusher from 'pusher';
import { auth } from '@/lib/auth';

const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.PUSHER_CLUSTER!,
  useTLS: true,
});

export async function POST(req: NextRequest) {
  const session = await auth();
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const { roomId, content } = await req.json();

  // Save to database
  // await db.insert(messages).values({ roomId, content, userId: session.user.id });

  // Trigger real-time event
  await pusher.trigger(`room-${roomId}`, 'new-message', {
    userId: session.user.id,
    content,
    timestamp: Date.now(),
  });

  return NextResponse.json({ ok: true });
}

On the client, you use pusher-js to subscribe. The DX is genuinely good, the free tier covers most hobby projects, and you can switch providers later if pricing becomes painful. We're not affiliated with any of these — we just use what works.

Server-Sent Events: The Underrated Option

Before you reach for WebSockets, ask: do you actually need bidirectional communication? If you're showing live notifications, activity feeds, progress bars, or streaming AI responses — all one-way, server-to-client — Server-Sent Events (SSE) are simpler and actually work in Next.js Route Handlers without any extra infrastructure.

// app/api/events/route.ts
export async function GET(req: Request) {
  const encoder = new TextEncoder();
  
  const stream = new ReadableStream({
    start(controller) {
      const send = (data: object) => {
        const encoded = encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
        controller.enqueue(encoded);
      };

      // Send initial connection confirmation
      send({ type: 'connected', timestamp: Date.now() });

      // Example: stream updates every 5 seconds
      const interval = setInterval(() => {
        send({ type: 'ping', timestamp: Date.now() });
      }, 5000);

      // Clean up on disconnect
      req.signal.addEventListener('abort', () => {
        clearInterval(interval);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

// Client usage:
// const evtSource = new EventSource('/api/events');
// evtSource.onmessage = (e) => console.log(JSON.parse(e.data));

SSE works in Route Handlers on Vercel (up to the function timeout limit), it reconnects automatically, it's HTTP so no firewall issues, and the browser API is simple. The limitation: server-to-client only, and long-running connections on serverless will hit timeout limits. For AI streaming responses though, this is exactly the right tool — and it's how most streaming chat UIs work.

Which One Should You Actually Use?

Here's our honest decision tree after building several apps with real-time requirements:

  • Streaming AI responses, progress bars, notifications (one-way): Use Server-Sent Events. Done.
  • Collaborative features, chat, live cursors on Vercel: Use a managed service (Pusher, Ably, PartyKit). Pay the money.
  • Collaborative features on your own infrastructure: Custom Next.js server or separate WebSocket service.
  • You need sub-100ms latency and WebRTC: You're not reading the right blog post — but start with a separate signaling server.
  • You want to avoid any extra services for a simple use case: Custom server on Railway, $5/month, move on with your life.

The mistake we made early on was trying to hack WebSocket support into Vercel deployments. We burned an entire afternoon trying to make long-polling work convincingly before accepting that the architecture just doesn't fit. Managed services exist for a reason. Now we reach for them first and only go custom when there's a compelling reason — usually cost at scale or very specific latency requirements.

Real-time is an infrastructure problem before it's a code problem. Pick your architecture first, then write the code.

Practical Notes for Production

A few things we've learned that don't fit neatly into examples:

  • Always handle reconnection on the client. Networks drop, servers restart. Exponential backoff with jitter is the pattern — don't just retry immediately on every close event.
  • Authenticate before accepting a WebSocket connection, not after. Checking credentials in the first message is too late — you've already accepted the connection.
  • For SSE on Vercel, be aware of the 60-second function timeout on the hobby plan (300s on Pro). Design your reconnection logic accordingly.
  • If you're using Pusher or similar, implement client-side presence channels carefully — they can get expensive at scale if you're not grouping users properly.
  • Test WebSocket behavior under poor network conditions. Chrome DevTools lets you throttle — do it before your users do it for you.

One thing that catches people out: WebSocket connections don't automatically authenticate with your session cookies in the same way HTTP requests do. If you're using NextAuth or a similar session-based auth system, you'll need to explicitly pass and validate an auth token when establishing the WebSocket connection. Don't skip this step.

If you're building on top of a Next.js starter and want auth, payments, and a database already wired up before you add real-time features, the templates at peal.dev give you that foundation — so you're not yak-shaving session handling when you're trying to build a live collaboration feature.

Real-time in Next.js is solvable. It just requires accepting that the framework doesn't hand it to you out of the box, picking the right layer for your deployment target, and not spending three hours trying to make persistent connections work in a serverless function. We've made that mistake so you don't have to.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders