WebSocket
Set up the WebSocket endpoint
Serve VitNode's /api/ws WebSocket from a single TanStack Start app with Nitro, Vercel included, or from a standalone Node API server.
VitNode's WebSocket lives at /api/ws. A single app serves it from the same deployment as the site and the HTTP API, through Nitro. A standalone API serves it from its own Node server. Both run the same handshake, protocol and handlers, so plugins and useVitNodeWebSocket work the same either way.
New single apps from create-vitnode-app already include everything on this page. Follow it to add WebSockets to an older single app.
Single app
The browser upgrades /api/ws. Nitro owns that route, so the upgrade never reaches the TanStack Start catch-all, while every other /api/* request still does. Nitro then sends the handshake through the embedded Hono API as an ordinary GET /api/ws, so the Origin check, the rate limiter and the session cookie apply before the socket opens.
Enable WebSockets in the Vite config
Tell VitNode this app serves its own socket:
plugins: [
vitnode({ appRoot: import.meta.dirname, webSocket: true }),
devtools(),
tailwindcss(),
tanstackStart(),
viteReact(),
],That one flag turns on Nitro's WebSocket support, mounts VitNode's Nitro route at /api/ws and lets the browser open the socket. Without it the browser never connects in an app that embeds the API.
The route ships with @vitnode/core, so there is no file to copy. It runs in Nitro's bundle, loads your server bundle on the first upgrade a fresh instance gets, and hands every handshake to the API your server bundle registered with configureInProcessApi. If that bundle cannot load, the handshake gets 503 and the client retries.
Older single apps mounted their own src/server/websocket.ts through nitro({ handlers }). Delete that file and the nitro() call when you switch to webSocket: true, or two handlers fight over /api/ws.
Mount the handshake on the embedded API
Add the /ws route after VitNodeAPI, so the API's middleware runs first:
import { websocketOriginMiddleware } from "@vitnode/core/api/middlewares/websocket-origin.middleware"
import { upgradeWebSocket } from "@vitnode/core/ws/crossws.server"
import { handleVitNodeWebSocket } from "@vitnode/core/ws/handle"
const createVitNodeApi = () => {
const app = new OpenAPIHono().basePath("/api")
VitNodeAPI({ app, vitNodeApiConfig })
app.get(
"/ws",
websocketOriginMiddleware(),
upgradeWebSocket(handleVitNodeWebSocket()),
)
return app
}upgradeWebSocket from @vitnode/core/ws/crossws.server does not upgrade anything itself. It hands the connection's handlers back to the Nitro route, which opens the socket. A plain GET /api/ws gets 426 Upgrade Required.
Standalone API
A separate API server runs on @hono/node-server and upgrades with its own helper and a ws server. Mount the route after VitNodeAPI, and pass the web app's origin when it is not VITNODE_WEB_URL:
import { serve, upgradeWebSocket } from "@hono/node-server"
import { websocketOriginMiddleware } from "@vitnode/core/api/middlewares/websocket-origin.middleware"
import { handleVitNodeWebSocket } from "@vitnode/core/ws/handle"
import { WebSocketServer } from "ws"
app.get(
"/ws",
websocketOriginMiddleware({ origin: [webOrigin] }),
upgradeWebSocket(handleVitNodeWebSocket()),
)
serve({
fetch: app.fetch,
port: Number(process.env.PORT ?? 8000),
websocket: { server: new WebSocketServer({ noServer: true }) },
})A web app pointed at that API with VITNODE_API_URL connects to it on its own; it needs no webSocket option and no Nitro route.
Who may connect
Both setups apply the same rules before a socket opens:
- Origin: the configured web and API origins, any extra
originyou pass, and the handshake's own origin (a page on the same host, such as a preview deployment). A handshake without anOriginheader is not a browser and is allowed. Anything else gets403. - Session: the session cookie decides the user. Guests connect too, as
null; a handler that needs a user checksc.get("user")itself, and live editing checks the AdminCP session on every message. - Redis: with
REDIS_URLset, the instance answers503until it is subscribed to the realtime channel, so it never accepts a socket that other instances cannot reach. The client retries.
A rejected handshake registers nothing. Identity is fixed for the life of a socket: after sign-in or sign-out the client reconnects on its own, but a session revoked from another device keeps its open socket until that socket closes.
Check the result
Run the app, open the site, and look at the browser's network panel: /api/ws shows 101 Switching Protocols, followed by vitnode_ping and vitnode_pong frames every 25 seconds. AdminCP ā Core ā System ā Integrations shows WebSocket as active.