Build a global chat room that shows a live user count, who's typing, and messages in real time. It runs on Next.js (App Router) with WebSockets on Vercel Functions, and shares state across instances using Upstash Redis so the room stays consistent no matter which Function instance a client lands on. There is no separate socket server to run because the WebSocket endpoint is a Function.
In this guide, you'll start with a Next.js app, add a WebSocket upgrade route to it, integrate Redis to persist state, and add a hub that tracks user presence and relays messages.
Deploy the template now, or read on for a deeper look at how it all works.
Next.js real-time chat app with WebSockets
A single-room chat app with real-time typing indicators, messages, and chat persistence.
I want to build a real-time chat app using the Next.js real-time WebSocket chat template. Read the setup instructions at https://agent-resources.dev/nextjs-websocket-chat-template.md and follow them. They will cover deploying the template, building on WebSockets on Vercel Functions, how everything works overall, and more.
Turn your agent into a Vercel expert with this plugin. It gives your coding agent current knowledge of the Vercel products this template uses. The plugin is optional; it is not required to use this template or for this guide.
- Node.js 20 or later
- pnpm (or npm/yarn)
- A Vercel account
- Vercel CLI 54.14.2 or later installed
A WebSocket connection on Vercel is pinned to one Function instance for its lifetime. That makes intra-instance delivery trivial (broadcast to the local sockets) but also means two clients can land on different instances and never see each other directly.
There are four moving parts:
- The WebSocket route (
app/api/ws/route.ts) accepts socket connections. - The chat hub (
lib/chat.ts) keeps track of sockets connected to this Function instance. - Redis shares room events and presence across Function instances.
- The React client connects, reconnects, sends messages, and renders the room.
Scaffold a new Next.js app with create-next-app.
--yes takes the default answers (TypeScript, App Router, Tailwind), so you land in a runnable project with app/ already in place. The rest of the guide adds files under app/ and lib/.
For this project, you’ll use Redis to persist chat channels and messages. To set up Redis, run the following command:
When prompted, select Upstash for Redis.
Now, pull the development environment variables by running the following command:
This command creates a .env.local file with the REDIS_URL environment variable.
From the project root, add the runtime packages:
@vercel/functions provides experimental_upgradeWebSocket and uses the ws (the Node WebSocket implementation). ioredis is the Redis client used both for writes and, on a duplicated connection, for the blocking reader. boring-avatars renders a deterministic avatar from a string seed.
The experimental_upgradeWebSocket API is experimental and will change in the future. This will only work on Vercel and shouldn’t be used in place of the native Node.js APIs for WebSocket connection upgrade. For more information, see docs.
This route handler performs the upgrade and dispatches each incoming frame to the hub. The handler is deliberately thin.
The order matters here: register(ws) and ws.on('message', ...) run in the same synchronous tick, with no await before them. A client sends its join frame the moment the socket opens; if you await anything before attaching the message listener, that first frame is dropped. 'error' reuses the same close handler because errors may also be followed by close; make unregister idempotent if both handlers call it.
With Redis, separate Function instances can share the same chat room. Without it, the app still works as a single-instance local chat.
One ioredis client serves the whole app. It connects over the native Redis protocol and returns null when not configured, so the app can run with no Redis. The hub will later call redis.duplicate() on it for the dedicated blocking-read connection.
maxRetriesPerRequest: null is what lets a single XREAD block for seconds without ioredis's per-command retry cap killing it. fieldsToObject normalizes the flat field arrays ioredis returns from XREAD/XREVRANGE into a record the hub can read.
The hub is the server-side room manager. It knows which sockets are connected to this Function instance, sends events to those sockets, and uses Redis when the event needs to reach other instances too.
Each Function instance has its own hub. That hub can talk directly to the sockets connected to the same instance. Redis is used only when state needs to be shared with other instances.
Start with the shared message types, Redis key names, and a few tuning values.
There are two identities to keep straight:
clientIdmeans "this user in this browser tab."connectionIdmeans "this specific WebSocket connection."
That distinction matters because a user can reconnect or open more than one tab. The server tracks each socket separately, then groups sockets by clientId when it calculates the online user count.
A socket connects in two phases:
registerrecords the socket immediately. This must stay synchronous so the server is ready for the firstjoinframe the browser sends as soon as the socket opens.joinattaches the user's identity, sends recent history to that socket, updates presence, and posts a system message if this is the user's first active connection.
unregister does the reverse when a socket closes: it removes the connection, broadcasts the updated presence list, and only posts a "left" message if that user has no other active connections.
Sending a message has two steps:
- First, the hub broadcasts to sockets connected to this same Function instance. That makes local delivery immediate.
- Then, if Redis is configured, the hub writes the message to Redis. Other Function instances are listening for those Redis entries and will forward the message to their own local sockets.
Each entry carries d (the JSON payload) and o (the originating instance's id). The o tag is how the reader avoids echoing an instance's own messages back to it. MAXLEN ~ MSG_MAXLEN trims the stream to approximately 200 entries, which is cheaper than exact trimming and fine for a capped log.
Because this guide is following the main flow, it does not inline every helper from lib/chat.ts. The full file also includes:
postTyping, which relays transient typing events locally and through Redis.loadHistory, which reads the newest messages from Redis for a newly joined socket.touch,computePresence,isOnline, andbroadcastPresence, which keep the online count and user list accurate across instances.heartbeat, which refreshes the last-seen time for local sockets so active users do not expire.
Each Function instance already sends messages to the sockets connected to it. We need Redis only when another instance writes a message or typing event. To receive those cross-instance events, each active instance keeps one Redis connection waiting for new stream entries. When Redis gets a new entry, the wait ends immediately, and the instance forwards that event to its own local sockets.
This gives us realtime delivery without polling:
- A user sends a message.
- Their Function instance broadcasts it locally.
- That instance also writes the event to Redis.
- Other Function instances wake up, read the event, and broadcast it locally.
The loop below is that listener implemented:
Here’s how this works:
XREAD BLOCKmeans "wait until Redis has something new," not "check every few seconds."- The timeout lets the loop wake up occasionally so it can shut down cleanly.
- Each event includes the originating instance id, so an instance skips events it already delivered locally.
- The cursor values (
lastMsgIdandlastTypingId) remember where this instance left off in each stream.
The loop's lifecycle is bounded by connections:
startStream runs on the first connection and is idempotent: the hub.streaming guard, set synchronously before any await, stops a second concurrent connection from opening a duplicate reader. It seeds the cursors from each stream's tail with XREVRANGE so a fresh instance only relays entries created after it started (history is handled separately, on join). stopStream runs from unregister once the last local connection closes, quitting the TCP client and clearing the timers so an idle instance holds nothing open.
Presence stays on a timer rather than the stream because expiring a dead connection is inherently time-based: a client that drops without a clean close can only be detected by its sorted-set score going stale past CONN_TTL_MS. The PRESENCE_MS recompute prunes those and rebroadcasts the roster; the HEARTBEAT_MS timer refreshes the score of every local connection so live ones never expire.
The client splits into identity (ChatRoom) and the live room (Chat). ChatRoom owns a per-tab clientId and display name in sessionStorage.
sessionStorage (not localStorage) is the deliberate choice here because it makes each browser tab its own user. So, you can test multi-user behavior by opening two tabs, while still surviving a reload. When no name is stored, the join form is prefilled with a generated suggestion like "Swift Otter".
Chat opens the socket, sends join on open, reconnects with backoff, and handles the four server events.
A WebSocket on a Function closes when the Function hits its max duration, so the client will be disconnected periodically and must reconnect (backoff from 1s, doubling to a 30s ceiling) and re-send join. Outbound, the client sends message on submit and a typing frame throttled to once per TYPING_THROTTLE_MS (2000ms) while editing.
The online count uses status === 'online' ? Math.max(count, 1) : count, so a connected user never sees "0 people online" before the first presence frame arrives. And a typing badge is cleared two ways: its own TYPING_CLEAR_MS (3000ms) timer, and immediately when that sender's message arrives.
To get started, clone the complete template, which has the chat UI implemented.
When developing a Next.js app that uses experimental_upgradeWebSocket() locally, you must run the development server using vercel dev instead of next dev :
This will start your app on http://localhost:3000:

Deploy to production and open it in two browser tabs.
Cause: WebSocket upgrades using the experimental_upgradeWebSocket API require the Vercel runtime to inject the upgrade handler. Plain next dev doesn't provide it at all, and local vercel dev may not service the upgrade depending on your CLI version.
Fix: Upgrade to the latest Vercel CLI version and verify the code with vercel dev. When possible, you should handle WebSocket connections using native Node.js APIs instead. For more information, see docs.
Cause: Within one instance, local broadcast covers everything, so this points at the cross-instance path. Without REDIS_URL, newStreamClient() returns null, startStream skips the reader, and instances never see each other's entries.
Fix: Confirm REDIS_URL is set in the deployment's environment, then re-run vercel env pull and vercel dev.
Cause: This is expected. A WebSocket lives only as long as its Function instance, which closes at the max duration. The client reconnects with backoff and re-sends join.
Fix: You can increase the function’s max duration. If it doesn't recover, check that the close handler's reconnect timer isn't being cancelled (the cancelled flag is only set on component unmount).
Cause: Next.js is trying to bundle a package that depends on Node.js built-ins (net, tls) instead of requiring it at runtime.
Fix: Add the package to serverExternalPackages in next.config.ts and rebuild.
Cause: The message handler isn't clearing the sender from the typing map, so the badge only clears on its 3-second timer.
Fix: In the message handler, delete the sender's pending timer and remove the entry keyed by message.clientId.
- WebSockets on Vercel Functions
@vercel/functionsreference- Redis commands reference
- ioredis GitHub repository
- Function max duration
localStorage is shared across every tab in a browser, so two tabs would share one identity and avatar — and overwrite each other's stored name. sessionStorage is per-tab and survives reloads, which makes each tab a distinct user and lets you test multi-user behavior in one browser.
Yes, as a single-instance deployment. With no credentials, the Redis client returns null; messages and presence still work within a single Function instance via local broadcast, but there's no history and no cross-instance delivery. That's the intended fallback for local development before provisioning Redis.
Any Redis works. The app expects a Redis URL to connect to. Apart from that, the keys, streams, and sorted sets are all standard Redis. So, they will work the same everywhere.
The stream does double duty: it's both the cross-instance relay and the durable history a new client replays on join. Pub/sub would deliver live messages but keep no log, so you'd need a second structure for history anyway. Blocking on the stream gets real-time delivery and a replayable backlog from one place.
A blocking XREAD parks its connection until an entry arrives, and any other command issued on that same connection queues behind it. So, the reader needs its own connection (redis.duplicate()) while writes and presence run on the main one. It's one client library and one URL, but two TCP connections per instance. At high fan-out, watch your Redis provider's connection cap, since each instance holds two.