How I built a chat app using Streams API, Next.JS, Redis and Vercel
Last week I added a chat feature to Sanity. In this article, I'll guide through how I built it using Streams API, Next.js, Redis and Vercel.
Before we start, a quick disclaimer: there are much better ways to build a chat application, for example by using WebSockets. Vercel unfortunately doesn't support WebSockets and I didn't want to spin a dedicated server, which is why I used Streams API. Using Streams API the way I use it here is most likely not the best use of resources but it works and is a good enough solution for my small scale use. If you're on the same boat, keep reading.
If the chat takes off, I'll have to move it to a dedicated Socket.io server, a serverless WebSocket on AWS, or something similar to reduce costs.
Storing messages in Redis
I use the KV (Redis) database from Vercel to store the last 100 messages. Here is the code used to send and read messages.
import { MAX_CHAT_MESSAGE_LENGTH } from "@/utils";
const MAX_MESSAGES = 100;
export const addChatMessage = async ({
topic,
content,
userId,
}: {
topic: string;
content: string;
userId: string;
}) => {
const key = REDIS_KEYS.CHAT(topic);
if (content.length > MAX_CHAT_MESSAGE_LENGTH) {
throw new Error("Message too long");
}
// I'm considering giving each message a unique id using nanoid:
// https://www.npmjs.com/package/nanoid
const message: ChatMessage = {
topic,
fromUserId: userId,
content,
timestamp: Date.now(),
};
await kv.zadd(key, {
score: message.timestamp,
member: message,
});
// If the number of messages exceeds MAX_MESSAGES, remove excess messages
await kv.zremrangebyrank(key, 0, -(MAX_MESSAGES + 1));
return message;
};
export const getChatMessages = async ({
topic,
fromTimestamp = 0,
}: {
topic: string;
fromTimestamp?: number;
}): Promise<ChatMessage[]> => {
const key = REDIS_KEYS.CHAT(topic);
const messages = await kv.zrange(key, fromTimestamp, "+inf", {
byScore: true,
});
return messages as ChatMessage[];
};
Sending messages with GraphQL
I use a GraphQL endpoint to send chat messages. I won't go into my entire GraphQL setup here - that would make this article too long and there are other good GraphQL tutorials you can follow. Instead, I will share some of the key files and schemas. Remember that you don't need to use GraphQL for the chat to work, a REST endpoint can work just as fine.
// resolvers.ts
import { REDIS } from "@/serverApi";
export const graphqlRoot = {
sendMessage: (
payload: { topic: string; content: string },
context: {
userId: string;
},
) => {
const { topic, content } = payload;
return REDIS.addChatMessage({ topic, content, userId: context.userId });
},
};
// schema.gql
type Mutation {
sendMessage(content: String!, topic: String!): Message!
}
type Message {
fromUserId: ID!
content: String!
timestamp: String!
}
// pages/api/private/graphql.ts
import { graphql } from "graphql";
import { PRIVATE_GRAPHQL } from "@/graphql";
import { NextAuthApiHandler, withApiAuth } from "@/lib";
import { errorHandlerMiddleware } from "@/utils";
const handler: NextAuthApiHandler = async (req, res) => {
const {
method,
session: {
user: { username: userId },
},
} = req;
switch (method) {
case "POST": {
const { query, variables } = req.body;
const response = await graphql({
schema: PRIVATE_GRAPHQL.graphqlSchema,
source: query,
contextValue: {
userId,
},
variableValues: variables,
rootValue: PRIVATE_GRAPHQL.graphqlRoot,
});
return res.send(response);
}
default:
res.status(405).json({ message: "Method not allowed" });
}
};
// My error handling and authentication middleware.
// Not needed if you're just looking for a basic proof of concept.
export default withApiAuth(errorHandlerMiddleware(handler));
Streaming messages with the Streams API
Vercel's serverless functions time out after 15 seconds, so I created an API endpoint that checks Redis for new messages every second and streams the result before closing after about 10 seconds. I use 10 seconds instead of 15 just to be on the safe side and avoid a timeout.
Theoretically, I should be able to stream for up to 5 minutes but this functionality is not working at the time of this writing
// app/api/chat/route.ts
import { REDIS } from "@/serverApi";
export const dynamic = "force-dynamic"; // prevent caching of this route
const MAX_RUNS = 10;
const REFRESH_INTERVAL = 1000;
export async function GET(request: Request) {
const encoder = new TextEncoder();
let intervalID: NodeJS.Timer;
const { searchParams } = new URL(request.url);
let lastTimestamp = Number(searchParams.get("lastTimestamp")) || 0;
const topic = searchParams.get("topic") || "";
let count = 0;
const customReadable = new ReadableStream({
start(controller) {
intervalID = setInterval(async () => {
const newMessages = await REDIS.getChatMessages({
topic,
fromTimestamp: lastTimestamp + 1,
});
if (newMessages.length > 0) {
lastTimestamp = newMessages[newMessages.length - 1].timestamp;
controller.enqueue(encoder.encode(JSON.stringify(newMessages)));
}
count += 1;
if (count > MAX_RUNS) {
clearInterval(intervalID);
controller.close();
return;
}
}, REFRESH_INTERVAL);
},
cancel() {
clearInterval(intervalID);
},
});
return new Response(customReadable, {
headers: { "Content-Type": "text/event-stream" },
});
}
Reading and sending messages on the front-end with React, Apollo, and Fetch
I created a useChatStream hook that is used to handle incoming messages. Whenever a request ends (remember that the connection is only open for 10 seconds), it is automatically restarted. I may add an AbortController in the future.
import { useEffect, useRef, useState } from "react";
import { ChatMessage } from "@/types";
const fetchData = async (lastTimestamp: number, topic: string) => {
const response = await fetch(
`/api/chat?lastTimestamp=${lastTimestamp}&topic=${topic}`,
);
if (!response.ok || !response.body) {
throw response.statusText;
}
return response.body.getReader();
};
const readFromStream = async (
topic: string,
lastTimestamp: number,
onData: (messages: ChatMessage[]) => void,
onConnected: () => void,
) => {
const reader = await fetchData(lastTimestamp, topic);
const decoder = new TextDecoder();
// eslint-disable-next-line no-constant-condition
while (true) {
const { value, done } = await reader.read();
onConnected();
if (done) {
break;
}
const newMessages = JSON.parse(decoder.decode(value, { stream: true }));
onData(newMessages);
}
};
export const useChatStream = ({ topic }: { topic: string }) => {
const [isLoading, setIsLoading] = useState(true);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamOpen, setStreamOpen] = useState(false);
const lastTimestamp = useRef(0);
useEffect(() => {
if (streamOpen) {
return;
}
const processChatStream = async () => {
try {
setStreamOpen(true);
await readFromStream(
topic,
lastTimestamp.current,
(newMessages: ChatMessage[]) => {
setMessages((prev: ChatMessage[]) => [...prev, ...newMessages]);
lastTimestamp.current =
newMessages[newMessages.length - 1].timestamp;
},
() => setIsLoading(false),
);
} finally {
setIsLoading(false);
setStreamOpen(false);
}
};
processChatStream();
}, [streamOpen, topic]);
return { messages, isLoading };
};
I use another hook to bring this together with a GraphQL query for sending messages and to replace user ids with usernames. I'm not including the code for getting the list of usernames because it's not strictly necessary for the chat to work.
// My GraphQL query
export const SEND_MESSAGE = gql(`
mutation sendMessage($content: String!, $topic: String!) {
sendMessage(content: $content, topic: $topic) {
fromUserId
content
timestamp
}
}
`);
import { useMutation } from "@apollo/client";
import { useMemo } from "react";
import { SEND_MESSAGE } from "@/graphql/private/queries";
import { useGlobalStore } from "@/store";
import { privateApolloClient, useUsernamesQuery } from "@/utils";
import { useChatStream } from "./useChatStream";
export const useChat = ({ topic = "" }) => {
const [mutateFunction] = useMutation(SEND_MESSAGE, {
client: privateApolloClient,
});
const { messages, isLoading } = useChatStream({ topic });
const { usernames } = useGlobalStore();
const unknownUsernames = useMemo(() => {
const result = new Set<string>();
messages.forEach((message) => {
if (!usernames[message.fromUserId]) {
result.add(message.fromUserId);
}
});
return Array.from(result);
}, [messages, usernames]);
useUsernamesQuery(unknownUsernames);
const enrichedMessages = useMemo(
() =>
messages.map((message) => {
return { ...message, fromUsername: usernames[message.fromUserId] };
}),
[messages, usernames],
);
return {
isLoading,
messages: enrichedMessages,
sendMessage: (content: string) => {
mutateFunction({
variables: {
content,
topic,
},
});
},
};
};
And finally, here are the React components. One for the floating mini-chat and another for a full-page chat. I use TailwindCSS for styling.
// MiniChat.tsx
import {
faMaximize,
faPaperPlane,
faSpinner,
faWindowMinimize,
} from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import React, { FormEvent, useEffect, useRef, useState } from "react";
import { useChat, useIsAuthenticated } from "@/hooks";
import { MAX_CHAT_MESSAGE_LENGTH, ROUTES } from "@/utils";
import { CustomIcon, WithAuthenticationRequired } from "../shared";
export const MiniChat: React.FC<{
topic?: string;
}> = ({ topic = "" }) => {
const [isOpen, setOpen] = useState<boolean>(true);
const isAuthenicated = useIsAuthenticated();
const [inputMessage, setInputMessage] = useState<string>("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const { messages, sendMessage, isLoading } = useChat({ topic });
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
const handleSubmit = (e?: FormEvent) => {
e?.preventDefault();
if (inputMessage.trim().length === 0) return;
sendMessage(inputMessage);
setInputMessage("");
};
return isOpen ? (
<div className="flex sm-none fixed bottom-0 right-5 w-96 h-1/3 bg-slate-100 shadow-xl shadow-inner rounded-t-lg flex-col p-3">
<div className="flex items-center justify-between">
<h5 className="font-bold">{topic} chat</h5>
<div className="flex items-center">
<Link href={ROUTES.CHAT(topic)} className="mr-2">
<CustomIcon
className="text-orange-500"
icon={faMaximize}
width={20}
height={20}
/>
</Link>
<button onClick={() => setOpen(false)}>
<CustomIcon
className="text-orange-500 relative -top-2"
icon={faWindowMinimize}
width={20}
height={20}
/>
</button>
</div>
</div>
<div className="overflow-scroll mb-auto">
{isLoading && (
<CustomIcon
className="animate-spin text-orange-500 block mx-auto mt-10"
icon={faSpinner}
width={30}
height={30}
/>
)}
{messages.map((message) => (
<div
className="mb-4"
key={message.timestamp + message.fromUserId + message.content}
>
<div className="flex justify-between mb-1">
<span className="text-xs font-medium">
{message.fromUsername}
</span>
<span className="text-xs text-gray-500">
{new Date(message.timestamp).toLocaleString(undefined, {
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
<div className="whitespace-pre-wrap">{message.content}</div>
</div>
))}
<div ref={messagesEndRef}></div>
</div>
{!isAuthenicated && (
<WithAuthenticationRequired
renderButton={({ onClick }) => (
<button onClick={onClick}>sign up to chat</button>
)}
/>
)}
{isAuthenicated && (
<form className="flex" onSubmit={handleSubmit}>
<textarea
maxLength={MAX_CHAT_MESSAGE_LENGTH}
className="flex-1 mr-3 rounded-lg px-3 py-2 h-10"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
handleSubmit(e);
}
}}
/>
<button type="submit" aria-label="send message">
<CustomIcon
className="inline mr-2 text-orange-500"
icon={faPaperPlane}
width={20}
height={20}
/>
</button>
</form>
)}
</div>
) : (
<div
className="flex fixed bottom-0 right-5 w-96 bg-slate-300 shadow-xl shadow-inner rounded-t-lg flex-col p-3 cursor-pointer"
onClick={() => setOpen(true)}
>
{topic} chat
</div>
);
};
// LargeChat.tsx
import { faPaperPlane, faSpinner } from "@fortawesome/free-solid-svg-icons";
import React, { FormEvent, useEffect, useRef, useState } from "react";
import { useChat, useIsAuthenticated } from "@/hooks";
import { MAX_CHAT_MESSAGE_LENGTH } from "@/utils";
import { CustomIcon, WithAuthenticationRequired } from "../shared";
export const LargeChat: React.FC<{
topic?: string;
className?: string;
}> = ({ topic = "", className = "" }) => {
const isAuthenicated = useIsAuthenticated();
const [inputMessage, setInputMessage] = useState<string>("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const { messages, sendMessage, isLoading } = useChat({ topic });
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
const handleSubmit = (e?: FormEvent) => {
e?.preventDefault();
if (inputMessage.trim().length === 0) return;
sendMessage(inputMessage);
setInputMessage("");
};
return (
<div className={"flex bg-slate-100 flex-col " + className}>
<div className="flex items-center justify-between border-b border-gray-300 bg-slate-200">
<h1 className="text-xl font-bold mx-6 py-3">{topic} chat</h1>
</div>
<div className="overflow-scroll mb-auto px-6">
{isLoading && (
<CustomIcon
className="animate-spin text-orange-500 block mx-auto mt-10"
icon={faSpinner}
width={50}
height={50}
/>
)}
{messages.map((message) => (
<div
className="mb-4"
key={message.timestamp + message.fromUserId + message.content}
>
<div className="flex justify-between mb-1">
<span className="text-md font-medium">
{message.fromUsername}
</span>
<span className="text-md text-gray-500">
{new Date(message.timestamp).toLocaleString(undefined, {
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
<div className="whitespace-pre-wrap text-lg">{message.content}</div>
</div>
))}
<div ref={messagesEndRef}></div>
</div>
{!isAuthenicated && (
<WithAuthenticationRequired
renderButton={({ onClick }) => (
<button onClick={onClick}>sign up to chat</button>
)}
/>
)}
{isAuthenicated && (
<form className="flex px-6 py-4 bg-slate-200" onSubmit={handleSubmit}>
<textarea
maxLength={MAX_CHAT_MESSAGE_LENGTH}
className="flex-1 mr-3 rounded-lg px-3 py-2"
value={inputMessage}
rows={2}
onChange={(e) => setInputMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
handleSubmit(e);
}
}}
/>
<button type="submit" aria-label="send message">
<CustomIcon
className="inline mr-2 text-orange-500"
icon={faPaperPlane}
width={25}
height={25}
/>
</button>
</form>
)}
</div>
);
};
Summary
The above solution works relatively well as a proof of concept but has a number of drawbacks. First, it uses an inordinate number of KV requests, sending one KV query every second for each visitor. Second, it needs to reconnect to the streaming endpoint every 10 seconds. On the other hand, I get to keep my stack simple by keeping everything in the same Next.js app and there aren't enough Sanity users yet for this to be expensive. Once Sanity starts getting more traffic I'll switch to a more proper solution, most likely based on WebSockets.
I hope this helps you build your own Streams API / Next.js app.
If you found this article useful, please consider sharing or upvoting it 🚀