Using WebSockets in the Next.js App Router #

You add new WebSocket() to a component in a Next.js App Router project and the build fails with WebSocket is not defined, or the page renders but hydration warns that server and client output differ, or you try to create the socket server inside a route handler and discover it cannot accept upgrades at all. The App Router splits your code between server components, which run only on the server and never in the browser, and client components, which run in both places. A WebSocket client belongs to exactly one of those worlds, and the socket server belongs to neither. This page places each piece correctly.

Root cause #

Three separate facts cause the three errors. First, server components execute in Node.js during rendering; they have no browser APIs and are never re-executed on the client, so a WebSocket created there either fails or opens a server-side connection that is thrown away when the render finishes. Second, client components still render once on the server to produce HTML, so any code at module scope or in the component body runs in Node too; only effects run exclusively in the browser. Third, route handlers (app/api/.../route.ts) implement request/response on the Web Request/Response API. They return a response and finish, and neither the Node nor the Edge runtime in Next.js exposes an upgrade hook through them, so they cannot keep a WebSocket open.

The hydration mismatch is a consequence of the second fact: if a client component renders “connected” on the client but “connecting” on the server, React sees different HTML and warns.

Where each piece runs Server components fetch initial data on the server only; client component bodies render in both places; effects run only in the browser and are where the WebSocket opens; route handlers cannot accept upgrades; a separate service hosts the socket server. Where each piece runs Server component fetch initial data; no browser APIs; never re-runs in the browser server only Client component body renders on server for HTML, then in browser; no socket here both useEffect in client component browser only: open and close the WebSocket here browser Route handler request/response only; cannot accept an upgrade server Separate WS service custom Node server or standalone service holds sockets own process The socket client lives in an effect; the socket server lives outside Next.js route handlers
Put each concern in the one place its runtime supports it.

Resolution #

Split the feature in two. A server component fetches the initial state over HTTP (or directly from your database) and passes it as props to a client component, which renders that state immediately — identical on server and client, so hydration matches — and opens the WebSocket in an effect to receive updates from then on. The socket server runs as its own service (or a custom Node server alongside Next.js), reachable at a URL provided through a public environment variable.

// app/rooms/[id]/page.tsx — server component: initial data, no socket.
import { LiveRoom } from './live-room';

export default async function RoomPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const res = await fetch(`${process.env.API_URL}/rooms/${id}`, { cache: 'no-store' });
const room = (await res.json()) as { id: string; messages: { id: string; text: string; seq: number }[] };
return <LiveRoom roomId={id} initialMessages={room.messages} />;
}
// app/rooms/[id]/live-room.tsx — client component: renders props, then goes live.
'use client';
import { useEffect, useState } from 'react';

type Msg = { id: string; text: string; seq: number };
const WS_URL = process.env.NEXT_PUBLIC_WS_URL!; // inlined at build time for the browser

export function LiveRoom({ roomId, initialMessages }: { roomId: string; initialMessages: Msg[] }) {
const [messages, setMessages] = useState(initialMessages); // same on server and client
const [status, setStatus] = useState<'connecting' | 'live' | 'offline'>('connecting');

useEffect(() => {
// Runs only in the browser, after hydration.
const lastSeq = initialMessages.at(-1)?.seq ?? 0;
const ws = new WebSocket(`${WS_URL}/rooms/${roomId}?afterSeq=${lastSeq}`);
ws.onopen = () => setStatus('live');
ws.onclose = () => setStatus('offline');
ws.onmessage = (e) => {
const msg = JSON.parse(e.data) as Msg;
// Drop anything the server-rendered snapshot already contained.
setMessages((prev) => (msg.seq <= (prev.at(-1)?.seq ?? 0) ? prev : [...prev, msg]));
};
return () => ws.close(1000, 'unmount'); // navigation away, StrictMode
}, [roomId, initialMessages]);

return (
<section aria-live="polite">
<p className="status">{status}</p>
<ul>{messages.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
</section>
);
}

The afterSeq parameter closes the gap between the moment the server component fetched the snapshot and the moment the socket connected: the server replays anything newer, and the sequence check on the client discards duplicates. Without it, messages posted during that window — typically a few hundred milliseconds — are silently lost. This is the same snapshot-plus-delta reconciliation described in reconciling snapshots and deltas over WebSockets.

For the server side, run a standalone WebSocket service (the usual choice in production, deployable and scalable independently) or a custom Node server that creates the Next.js request handler and attaches a ws server to the same HTTP server’s upgrade event. The custom server gives up some Next.js deployment conveniences, and platforms built around serverless functions do not run it at all; there, a managed service is the practical option — see managed vs self-hosted WebSocket services.

Snapshot, hydrate, then go live The browser requests the page, the Next.js server fetches the room up to sequence 310 and returns HTML, the browser hydrates, then connects to the WebSocket service with afterSeq 310 and receives messages 311 and 312 followed by live updates. Snapshot, hydrate, then go live Browser Next.js server API WS service GET /rooms/42 fetch room (seq ≤ 310) HTML with 310 messages hydrate (matches) connect afterSeq=310 replay 311–312, then live The sequence number stitches the server-rendered snapshot to the live stream without gaps
Server rendering gives a fast first paint; the socket takes over from the exact point it left off.

Edge cases #

Module-scope singletons. A socket singleton created at module scope in a client component file still executes during server rendering. Guard with typeof window !== 'undefined', or create it lazily inside an effect or event handler.

Environment variables. Only variables prefixed NEXT_PUBLIC_ reach browser code, and they are inlined at build time. If the socket URL differs per environment but you build once, fetch it at runtime from a config endpoint instead.

Navigation between routes. Client-side navigation unmounts the page’s components, so a socket owned by a page component closes on every route change. If the connection should persist across routes, own it in a client component in the root layout.tsx, which survives navigation, and expose it through context as in WebSocket context provider in React.

Verification #

Build and run in production mode (next build && next start), because development mode’s StrictMode double-mount changes effect behaviour. Load a room page with the Network panel open: the HTML should already contain the messages (view source), there should be no hydration warning in the console, and the WS view should show one connection opened after load with the afterSeq parameter. Post a message from another client between page load and socket open (throttle the connection to make the window visible) and confirm it appears exactly once.

Hosting the socket server with Next.js Comparison of hosting a WebSocket server in a Next.js route handler, a custom Node server, a standalone service or a managed service by upgrade support, independent scaling and compatibility with serverless hosting. Hosting the socket server with Next.js Upgrades Scales separately Serverless hosts Route handler no Custom Node server yes no, same process not supported Standalone ws service yes yes deploy elsewhere Managed service yes yes yes Route handlers are request/response only; the socket always needs a long-lived process somewhere
Next.js serves the page; something else holds the connection.

Operational checklist #

FAQ #

Can I run a WebSocket server in a Next.js API route? #

Not in the App Router’s route handlers: they implement request/response and expose no upgrade mechanism. Use a custom Node server that attaches a ws server to the HTTP server, or run the socket server as a separate service.

Why does my socket connect twice in development? #

React StrictMode mounts, unmounts and remounts components in development, so the effect runs twice. Make sure the cleanup closes the first socket; production renders once. Details are in useWebSocket cleanup and teardown patterns.

Should the initial data come over the socket instead of server rendering? #

You can, but you lose the fast first paint and search-indexable HTML that server components provide, and the page shows a loading state until the socket connects. Server-render the snapshot and use the socket for changes.

Do Server Actions replace WebSockets? #

No. Server Actions are request/response mutations initiated by the client. They are a good way to send changes, but they cannot push updates from the server; you still need a WebSocket or Server-Sent Events for that.

Back to React WebSocket Custom Hooks.