Chatpack

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-memory

Bun 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 react
import { 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

lib/chat.ts
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).

app/api/chat/[...chatpack]/route.ts
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:

lib/chat.ts
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.

Next steps

On this page