Chat Room
Build a realtime chat room backend with Rivet Actors: one actor per room, SQLite-backed message history, and WebSocket broadcast to every connected client.
A chat room needs three things: one place that orders every message in the room, history that survives restarts, and instant delivery to everyone who is connected. A single Rivet Actor per room gives you all three.
What we’ll build:
- A
chatRoomActor per room, keyed by the room name, that every client in the room connects to. - Message history in the Actor’s SQLite database, so it survives sleep and restarts.
- A
newMessageevent that the Actor broadcasts to every connected client on each send. - A React client that loads the history once and then appends messages as they arrive.
Install
npm add rivetkit @rivetkit/react
Create the chat room Actor
Each room is its own chatRoom Actor. It stores the room’s messages in its own SQLite database:
import { actor, event, setup } from "rivetkit";
import { db } from "rivetkit/db";
export type Message = { sender: string; text: string; timestamp: number };
export const chatRoom = actor({
// Persist chat history in the actor's SQLite database: https://rivet.dev/docs/actors/sqlite
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
text TEXT NOT NULL,
timestamp INTEGER NOT NULL
)
`);
},
}),
events: {
newMessage: event<Message>(),
},
actions: {
// Callable functions from clients: https://rivet.dev/docs/actors/actions
sendMessage: async (c, sender: string, text: string) => {
const message: Message = { sender, text, timestamp: Date.now() };
await c.db.execute(
"INSERT INTO messages (sender, text, timestamp) VALUES (?, ?, ?)",
sender,
text,
message.timestamp,
);
// Send events to all connected clients: https://rivet.dev/docs/actors/events
c.broadcast("newMessage", message);
return message;
},
getHistory: async (c) => {
const rows = await c.db.execute(
"SELECT sender, text, timestamp FROM messages ORDER BY id ASC",
);
return rows as Message[];
},
},
});
// Register actors for use: https://rivet.dev/docs/setup
export const registry = setup({
use: { chatRoom },
});
// Start the server on port 6420
registry.start();
- The client picks the room with the key
[roomId]. Every client with the same room name reaches the same Actor, and different rooms never share history or connections. Rooms also spread across the cluster on their own, so a busy room does not slow down the others. db({ onMigrate })creates themessagestable.onMigrateruns every time the Actor starts, which is why the statement usesCREATE TABLE IF NOT EXISTS.- Every
sendMessagefor a room runs in the same Actor, the only writer to that room’s database. TheAUTOINCREMENTid therefore gives the room one order without any locks. getHistoryreturns every message sorted byid, not bytimestamp, so every client loads the history in that order.setup({ use: { chatRoom } })registers the Actor, andregistry.start()serves it on port 6420.
SQLite fits history that is large, long-lived, or needs caps, pagination, or search. If you only keep a small recent window, such as the last 50 to 100 messages, you can push messages onto an array in state instead and trim it to the cap on every send. That is simpler, but the whole history lives in memory and there is no query layer.
getHistory returns every row in one response with no limit, and the table grows forever. A long-lived room eventually hits the outgoing message size and storage limits. In production, add a LIMIT with pagination, and prune or archive old rows.
Broadcast new messages
sendMessage saves the message, then pushes it to everyone in the room:
import { actor, event, setup } from "rivetkit";
import { db } from "rivetkit/db";
export type Message = { sender: string; text: string; timestamp: number };
export const chatRoom = actor({
// Persist chat history in the actor's SQLite database: https://rivet.dev/docs/actors/sqlite
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
text TEXT NOT NULL,
timestamp INTEGER NOT NULL
)
`);
},
}),
events: {
newMessage: event<Message>(),
},
actions: {
// Callable functions from clients: https://rivet.dev/docs/actors/actions
sendMessage: async (c, sender: string, text: string) => {
const message: Message = { sender, text, timestamp: Date.now() };
await c.db.execute(
"INSERT INTO messages (sender, text, timestamp) VALUES (?, ?, ?)",
sender,
text,
message.timestamp,
);
// Send events to all connected clients: https://rivet.dev/docs/actors/events
c.broadcast("newMessage", message);
return message;
},
getHistory: async (c) => {
const rows = await c.db.execute(
"SELECT sender, text, timestamp FROM messages ORDER BY id ASC",
);
return rows as Message[];
},
},
});
// Register actors for use: https://rivet.dev/docs/setup
export const registry = setup({
use: { chatRoom },
});
// Start the server on port 6420
registry.start();
eventsdeclaresnewMessageas a typed event carrying aMessage. The React client gets the same payload type throughtypeof registry.- The action sets
timestampwithDate.now()on the server and lets SQLite assign theid. Never accept timestamps or ids from the client. - The insert uses
?placeholders, so user text never ends up inside the SQL string. Keep every query parameterized. - The insert finishes before
c.broadcast("newMessage", message), so a message that other clients have seen is already saved. The broadcast reaches every connection in the room, including the sender, and the action also returns the message to the caller. c.broadcastis for messages the whole room should see. For private payloads, such as a direct message inside a room, send to a single connection withconn.sendinstead.
The example has no authentication. Any client can join any room by knowing its name, and sender is a plain action argument, so anyone can post as anyone. In production, check a token in onBeforeConnect, keep the user’s identity in connection state, check room membership before serving history, and read the sender from the connection instead of the arguments.
sendMessage accepts empty text and text of any length, and any client can call it as often as it likes. Every call is saved and sent to every connection, so one spammer costs the whole room. In production, trim the text on the server, reject empty messages, cap the length, and rate limit each connection.
Connect the React client
The React client connects with useActor, loads the history once, then appends every newMessage:
const { useActor } = createRivetKit<typeof registry>("http://localhost:6420");
export function App() {
const [roomId, setRoomId] = useState("general");
const [username, setUsername] = useState("User");
const [input, setInput] = useState("");
const [messages, setMessages] = useState<Message[]>([]);
const chatRoom = useActor({
name: "chatRoom",
key: [roomId],
});
useEffect(() => {
if (chatRoom.connection) {
chatRoom.connection.getHistory().then(setMessages);
}
}, [chatRoom.connection]);
chatRoom.useEvent("newMessage", (message: Message) => {
setMessages((prev) => [...prev, message]);
});
const sendMessage = async () => {
if (chatRoom.connection && input.trim()) {
await chatRoom.connection.sendMessage(username, input);
setInput("");
}
};
createRivetKit<typeof registry>returns hooks typed against the backend, so action arguments and event payloads are checked. See the React client.useActor({ name: "chatRoom", key: [roomId] })connects to the room’s Actor and creates it if it does not exist yet. The room input defaults togeneral. Changing it connects to a different Actor with its own history.- Once
chatRoom.connectionis ready, the client callsgetHistoryonce to render the backlog. After that,useEvent("newMessage", ...)appends each new message as it arrives. - The sender sees its own message through the same broadcast as everyone else, so there is only one code path for rendering messages.
sendMessageonly waits for the action and clears the input.
Try it
git clone https://github.com/rivet-dev/rivet.git
cd rivet/examples/chat-room
pnpm install
pnpm dev
Open http://localhost:5173 in two windows and give each a different username. Send a message from one window and it appears in both. Reload, and the history is still there. Change the room name to get an empty room of your own.
Extend it
- Typing indicators and presence: keep the username and a typing flag in connection state, never in SQLite, so they disappear with the connection. Broadcast a typing event only when a user starts or stops typing, broadcast join and leave from
onConnectandonDisconnect, and clear a typing indicator on the client after a short timeout so a dropped connection never leaves it stuck. - Per-user inbox: for offline delivery, unread counts, or notifications, add a
userInboxActor keyed by user id. The room forwards each message to every member’s inbox with actor-to-actor calls, and the inbox keeps the unread state. A direct message can be achatRoomkeyed by the sorted pair of user ids.
Next steps
- SQLite: queries, transactions, and migrations in the Actor’s database.
- Live Cursors and Presence: connection state and
onDisconnectfor showing who is in a room. - Permissions: check who connects and what they may call.