This guide teaches you to build a working real-time chat feature using Next.js 15 App Router and Upstash Redis with WebSocket-compatible pub/sub. You will have a functional, deployable chat module by the end. Most developers complete this in under two hours.
What You'll Build
- A Next.js 15 App Router project with a dedicated chat route
- A WebSocket connection layer using the
wslibrary running on a Node.js custom server - A pub/sub message bus backed by Upstash Redis so messages survive server restarts and scale across instances
- A minimal React chat UI component that sends and receives messages in real time
- A deployment-ready configuration tested on Node 20+ and compatible with Railway and Render
Prerequisites
- Node 20 or higher installed locally
- pnpm 9 installed (
npm install -g pnpm@9) - A free Upstash account with a Redis database created
- Familiarity with React hooks and basic Next.js routing
- A GitHub account if you want to deploy via Railway or Render
Step 1: Scaffold the Next.js 15 Project
Why start with a clean scaffold instead of adding to an existing app?
WebSocket servers require a custom Node.js entry point. Mixing that into an existing project without planning causes port conflicts and confusing middleware behaviour. Starting clean takes five minutes and saves an hour of debugging.
pnpm create next-app@latest realtime-chat \
--typescript \
--tailwind \
--app \
--no-src-dir \
--import-alias "@/*"
cd realtime-chat
Accept all defaults. You now have a Next.js 15 project with the App Router and TypeScript enabled.
Expected result: Running pnpm dev opens the default Next.js welcome page on port 3000.
Common pitfall: If you already have a Next.js 14 project, upgrade to 15 first with pnpm add next@15 react@19 react-dom@19. The custom server API changed slightly between versions.
Step 2: Install Dependencies
pnpm add ws @upstash/redis
pnpm add -D @types/ws
The ws package is the most widely used WebSocket library for Node.js as of 2026, with over 90 million weekly downloads. Upstash Redis uses an HTTP-based client, so it works inside serverless environments and does not require a persistent TCP connection.
Step 3: Create the Custom Node.js Server
Why does Next.js need a custom server for WebSockets?
Next.js's built-in server does not expose the raw http.Server instance, which is required to hand off WebSocket upgrade requests. A custom server wraps Next.js and takes control of that upgrade event.
Create a file at the project root called server.ts:
import { createServer } from 'http';
import { parse } from 'url';
import next from 'next';
import { WebSocketServer, WebSocket } from 'ws';
import { Redis } from '@upstash/redis';
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
const CHANNEL = 'chat:messages';
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url!, true);
handle(req, res, parsedUrl);
});
const wss = new WebSocketServer({ server });
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('message', async (data) => {
const message = data.toString();
// Publish to Upstash Redis for persistence and cross-instance fanout
await redis.lpush(CHANNEL, message);
await redis.ltrim(CHANNEL, 0, 99); // Keep last 100 messages
// Broadcast to all connected clients on this instance
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
});
ws.on('close', () => {
clients.delete(ws);
});
});
const port = process.env.PORT || 3000;
server.listen(port, () => {
console.log(`Server ready on http://localhost:${port}`);
});
});
Expected result: The file compiles without errors when you run pnpm tsc --noEmit.
Common pitfall: Do not import next as a default import using require. The ESM-compatible form shown above is required in Next.js 15.
Step 4: Wire Up Environment Variables
Create a .env.local file at the project root:
UPSTASH_REDIS_REST_URL=https://your-database.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token_here
Find both values in the Upstash console under your database's REST API section. Never commit this file to Git.
Add a .env.local entry to .gitignore if it is not already there. Next.js 15 includes it by default, but confirm before pushing.
Step 5: Update package.json Scripts
Next.js needs to run your custom server instead of its default one. Update package.json:
{
"scripts": {
"dev": "ts-node --project tsconfig.json server.ts",
"build": "next build",
"start": "NODE_ENV=production ts-node --project tsconfig.json server.ts"
}
}
Install ts-node as a dev dependency:
pnpm add -D ts-node
Add the following to tsconfig.json under compilerOptions if not already present:
"module": "CommonJS",
"esModuleInterop": true
Common pitfall: ts-node and ESM do not always cooperate in monorepos. If you see ERR_UNKNOWN_FILE_EXTENSION, add "ts-node": { "esm": false } to your tsconfig.json.
Step 6: Build the React Chat Component
Create the directory and file app/chat/page.tsx:
'use client';
import { useEffect, useRef, useState } from 'react';
interface Message {
user: string;
text: string;
ts: number;
}
export default function ChatPage() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [username] = useState(() => `User${Math.floor(Math.random() * 1000)}`);
const socketRef = useRef(null);
useEffect(() => {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${protocol}://${window.location.host}`);
socketRef.current = ws;
ws.onmessage = (event) => {
try {
const msg: Message = JSON.parse(event.data);
setMessages((prev) => [...prev, msg]);
} catch {
// Ignore malformed messages
}
};
return () => ws.close();
}, []);
const send = () => {
if (!input.trim() || !socketRef.current) return;
const msg: Message = { user: username, text: input.trim(), ts: Date.now() };
socketRef.current.send(JSON.stringify(msg));
setInput('');
};
return (
Live Chat
{messages.map((m) => (
{m.user}:
{m.text}
))}
setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="Type a message..."
/>
);
}
Expected result: Navigate to http://localhost:3000/chat in two browser tabs. A message sent in one tab appears in the other within under 100ms on a local network.
Pro tip: Open your browser's Network tab, filter by WS, and confirm the WebSocket handshake shows a 101 Switching Protocols response. If you see a 400, your server is not handling the upgrade event correctly.
Step 7: Persist and Replay Recent Messages
When should you skip message history?
If your chat is ephemeral (like a live event Q&A), skip this step. For most product use cases, users expect to see recent messages when they join.
Create an API route at app/api/history/route.ts:
import { Redis } from '@upstash/redis';
import { NextResponse } from 'next/server';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
export async function GET() {
const messages = await redis.lrange('chat:messages', 0, 49);
// Upstash returns newest-first from lpush, so reverse for display
return NextResponse.json(messages.reverse());
}
Then call this endpoint in the chat component's useEffect before opening the socket:
useEffect(() => {
fetch('/api/history')
.then((r) => r.json())
.then((history: string[]) => {
const parsed = history.map((h) => JSON.parse(h) as Message);
setMessages(parsed);
})
.catch(() => {});
// ... rest of socket setup
}, []);
This gives users the last 50 messages on load. Upstash's LRANGE on a 50-item list typically responds in under 10ms from the nearest regional endpoint.
Step 8: Deploy to Railway
Railway supports long-running Node.js processes, which is necessary for WebSocket servers. Vercel's serverless model does not support persistent WebSocket connections at this time, so Railway or Render are better choices for this architecture as of September 2026.
- Push your project to a GitHub repository.
- In Railway, create a new project from your GitHub repo.
- Add your
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENas environment variables in the Railway dashboard. - Set the start command to
pnpm start. - Deploy and check the Railway logs for "Server ready on".
Pro tip: Railway automatically assigns a public domain with TLS. Your WebSocket client code uses wss:// in production because the protocol check reads window.location.protocol. No code change is needed between local and production.
If you are building a product that relies on real-time features like this at scale, the team at Lenka Studio regularly architects and ships these kinds of systems for SMBs across Australia and Singapore.
Frequently Asked Questions
Does this work on Vercel?
Not with this architecture. Vercel functions are stateless and serverless. They terminate after each request, so a persistent WebSocket connection cannot live there. Use Railway, Render, or a dedicated VPS instead. Vercel does support Vercel's own Realtime product using Server-Sent Events, which is a different approach.
What if the WebSocket connection drops?
Add a reconnect loop in the useEffect. Store a reference to a setTimeout and call the connection setup again on the socket's onclose event. Most production chat apps implement exponential backoff, starting at 1 second and capping at 30 seconds.
How does this scale beyond one server instance?
The current architecture broadcasts only to clients connected to the same server instance. For multi-instance deployments, replace the in-memory clients set with a proper Upstash Redis pub/sub subscriber on each instance. Each instance subscribes to the same channel and fans messages out to its own local clients.
Is Upstash Redis free enough for a small app?
Upstash's free tier includes 10,000 commands per day and 256MB of storage as of 2026. A low-traffic chat feature typically uses fewer than 2,000 commands per day. The pay-as-you-go tier costs $0.20 per 100,000 commands beyond that, so costs stay very low at early stages.
What if I get a 101 but messages do not appear in the second tab?
Check that both clients are connecting to the same server process. In development with pnpm dev, Next.js may restart the process, clearing the in-memory clients set. Stop and restart the dev server, then open both tabs fresh. In production this resolves itself because the server process is stable.
Next Steps
From here, you can add user authentication using Clerk or Supabase Auth so each message carries a verified identity. You could also add room support by namespacing the Redis list key (for example, chat:room:general) and passing a room ID as a query parameter in the WebSocket URL.
If your team is planning a product that needs real-time collaboration, live support, or event-driven notifications, Lenka Studio builds and deploys these features for growing businesses. Reach out and tell us what you are building.




