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.
Copy link to headingQuick start with an AI coding agent
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.
This guide covers the core implementation. The AI assistant prompt above covers all the details your coding agent needs, and you can see the full implementation in the template repository.
Copy link to headingVercel Plugin
Turn your agent into a Vercel expert with the Vercel 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.
Copy link to headingPrerequisites
- Node.js 20 or later
- pnpm (or npm/yarn)
- A Vercel account
- Vercel CLI 54.14.2 or later installed
Copy link to headingHow it works
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.
Copy link to headingSteps
Copy link to heading1. Create the project
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/.
Copy link to headingConfigure Redis
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.
Copy link to heading2. Install the dependencies
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.
Copy link to heading3. Add the WebSocket upgrade route
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.
Copy link to heading4. Create the Redis client
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.
Copy link to heading5. Build the hub
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.
Copy link to heading6. Listen to messages from all instances
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.
Copy link to heading7. Build the client
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.
Copy link to heading8. Run locally
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:

Copy link to heading9. Deploy and test live
Deploy to production and open it in two browser tabs.
Copy link to headingTroubleshooting
Copy link to headingThe socket won't connect locally
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.
Copy link to headingMessages from another tab or device don't appear
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.
Copy link to headingThe connection drops and reconnects every minute or so
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).
Copy link to headingThe build fails resolving ioredis, ws, or Node built-ins
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.
Copy link to headingA typing indicator lingers after the message arrives
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.
Copy link to headingRelated resources
- WebSockets on Vercel Functions
@vercel/functionsreference- Redis commands reference
- ioredis GitHub repository
- Function max duration
Copy link to headingFAQ
Why sessionStorage for identity instead of localStorage?
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.
Can I run this without Redis at all?
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.
Do I have to use Upstash, or can I use another Redis provider?
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.
Why XREAD BLOCK instead of Redis pub/sub?
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.
Why a second connection instead of one?
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.