Quickstart
A working chat backend - with live delivery - in five minutes.
This walks you from npm install to a live, real-time chat backend. Every step
works as written; swap the demo pieces (memory storage, cookie auth) for
production pieces when you're ready.
Prefer learning from a complete app?
examples/messenger is a
full 1:1 messenger - sidebar, live messages, read receipts - in vanilla HTML+JS with a
step-by-step tutorial README.
Install
Both packages are needed - @chatpack/core is the engine,
@chatpack/adapter-memory is the storage it plugs into:
npm install @chatpack/core @chatpack/adapter-memoryBun note: if Bun's supply-chain guard (minimumReleaseAge) is enabled, versions published in
the last 24h are skipped and Bun silently resolves an older release. Check with npm view @chatpack/core dist-tags.
Add the client (optional)
For a browser or React frontend, add the first-party client:
npm install @chatpack/client reactimport { createChatClient } from "@chatpack/client/react";
export const chatClient = createChatClient({
// Omit baseURL when the frontend and handler share an origin.
credentials: "include",
});The client uses the server's existing cookie session. It does not implement login, logout, user lookup, or token handling. See the client guide for REST, realtime, React, and plugin usage.
Create your chat server
import { chatpack } from "@chatpack/core";
import { memoryAdapter } from "@chatpack/adapter-memory";
export const chat = chatpack({
storage: memoryAdapter(),
// resolve the current user from a request - the ONLY auth touchpoint.
auth: async (req) => {
const session = await getSessionFromCookie(req.headers.get("cookie"));
return session ? { id: session.userId } : null;
},
});The auth hook must return ChatpackUser | null - an object with at least
{ id: string } (extra fields are allowed and ignored), or null for
unauthenticated requests. Returning a bare string is treated as unauthenticated
and every request will get a 401.
Prefer cookie-based sessions over Authorization headers: the browser sends cookies
automatically on every request - including the SSE stream in step 5, where custom headers are
impossible. See Authentication for demo cookies, iframe-proof
attributes, and hybrid bearer-token setups.
The hook receives a raw Web-standard Request - there is no request.cookies
helper. A minimal demo auth that parses the cookie header itself:
// demo auth: a plain cookie naming the user (swap for your auth library)
auth: (request) => {
const cookie = request.headers.get("cookie") ?? "";
const id = /(?:^|;\s*)demo_user=([^;]+)/.exec(cookie)?.[1] ?? null;
return id ? { id: decodeURIComponent(id) } : null;
},Mount the API
The route file must be a catch-all ([...chatpack] in Next.js) - Chatpack
serves many sub-paths under basePath (default /api/chat).
import { chat } from "@/lib/chat";
export const { GET, POST, PATCH, DELETE, PUT } = chat.handler();Never hand-write your own message or stream routes. The one handler already serves every route
- conversations, messages, read-state, plugins, and the SSE stream. Custom
/api/messages-style routes split state and break live delivery.
Your chat backend is now live at /api/chat. More frameworks (TanStack Start,
Express, plain Node) in Framework guides.
Call it over HTTP
Find-or-create a conversation (the authenticated user + otherUserId):
curl -X POST /api/chat/conversations \
-H 'content-type: application/json' \
-d '{"otherUserId": "bob"}'{
"conversation": {
"id": "conv_1",
"type": "direct",
"pairKey": "alice:bob",
"name": null,
"createdAt": "2026-07-22T19:47:47.945Z",
"metadata": {},
"participants": [
{
"conversationId": "conv_1",
"userId": "alice",
"role": "admin",
"joinedAt": "…",
"lastReadMessageId": null
},
{
"conversationId": "conv_1",
"userId": "bob",
"role": "admin",
"joinedAt": "…",
"lastReadMessageId": null
}
]
}
}A group uses a different route - and always creates, since two groups with the same members are still two groups:
curl -X POST /api/chat/conversations/group \
-H 'content-type: application/json' \
-d '{"name": "Standup", "userIds": ["bob", "carol"]}'The caller becomes an admin, everyone in userIds a member. Renaming and
membership changes are four admin-only routes - see
Groups.
Send a message - note the text field is body:
curl -X POST /api/chat/conversations/conv_1/messages \
-H 'content-type: application/json' \
-d '{"body": "hey bob!"}'List history (newest first, keyset-paginated):
curl '/api/chat/conversations/conv_1/messages?limit=50'Chatpack never owns a users table. Configure userExists(userId) to validate direct-chat
targets and new group participants against your identity store. Omitting the optional hook keeps
opaque-id behavior.
Go live in the browser
const events = new EventSource("/api/chat/stream");
// TypeScript: custom event names fall outside EventSourceEventMap, so the
// listener parameter is typed `Event` - cast to MessageEvent for `.data`.
events.addEventListener("message.created", (e) => {
const { message } = JSON.parse((e as MessageEvent).data);
// render it - reconnection & missed-message backfill are automatic
});
events.addEventListener("reaction.added", (e) => {
const { message } = JSON.parse((e as MessageEvent).data);
// message.reactions is the COMPLETE set after the change - replace, don't merge
});If the connection drops, EventSource reconnects with Last-Event-ID and
Chatpack replays whatever was missed from storage - durable-first delivery,
no lost messages. Reactions are the exception: they have no seq, so they're
live-only and show up on the next refetch instead. Details in
Real-time with SSE.
Or call it straight from server code
// find-or-create a 1:1 conversation between two users
const conversation = await chat.api.getOrCreateConversation({
userId: "alice",
otherUserId: "bob",
});
// send a message
await chat.api.sendMessage({
userId: "alice",
conversationId: conversation.id,
body: "hey bob!",
});
// read the history
const { messages } = await chat.api.listMessages({
userId: "bob",
conversationId: conversation.id,
});
// quote-reply, and react (idempotent - returns the full reaction set)
await chat.api.sendMessage({
userId: "bob",
conversationId: conversation.id,
body: "hey alice!",
replyToMessageId: messages[0].id,
});
await chat.api.addReaction({ userId: "bob", messageId: messages[0].id, emoji: "👍" });
// mention by id (Chatpack never parses the text), and forward into another thread
await chat.api.sendMessage({
userId: "bob",
conversationId: conversation.id,
body: "@alice ship it",
mentions: ["alice"],
});
await chat.api.forwardMessage({
userId: "bob",
messageId: messages[0].id,
toConversationId: "conv_2", // any conversation bob can write to
});That's it. Only the two participants can read or write - enforced by default,
customizable via the permissions hooks.
Going to production
Swap the storage line for Postgres - everything else stays the same:
import { drizzle } from "drizzle-orm/node-postgres";
import { drizzleAdapter } from "@chatpack/adapter-drizzle";
export const chat = chatpack({
storage: drizzleAdapter(drizzle(process.env.DATABASE_URL!)),
auth: async (req) => getSessionUser(req),
});See Drizzle / Postgres for table creation and serverless drivers, and Deployment for where SSE and the memory adapter do (and don't) work.