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.
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.
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.
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.
Related #
- Building a useWebSocket React Hook with TypeScript — the hook to use inside the client component.
- SvelteKit SSR-Safe WebSocket Initialization — the same problem in SvelteKit.
- Nuxt 3 WebSocket Integration — the same problem in Nuxt.
- Reconciling Snapshots and Deltas over WebSockets — stitching the snapshot to the stream.
Back to React WebSocket Custom Hooks.