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. For React Native, use the native app tooling requirements.

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 for Browser, Node, Kotlin, and Swift. React Native uses 8.0.0 with the same 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, Node, and React Native 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.
  • The Browser, Node, Kotlin, and Swift examples discard their generated keys and in-memory databases when the process ends. Each run is a new inbox. The React Native example uses the app’s saved signer, database key, and default native storage. Reuse them between launches.
  • A persistent 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