Skip to content

Build a quickstart chat app

Follow this tutorial to run a local XMTP backend and send a message from one client to another.

Each client uses a key pair to create an identity and register it with the backend. This gives the client an inbox ID, which is a stable identifier that other clients send messages to.

You need Docker with Docker Compose. For Browser and Node, you also need Node.js 22.18.0 or later.

Run these commands in a terminal. They create a project directory, download the Compose file, and start PostgreSQL and the XMTP backend:

Terminal window
mkdir xmtp-quickstart && cd xmtp-quickstart
curl -fsSL https://xmtp.org/quickstart/compose.yaml -o compose.yaml
docker compose up -d --wait

The last command returns when the backend is healthy. The backend URL is http://127.0.0.1:5050. Use 127.0.0.1, not localhost: some systems resolve localhost to an IPv6 address that Docker does not forward.

The Compose file
compose.yaml
# Local XMTP backend for the quickstart. Do not use it in production:
# it has no authentication and a fixed database password.
#
# docker compose up -d --wait start, and wait until the backend is healthy
# docker compose logs backend show the backend logs
# docker compose down stop, and keep the data
# docker compose down -v stop, and delete the data
#
# Clients connect to http://127.0.0.1:5050.
name: xmtp-quickstart
services:
db:
image: postgres:18.6
environment:
POSTGRES_USER: xmtp
POSTGRES_PASSWORD: xmtp
POSTGRES_DB: xmtp
volumes:
- db-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U xmtp -d xmtp"]
interval: 1s
timeout: 3s
retries: 30
backend:
image: ghcr.io/xmtp/backend:8.0.0-rc2
depends_on:
db:
condition: service_healthy
environment:
XMTP_DATABASE_URL: postgres://xmtp:xmtp@db:5432/xmtp
XMTP_CONFIG: |
[server]
identifier = "local.xmtp.quickstart"
[database]
url = "env:XMTP_DATABASE_URL"
ports:
# Loopback only. Change to "5050:5050" to reach it from another device.
- "127.0.0.1:5050:5050"
healthcheck:
test: ["CMD", "/bin/grpc-health-probe", "-addr=127.0.0.1:5050"]
interval: 1s
timeout: 5s
retries: 30
volumes:
db-data:

This backend has no authentication. Do not expose it outside your computer. See Run the backend for a production configuration.

Use SDK 8.0.0-rc2, which matches the backend image. See Install the XMTP SDK for other package managers.

Create a Vite app in the xmtp-quickstart directory and install the SDK:

Terminal window
npm create vite@latest web -- --template vanilla-ts --no-interactive
cd web
npm install
npm install --save-exact @xmtp/browser-sdk@8.0.0-rc2

The Browser and Node examples come from complete source files. They are checked against the SDK types during the docs build.

Replace the contents of src/main.ts with this code. Each page load creates a new client. A page opened with ?to=<inbox-id> sends one message to that inbox.

src/main.ts
import {
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
} from "@xmtp/browser-sdk";
const
const log: (line: string) => void
log
= (
line: string
line
: string) => {
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
Document.body: HTMLElement

The Document.body property represents the or node of the current document, or null if no such element exists.

MDN Reference

body
.
ParentNode.append(...nodes: (Node | string)[]): void

Inserts nodes after the last child of node, while replacing strings in nodes with equivalent Text nodes.

Throws a "HierarchyRequestError" DOMException if the constraints of the node tree are violated.

MDN Reference

append
(
line: string
line
,
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
Document.createElement<"br">(tagName: "br", options?: ElementCreationOptions): HTMLBRElement (+2 overloads)

In an HTML document, the document.createElement() method creates the HTML element specified by localName, or an HTMLUnknownElement if localName isn't recognized.

MDN Reference

createElement
("br"));
};
// A new identity and an in-memory database for each page load.
const
const client: Client
client
= await
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
.
Client.create(signer: Signer, options: ClientOptions): Promise<Client>
create
(await
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
.
ClientMembers.generateKeys(): Promise<LocalSigningKey>
generateKeys
(), {
backend?: BackendSource | undefined
backend
: {
url: string
url
: "http://127.0.0.1:5050" },
storage: StorageOptions
storage
: {
location: StorageLocation
location
: "inMemory" },
});
const log: (line: string) => void
log
(`Your inbox ID: ${
const client: Client
client
.
ClientMembers.inboxId: string
inboxId
}`);
const log: (line: string) => void
log
(`To message this tab, open ${
var location: Location

The read-only location property of the Window interface returns a Location object with information about the current location of the document.

MDN Reference

location
.
Location.origin: string

The origin read-only property of the Location interface returns a string containing the Unicode serialization of the origin of the location's URL.

MDN Reference

origin
}/?to=${
const client: Client
client
.
ClientMembers.inboxId: string
inboxId
}`);
// With ?to=<inbox-id>, send one message to that inbox.
const
const to: string | null
to
= new
var URLSearchParams: new (init?: string[][] | Record<string, string> | string | URLSearchParams) => URLSearchParams

The URLSearchParams interface defines utility methods to work with the query string of a URL.

MDN Reference

URLSearchParams
(
var location: Location

The read-only location property of the Window interface returns a Location object with information about the current location of the document.

MDN Reference

location
.
Location.search: string

The search property of the Location interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the location's URL. If the URL does not have a search query, this property contains an empty string, "".

MDN Reference

search
).
URLSearchParams.get(name: string): string | null

The get() method of the URLSearchParams interface returns the first value associated to the given search parameter.

MDN Reference

get
("to");
if (
const to: string | null
to
) {
const
const dm: Dm
dm
= await
const client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.createDm(peer: InboxId | PublicIdentity, options?: CreateDmOptions): Promise<Dm>
createDm
(
const to: string
to
);
await
const dm: Dm
dm
.
Dm.sendText(text: string, options?: SendOptions): Promise<MessageId>
sendText
("Hello from the browser");
}
// The stream also finds new conversations.
for await (const
const message: Message
message
of
const client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.streamAllMessages(options?: MessageStreamOptions): MessageStream
streamAllMessages
()) {
if (
const message: Message
message
.
Message.content: MessageContent
content
.
kind: "text" | "markdown" | "readReceipt" | "reaction" | "attachment" | "remoteAttachment" | "multiRemoteAttachment" | "transactionReference" | "walletSendCalls" | "actions" | "intent" | "groupUpdated" | "leaveRequest" | "deletedMessage" | "reply" | "custom" | "unknown"
kind
=== "text") {
const
const sender: "You" | "Them"
sender
=
const message: Message
message
.
MessageFields.senderInboxId: string
senderInboxId
===
const client: Client
client
.
ClientMembers.inboxId: string
inboxId
? "You" : "Them";
const log: (line: string) => void
log
(`${
const sender: "You" | "Them"
sender
}: ${
const message: Message
message
.
Message.content: {
readonly kind: "text";
readonly value: string;
}
content
.
value: string
value
}`);
}
}

Start the development server:

Terminal window
npm run dev
  1. Open the URL that Vite prints, such as http://localhost:5173/. The page shows its inbox ID.
  2. Open the ?to= link from the first page in a second tab. The second tab sends Hello from the browser.
  3. The first tab shows Them: Hello from the browser.
  • streamAllMessages() delivers messages from every conversation, including conversations that start after the stream opens. By default, it includes allowed and unknown conversations. A conversation that another inbox starts is unknown until you allow it. See Consent.
  • When the stream opens, it first delivers the messages that arrived while the client was offline.
  • These examples use randomly generated keys that get thrown away as soon as the process ends, and in-memory databases. Each run is a new inbox. A real app must save and restore its signing key and keep the same database between launches. In Node, pass a 32-byte storage.encryptionKey to encrypt the database.

Run these commands in the xmtp-quickstart directory:

Terminal window
docker compose down # stop, and keep the data
docker compose down -v # stop, and delete the data