FAQ & Troubleshooting
The questions and failure modes that come up most, with direct answers.
Integration
Three usual suspects, in order:
- Your
authhook returns the wrong shape. It must return{ id: string }ornull- a bare string or{ userId: ... }is treated as unauthenticated. The 401 body'smessagenames the exact failure. - The cookie never reaches the server. Check the Network tab: is the
cookie on
/api/chat/*requests? If your app renders inside a cross-site iframe (every AI-builder preview pane),SameSite=Laxcookies are silently dropped - setSameSite=None; Secure; Partitioned. See Authentication. - You're parsing cookies with a helper that doesn't exist. The hook
receives a raw
Request- there's norequest.cookies; parserequest.headers.get("cookie")yourself.
Your route file isn't a catch-all. Chatpack serves many sub-paths under
basePath - in Next.js the file must be
app/api/chat/[...chatpack]/route.ts, in TanStack Start api/chat.$.ts, in
Hono/Elysia /api/chat/*. Note auth runs before routing, so fix any 401
first; a lingering 404 NOT_FOUND then means the mount path or basePath is
wrong.
No - never hand-write message or stream routes, and never call the storage
adapter directly. The one handler already serves every route; custom routes
split state and break live delivery. From server code, use chat.api.*.
Dev-server HMR (next dev, Vite) re-evaluates modules, creating a fresh
chatpack() instance and a fresh memory adapter. Guard the instance with
globalThis:
const g = globalThis as typeof globalThis & { __chatpack__?: ChatpackInstance };
export const chat = (g.__chatpack__ ??= chatpack({ storage, auth }));No. Chatpack never owns a users table. The auth hook resolves the acting user.
The optional userExists(userId) hook validates new direct-chat targets and
group participants against your identity store. Omitting it preserves opaque-id
behavior.
Real-time
One EventSource gives you automatic reconnection, automatic Last-Event-ID
resumption, cookie auth for free, and plain HTTP that passes every proxy - no
socket server, no reconnect code. Chatpack replays missed messages from
storage on reconnect (durable-first), which is the hard part WebSockets don't
solve for you either.
Common causes:
- Listening for
messageinstead of the named eventmessage.created- useaddEventListener("message.created", ...). - Forgetting that the listener's parameter needs a cast in TypeScript:
(e as MessageEvent).data. - The stream authenticated as a different user than the sender's counterpart - events are only delivered for conversations the connected user participates in.
Not the stream - and a transport swap doesn't fix it. The blocker on serverless is function lifetime, not fan-out: the isolate stops running, so there's no connection left to push to. Use a database adapter and poll there.
Live-ish updates still work with no frontend change:
@chatpack/client falls back to polling
when the stream can't be held. Typing, presence and receipts are the part you
genuinely lose - they're ephemeral and never stored, so there is nothing to poll
for.
On several long-lived servers (2+ containers behind a load balancer), SSE
does work once you swap in
@chatpack/transport-redis - same public API, no
code change beyond the transport option. See
Deployment.
For a new app that needs live chat, Railway is the easiest default for the Chatpack API because it runs as a long-lived Node service. Render, Fly.io, and a conventional server or VM are also options. The frontend can stay on Vercel while the API runs on one of these hosts. Before calling realtime ready, test message delivery between two authenticated users in separate sessions.
Mostly by design: ephemeral events are fire-and-forget - never stored, never
replayed. Client conventions make them feel right: throttle typing POSTs to
~1 per 3s and expire the indicator after ~5s of silence; dedupe receipt ticks
by payload.messageId; treat lastReadMessageId as the durable truth.
By design. Reactions are stored, but they have no seq, so their SSE frames
carry no id: (emitting one would rewind Last-Event-ID and replay messages)
and Last-Event-ID gap-fill can't replay them. Refetch the conversation when
the stream reopens - @chatpack/client users get the reactions on the next
messages.list. Details in
Real-time with SSE.
Reaction writes are idempotent: (messageId, userId, emoji) is unique, so
reacting again is a no-op and un-reacting something you never reacted to is
also a no-op - neither is an error. Both routes return the message with its
complete reaction set, so replace that field in your UI rather than
merging into it.
Storage
Use the first-party @chatpack/adapter-supabase
adapter. Apply its migration, create the adapter with a server-only Supabase
client using the service-role key, and never expose that client or key to
browser code. The migration owns the Chatpack tables, RLS, and transaction-
sensitive RPCs.
Officially: any Postgres reachable by a Drizzle driver (node-postgres,
postgres.js, PGlite, Neon, Vercel Postgres). Anything else - MySQL, SQLite,
Convex, Firestore, DynamoDB - via the StorageAdapter interface; the
custom adapter guide has the invariants,
reference schema, and a verification checklist.
They're soft-deleted: the row remains with body: "" and deletedAt set,
keeping its seq so ordering and pagination stay stable. Clients render a
tombstone. If you need hard deletion for compliance, run it directly against
your database on your own schedule.
Recent releases added columns and tables: reactions and quote-replies brought
chatpack_message_reactions and reply_to_message_id; groups brought
type + name on chatpack_conversations, role on
chatpack_participants, and made pair_key nullable with a partial unique
index. Re-run the migration before deploying the upgrade - every statement
is IF NOT EXISTS / ADD COLUMN IF NOT EXISTS, so re-running the whole script
is safe and preserves your data and seq counters. It also backfills existing
rows (type: "direct", every current participant to admin, which is a no-op
for DMs since canManage only gates group routes).
Custom adapters need the group methods too - createGroupConversation,
updateConversation, addParticipants, removeParticipant,
setParticipantRole - alongside the earlier reaction ones; see the
custom adapter guide.
Project
The engine is tested (core against the memory adapter, the Postgres adapter
against real Postgres via PGlite, concurrency invariants included), but the
project is 0.x - the API may take minor breaking changes before 1.0. Pin
versions and read changelogs when upgrading.
File attachments ship as the optional
@chatpack/file plugin, backed by
Filepack. Prefer plain metadata
pointers if you already host files elsewhere.
Chatpack does not ship push notification providers or reusable React UI
components. Applications can start notification delivery from
afterMessageMutation, and @chatpack/client/react provides headless hooks.
Reactions, quote-replies, and group chats did ship - each fit the existing
seams. See Roadmap.
Groups ship: createGroupConversation, a first-class name, admin/member
roles, admin-gated membership management, and 1-256 participants
(Conversations & messages).
A group is a type on Conversation, not a second entity, so messages,
permissions, read-state, search, reactions and SSE all work unchanged.
Invite links and join requests ship too, as an optional storage capability
(Invites & join requests):
an admin mints a code with an optional expiry and use cap, anyone holding it
redeems it, and an outsider who has no code can ask instead - a pending
request an admin approves or denies. Adapters that skip the capability answer
501 INVITES_UNSUPPORTED; both first-party adapters implement it.
Public channels ship as a third way in, behind a second optional capability
(Channel routes). A channel is just a
group with visibility: "public": it appears in GET /channels, a browsable
directory any signed-in user can read, and each channel's joinPolicy decides
whether joining is instant or lands in the same approval queue invites use.
Discoverable is not readable - browsing hands out a name and a participant
count, never member ids or messages, so reading a channel still means joining
it. Adapters that skip the capability answer 501 CHANNELS_UNSUPPORTED.
What isn't there: per-group permission grants and roles beyond
admin/member. Chatpack owns membership, roles, and the three ways in; the
social layer around them stays your app's.
Mentions ship, but as validation, not parsing. You pass mentions: ["user_1"] alongside body on send or edit, and Chatpack stores that set and
returns it on every message. It never reads the text looking for @: there is no
users table to resolve a name against, and body stays opaque
(ADR 0022). So your composer's picker supplies the
ids, and body and mentions can legitimately disagree - keeping them in step is
your app's job.
What Chatpack does guarantee is that a mention is real. Every id must be a
current participant, or the whole call fails with
400 MENTION_NOT_PARTICIPANT - never a silent drop, because a drop nobody sees
looks exactly like a notification that fired. On edit, omitting mentions leaves
the stored set alone (so a mentions-unaware client can't erase them) and []
clears it; an id that's already stored stays valid even after that person leaves,
so fixing a typo doesn't have to drop a mention that was legitimate when it was
made. Read the array as a set - it comes back sorted, not in the order you
sent it.
Chatpack core does not provide a general notification service or mention inbox.
It stores mentions and stops. afterMessageMutation gives your app mentions
and recipientIds for its own email or push integration. The Chatpack app
includes a separate thread inbox and alert-delivery example.
Yes - and it's a copy, not a live pointer. POST /messages/:id/forward with
{ conversationId } writes a new message into that conversation: the body
verbatim, the forwarder as sender (they're the one speaking there), its own id and
its own seq, counting toward unread like any other message. Editing or deleting
the original afterwards changes nothing about the copy. You need read access to
the source and write access to the target, and a deleted message can't be
forwarded (409 MESSAGE_DELETED).
The copy carries forwardedFrom: { messageId, conversationId, senderId },
frozen at forward time and naming only the immediate source - one hop, like
replies, so a chain doesn't accumulate. Deliberately no excerpt and no source
conversation name: the people reading the copy may have no access to where it
came from, and a live field would let them watch a conversation they were never in.
Reactions, the reply pointer, mentions, metadata and role don't travel either -
pass fresh ones in the forward body if you want them. See
ADR 0024.
replyToMessageId makes a quote reply in the main conversation. Set
threads: { enabled: true } on the Chatpack installation and send with
threadRootMessageId to put a reply in a thread. Apply the storage adapter's
thread migration first. Thread replies have their own page and count. A sender
can also set alsoSendToMain: true to show the same reply in the main
conversation. Chatpack provides the thread API; your app owns the thread UI,
inbox, and alert delivery. See Message threads
for migration steps and the app example.
Aggregate counter deltas (messagesSent, conversationsCreated), the library
version, and a random per-process id - at most twice a day. Never message
bodies, user ids, conversation ids, or hostnames. Opt out with
telemetry: false or CHATPACK_TELEMETRY=0. See
Telemetry.
Discord is the fastest - the maintainers are in there, and "I got stuck installing this" is exactly the kind of report the community exists for. Longer questions and show-and-tell go in GitHub Discussions, bugs and feature requests in Issues, and releases are announced on X.