Next.js WebSockets in App Router: Socket.IO, Custom Servers, and Deployment (2026)

How to add WebSockets to a Next.js App Router app in 2026: Socket.IO with a custom server, Vercel's native beta, managed services like Ably and Pusher, plus the client-side gotchas around 'use client', useEffect, hydration, and route-change persistence.

Next.js WebSockets Guide (2026)

Updated: July 15, 2026

Next.js WebSockets in the App Router require either a custom Node.js server, Vercel's native WebSocket beta (public since June 22, 2026), or a managed service like Ably, Pusher, or Partykit, because Next.js does not ship a WebSocket server of its own. On the client side, all WebSocket code has to live inside a client component and be instantiated in useEffect; otherwise SSR crashes with a ReferenceError, because the browser's WebSocket global doesn't exist during server render. I've shipped this pattern on three production apps now, and each one hit a slightly different wall, so this guide walks through every option end to end, with runnable code, so you can pick the right one for your deploy target and skip the forum-post archaeology.

  • WebSocket client code must be in a component with "use client" and instantiated inside useEffect. If it runs at module scope, SSR fails because WebSocket is undefined on the server.
  • Standard Vercel serverless functions still cannot host persistent WebSocket connections. The June 2026 native beta supports a single pinned connection per function with a 5-minute default cap (30 minutes on Pro/Enterprise).
  • Socket.IO with a custom server.js gives you rooms, namespaces, and reconnection, but forces you off Vercel and onto Fly.io, Railway, or a self-hosted container.
  • To keep a connection alive across route changes, put the WebSocket in a React Context provider mounted in the root layout. Never in a page component that unmounts on navigation.
  • Managed services (Ably, Pusher, Partykit, Convex, Liveblocks) handle infrastructure, presence, and fan-out. Use them when you need multiple subscribers or cross-instance broadcasting.

Why WebSockets are hard in the Next.js App Router

In the pages router days, you'd bind a Socket.IO server to res.socket.server.io from an API route the first time a client hit /api/socket, and Next.js would keep that server alive for the process lifetime. It was ugly, and it depended on internal Node.js server internals, but it worked. In the App Router, there's no res object on a route handler. You return a Response, the runtime terminates, and there's nowhere obvious to hang a long-lived listener.

That change isn't just cosmetic. The App Router assumes a request-response model: browser asks for a page, the server renders it, sends HTML back, then the function may spin down. WebSockets need the opposite: a long-lived process that holds the socket open indefinitely. If you're deploying to Vercel, Netlify, or any serverless target, that persistent process doesn't exist. Even on a Node.js host, you have to decide whether the WebSocket server shares the Next.js HTTP server (custom server, no Vercel) or runs in its own process (extra infra, but Vercel-deployable).

The client-side story is separately painful. Server components can't touch the browser WebSocket global. Client components can, but Next.js still pre-renders them on the server for the initial HTML, so you can't just call new WebSocket(...) at the top of the file (that'll crash the build). Every real-time feature ends up being a decision matrix: where does the server live, where does the client live, and how do you keep the socket open when the user navigates?

Does Vercel support WebSockets in 2026?

Yes, in beta, with real limits. Vercel's native WebSocket support went public beta on June 22, 2026. It runs on Fluid Compute and works via the standard WebSocket upgrade: a client connects to a route handler, and the function's execution is pinned to that specific request for the duration of the socket.

That pinning is the important footnote. Once the socket is upgraded, the function instance holds the connection for its remaining budget: 5 minutes on Hobby by default, 30 minutes on Pro/Enterprise, and only on specific Node.js/Python runtime versions. Any future connection isn't guaranteed to land on the same instance, and Vercel gives you no built-in way to broadcast a message from instance A to a socket held by instance B. There's no presence tracking, no delivery guarantees beyond the pinned connection, and no automatic reconnection after the duration cap trips.

In practice, that's fine for a single-user long-running conversation (a chat with an AI backend, a progress feed for one client, an ephemeral notification stream). It's not enough for a group chat, a collaborative editor, or anything with multiple subscribers on the same channel. For those, you either run your own WebSocket server or reach for a managed service. If you only need server-to-client one-way updates and can live inside a single request, Server-Sent Events on Fluid Compute are simpler and don't need beta features. I cover the tradeoff in the Next.js Server-Sent Events guide.

The custom-server approach with Socket.IO

If you need rooms, namespaces, HTTP long-polling fallback, or reconnection out of the box, Socket.IO is still the pragmatic choice, and the price is a custom Node.js server. This is what shipping a group chat on Fly.io or a Railway container looks like end to end.

Create a server.js at the project root that boots Next and mounts Socket.IO on the same HTTP server:

// server.js
import { createServer } from "node:http";
import next from "next";
import { Server as IOServer } from "socket.io";

const dev = process.env.NODE_ENV !== "production";
const hostname = process.env.HOSTNAME ?? "0.0.0.0";
const port = Number(process.env.PORT ?? 3000);

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

await app.prepare();

const httpServer = createServer((req, res) => handle(req, res));

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

io.on("connection", (socket) => {
  socket.on("chat:join", (room) => socket.join(room));
  socket.on("chat:message", ({ room, text }) => {
    io.to(room).emit("chat:message", {
      text,
      at: Date.now(),
      from: socket.id,
    });
  });
});

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

Then in package.json, switch next start for the custom entrypoint. Heads up: Turbopack's dev server isn't compatible with a custom Node HTTP server at the moment, so dev mode uses the classic runtime here.

{
  "scripts": {
    "dev": "NODE_ENV=development node --experimental-specifier-resolution=node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js"
  }
}

On the client, install socket.io-client and open the connection from a component that mounts once. Because the server and client share the same origin, you don't pass a URL; Socket.IO negotiates the transport itself.

"use client";
import { useEffect, useState } from "react";
import { io, type Socket } from "socket.io-client";

let socket: Socket | undefined;

export function ChatRoom({ room }: { room: string }) {
  const [messages, setMessages] = useState<{ text: string; from: string }[]>([]);

  useEffect(() => {
    socket ??= io({ path: "/socket.io", autoConnect: true });
    socket.emit("chat:join", room);
    socket.on("chat:message", (m) => setMessages((prev) => [...prev, m]));
    return () => {
      socket?.off("chat:message");
    };
  }, [room]);

  return <ul>{messages.map((m, i) => <li key={i}>{m.text}</li>)}</ul>;
}

You lose two things by taking this path. First, Vercel is off the table; you're on a long-lived container, which means process supervision, zero-downtime deploys, and a plan for what happens to open sockets during a rolling restart. Second, custom servers disable some of Next.js's built-in optimizations like Automatic Static Optimization edge behavior. If you're already self-hosting anyway, that trade is fine. See the Docker self-hosting guide for a production Dockerfile that works with this server.js.

Client component setup: "use client", useEffect, and hydration

The number-one WebSocket bug in the App Router is instantiating the connection at module scope. It looks like this:

"use client";
// BROKEN: crashes SSR pre-render
const socket = new WebSocket("wss://example.com/chat");

export function LiveFeed() {
  // ...
}

Even with "use client", Next.js still runs this file on the server to generate the initial HTML for the streaming render. WebSocket is a browser global. Node.js older than v22 doesn't have it, and even where it exists, you don't want to open real sockets from the server render. The fix is boring and universal: put the constructor inside useEffect, which only ever runs in the browser after hydration.

"use client";
import { useEffect, useRef, useState } from "react";

export function LiveFeed() {
  const wsRef = useRef<WebSocket | null>(null);
  const [events, setEvents] = useState<string[]>([]);

  useEffect(() => {
    const ws = new WebSocket("wss://example.com/chat");
    wsRef.current = ws;

    ws.addEventListener("message", (e) => {
      setEvents((prev) => [...prev, e.data]);
    });

    return () => {
      ws.close(1000, "unmount");
      wsRef.current = null;
    };
  }, []);

  return <ul>{events.map((e, i) => <li key={i}>{e}</li>)}</ul>;
}

A related trap: don't render the connection status in a way that differs between server and client on the first paint. If your server-rendered HTML says "Disconnected" and your client immediately swaps in "Connecting…" before hydration completes, React flags a mismatch. Use useState(false) for a hasMounted flag, or leave the status area empty in the initial render. Never rely on typeof window !== "undefined" to gate JSX inside a render function, because that value is different between the two renders.

Persist a WebSocket connection across route changes

So, here's the fun one. The App Router does soft client-side navigation. If your LiveFeed lives in app/dashboard/page.tsx and the user clicks a link to /settings, the component unmounts, your useEffect cleanup fires, the socket closes, and when they come back the socket reopens. On a chat app or a live-editing surface, that's a visible bug: messages disappear, presence flickers, and you burn a reconnect handshake on every route change. (I hit this exact bug shipping a customer-support chat and blamed the load balancer for two hours before I realized it was route changes.)

The fix is to move the WebSocket out of any page or nested layout and into a Context provider mounted in the root layout (app/layout.tsx). The root layout never unmounts during in-app navigation, so the connection survives.

"use client";
import { createContext, useContext, useEffect, useRef, useState } from "react";

type WSContext = {
  socket: WebSocket | null;
  status: "idle" | "open" | "closed";
};

const Ctx = createContext<WSContext>({ socket: null, status: "idle" });

export function SocketProvider({ children }: { children: React.ReactNode }) {
  const wsRef = useRef<WebSocket | null>(null);
  const [status, setStatus] = useState<WSContext["status"]>("idle");

  useEffect(() => {
    const ws = new WebSocket(process.env.NEXT_PUBLIC_WS_URL!);
    wsRef.current = ws;
    ws.addEventListener("open", () => setStatus("open"));
    ws.addEventListener("close", () => setStatus("closed"));
    return () => ws.close(1000, "provider-unmount");
  }, []);

  return (
    <Ctx.Provider value={{ socket: wsRef.current, status }}>
      {children}
    </Ctx.Provider>
  );
}

export const useSocket = () => useContext(Ctx);

Then wire it into app/layout.tsx:

import { SocketProvider } from "@/components/socket-provider";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SocketProvider>{children}</SocketProvider>
      </body>
    </html>
  );
}

Now any client component can call useSocket() and get the same singleton socket regardless of route. Add a reconnection loop with exponential backoff inside the provider's close handler, because the raw browser WebSocket doesn't reconnect automatically. That's exactly the kind of thing Socket.IO would give you for free.

Managed services: Ably, Pusher, and Partykit

If you want WebSocket features (fan-out, presence, message history, cross-instance broadcast) without running the infrastructure, a managed service is the correct answer. Vercel's own docs recommend a specific list: Ably, Convex, Liveblocks, Partykit, Pusher, PubNub, Firebase Realtime Database, TalkJS, SendBird, and Supabase. The trade-off is per-message pricing and vendor coupling, but you keep your Next.js deployment on Vercel and get features that would take weeks to build yourself.

Honestly, this is where I've landed on my last two side projects. Here's what a minimal Pusher-protocol setup looks like end to end. On the server side, you publish from a route handler after some write happens:

// app/api/messages/route.ts
import { NextResponse } from "next/server";
import Pusher from "pusher";

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: Request) {
  const { room, text } = await req.json();
  // ... persist to DB ...
  await pusher.trigger(`room-${room}`, "message:new", { text, at: Date.now() });
  return NextResponse.json({ ok: true });
}

On the client, subscribe to the channel in the provider component:

"use client";
import { useEffect, useState } from "react";
import Pusher from "pusher-js";

export function RoomFeed({ room }: { room: string }) {
  const [items, setItems] = useState<{ text: string }[]>([]);

  useEffect(() => {
    const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
    });
    const channel = pusher.subscribe(`room-${room}`);
    channel.bind("message:new", (data: { text: string }) => {
      setItems((prev) => [...prev, data]);
    });
    return () => {
      pusher.unsubscribe(`room-${room}`);
      pusher.disconnect();
    };
  }, [room]);

  return <ul>{items.map((m, i) => <li key={i}>{m.text}</li>)}</ul>;
}

Partykit is worth a specific mention if you like the code model. You write a small handler that runs on Cloudflare's edge, and clients connect to it as if it were a plain WebSocket. Ably's Chat SDK gives you typing indicators, presence, and message history out of the box, which is closer to a batteries-included product than raw infrastructure.

WebSockets vs Server-Sent Events: which do you actually need?

Half the time someone says "I need WebSockets," they only need Server-Sent Events. SSE is one-way (server-to-client), works over standard HTTP without an upgrade, streams inside a normal Fluid Compute function on Vercel, and doesn't need any of the client-side gymnastics above. If your feature is a notification feed, a progress bar, a live-updating dashboard, or a token-by-token AI response, SSE is genuinely simpler.

FeatureWebSocketsServer-Sent Events
DirectionBidirectionalServer → client only
Protocolws:// / wss:// upgradePlain HTTP (text/event-stream)
Vercel deploymentBeta only, single pinned instanceFully supported on Fluid Compute
Custom server neededUsually yesNo
ReconnectionManual (or via Socket.IO)Built into the browser
Binary messagesYesNo (UTF-8 text)
Typical useChat, collaboration, gamesNotifications, progress, AI streaming

Rule of thumb: if the client only reads, use SSE. If the client publishes too (chat messages, cursor positions, presence pings), you need WebSockets. And if you want the App Router routing story to stay boring, the fewer places you introduce persistent-connection code, the easier your runtime choice becomes. SSE works on both Edge and Node; WebSockets effectively force Node.

Deployment checklist for real-time Next.js

Before you ship, run through this once. Most production incidents I've seen with WebSocket-backed Next.js apps come from one of these being wrong.

  • Confirm the deploy target. Vercel serverless (managed service only, unless you're on native beta with its limits), Vercel native beta (single-instance, short duration), or long-lived host like Fly.io/Railway/Docker (custom server viable).
  • Terminate TLS in front of the socket. Browsers refuse mixed content, so an https page cannot connect to a ws:// URL. Behind a reverse proxy (Nginx, Caddy, Cloudflare), make sure Upgrade and Connection headers are forwarded, or the handshake fails.
  • Pin a heartbeat. Load balancers and reverse proxies close idle connections after ~30–60 seconds by default. Send a ping frame from either side every 20–25 seconds, or use Socket.IO's built-in pingInterval.
  • Plan for reconnection with backoff. If you use the raw WebSocket API, wrap it in a helper that retries with exponential backoff and jitter. A thundering herd of clients all reconnecting simultaneously after a deploy will take down a small server.
  • Authenticate the upgrade. Cookies are sent on the initial handshake request, so you can validate a session inside the connection handler. For bearer tokens, pass them as a query param or in the Sec-WebSocket-Protocol header (never in a message payload, since anyone can send messages before auth).
  • Version the message schema. Include a type and a v field on every message from day one. You will regret shipping bare strings the first time you need to add a field.

If you're doing this for a chat feature specifically, add rate limiting to the publish path, because a single misbehaving client can flood a room. The Next.js rate-limiting guide covers a pattern that works from route handlers and can be adapted to Socket.IO middleware.

Frequently Asked Questions

Can you use WebSockets in a Next.js Server Component?

No. Server Components run only on the server and cannot hold a client-side WebSocket connection. You must add "use client" to any component that opens a WebSocket, and instantiate the connection inside useEffect so it runs in the browser after hydration.

Why does my WebSocket code throw ReferenceError during build?

Because you called new WebSocket(...) at module scope. Next.js pre-renders client components on the server for the initial HTML, and the browser WebSocket global does not exist in Node's server render. Move the constructor inside useEffect, which only runs in the browser.

Do I need Socket.IO, or is the native WebSocket API enough?

The native API is enough for simple point-to-point streaming where you're happy to write your own reconnection, room routing, and fallback logic. Reach for Socket.IO when you want rooms, namespaces, automatic reconnection, HTTP long-polling fallback, and message acknowledgements without building them yourself, knowing you're committing to a custom server.

How do I keep a WebSocket open when the user navigates between pages?

Move the WebSocket into a React Context provider mounted in app/layout.tsx. The root layout never unmounts during in-app navigation, so the socket persists. If the connection lives inside a page or a nested layout, it will tear down and re-handshake on every route change.

What's the difference between Fluid Compute and native WebSockets on Vercel?

Fluid Compute streams a standard HTTP response, and the connection closes when the response body ends, which is fine for SSE. Native WebSocket support (public beta since June 22, 2026) actually upgrades the HTTP connection to a persistent bidirectional socket, pinning the function instance for up to 5 minutes (30 minutes on Pro/Enterprise). They are separate features.

Ben Howard
About the Author Ben Howard

Full-stack Next.js developer who's been with the framework since pages-only days. Slowly warming up to App Router.