# Dendri — full documentation
> Open-source, self-hostable WebRTC signaling stack for realtime collaboration apps.
> This file inlines every page of https://dendri.dev/docs/ as markdown.
> Index: https://dendri.dev/llms.txt
---
# Introduction
Dendri adds realtime collaboration to any web app (presence, live cursors, chat, video, and CRDT document sync) using peer-to-peer WebRTC instead of a message broker you pay per event.
## What is Dendri?
Dendri is a small signaling service plus a TypeScript SDK. The server's only job is to introduce peers to each other and broker the WebRTC handshake. Once two browsers are connected, data flows *directly between them*: the server is out of the data path, which is why there is no per-message billing and no realtime backend for you to scale.
The project ships as three parts:
| Package | Version | License | What it is |
| --- | --- | --- | --- |
| `@afterrealism/dendri-client` | 2.6.x | Apache-2.0 | Framework-neutral browser/Node SDK: rooms, presence, topics, RPC, media calls, and adapters for React, Vue, and Svelte. |
| `@afterrealism/dendri-y` | 0.1.x | Apache-2.0 | Yjs CRDT provider that syncs a `Y.Doc` (and awareness) over a Dendri room. |
| `dendri-server` | Rust | AGPL-3.0 | Axum/Tokio signaling server with WebSocket, SSE, and long-poll transports, TURN credential minting, and an optional encrypted relay. |
> **Note:** **Bring your own endpoint.** The SDK never connects anywhere by default. Every app passes an explicit server URL: your self-hosted instance or the hosted tier. If the URL is missing, connection fails loudly instead of silently phoning home.
## How it works
When your app calls `join("room-name")`, the SDK connects to the signaling server, discovers the other peers in the room, and negotiates direct WebRTC connections with them. Rooms use a **star topology**: the first peer to join becomes the *host*, later peers connect to the host, and if the host disconnects a deterministic election promotes a new one (host migration), no coordination service required.
Real networks are hostile, so connectivity degrades through four tiers automatically:
| Tier | Path | When it's used |
| --- | --- | --- |
| 1 | Direct P2P | Same network, or open NAT. Lowest latency, zero server involvement. |
| 2 | STUN | Peers behind NAT discover their public addresses and hole-punch. |
| 3 | TURN | Symmetric NAT or UDP blocked; traffic relays through a TURN server (e.g. coturn). |
| 4 | TLS relay | Everything else (strict VPNs, corporate proxies); messages relay through the signaling server, end-to-end encrypted with ECDH + AES-256-GCM so the server can't read them. |
The signaling connection itself also falls back: WebSocket first, then Server-Sent Events, then HTTP long-poll, so the handshake works even behind proxies that kill WebSockets.
## Core concepts
### Peers
A `Dendri` instance is one peer: a stable identity (peer ID) plus a signaling connection. Peers open **data connections** to exchange arbitrary payloads and **media connections** for audio/video. You rarely manage peers by hand (rooms and the store do it for you), but the low-level API is always available.
### Rooms
A `Room` groups peers under a shared name and manages the star topology, peer discovery, reconnection, and host migration. Everything higher-level (presence, topics, RPC) hangs off a room.
### Presence
Each peer can publish a small state object ("Ana, editing slide 3, cursor at…") that is broadcast to the room and kept in sync. Presence answers "who's here and what are they doing" without you building any plumbing.
### Topics
Pub/sub channels inside a room. Broadcast with a `topic` tag and only subscribers to that topic receive it, so chat messages, cursor updates, and game state can share one room without interfering.
### RPC
Request/response over data channels. Register a named method on one peer, call it from another, and get a promise back, with timeouts and typed errors. Useful for host-authoritative logic in games and tools.
### The store
`createDendriStore()` wraps a room in a framework-agnostic reactive store (`subscribe` / `getSnapshot`), with ready-made bindings for React, Vue, and Svelte. It's the recommended entry point for UI apps; see the [framework guides](https://dendri.dev/docs/frameworks/).
## Explore the docs
- [Quickstart](https://dendri.dev/docs/quickstart/) — Run a server, install the SDK, and ship a working P2P chat in about ten minutes.
- [Configuration](https://dendri.dev/docs/configuration/) — Every client option: URLs, transports, TURN, relay, JWT auth, and debugging.
- [Framework guides](https://dendri.dev/docs/frameworks/) — Complete React, Vue, Svelte, and vanilla JS integrations with the official adapters.
- [Building the examples](https://dendri.dev/docs/examples/) — Walkthroughs of chat, live cursors, presence, whiteboard, video, files, and a game.
- [Collaborative editing](https://dendri.dev/docs/collaboration/) — Sync Yjs documents and awareness over a room with `@afterrealism/dendri-y`.
- [API reference](https://dendri.dev/docs/api/) — Classes, methods, events, and types for the client SDK and the Yjs provider.
- [Self-hosting](https://dendri.dev/docs/self-hosting/) — Run the open-source Rust server with Docker/Podman, TLS, TURN, and Redis.
- [AI & agents](https://dendri.dev/docs/ai/) — llms.txt, markdown docs, and the MCP server for Claude Code, opencode, Cursor, and VS Code.
## Licensing at a glance
The client SDKs (`dendri-client`, `dendri-y`) are **Apache-2.0**: embed them in anything, commercial or not. The signaling server is **AGPL-3.0**: run it freely, but if you offer it as a network service with modifications, you must publish those modifications. A commercial license is available from Afterrealism for closed-source hosted use. Details in [Self-hosting → Licensing](https://dendri.dev/docs/self-hosting/#licensing).
Source: https://dendri.dev/docs/
---
# Quickstart
From zero to a working peer-to-peer chat in four steps: get a server endpoint, install the SDK, wire up a room, and open two tabs.
## Prerequisites
- **Node.js 18+** and a package manager (npm, pnpm, or bun).
- A **Dendri server endpoint**: hosted or local (step 1 covers both).
- A modern browser. WebRTC is supported everywhere Dendri runs.
## Step 1: Get a server endpoint
The SDK always connects to an endpoint *you* provide. Pick one of two paths:
### Option A: Hosted tier
Grab a plan on the [pricing page](https://dendri.dev/#pricing); your API key arrives by email and can be managed in the [dashboard](https://app.dendri.dev/). You'll connect with:
```ts title="hosted endpoint"
const store = createDendriStore({
url: "wss://signal.dendri.dev",
apiKey: "dk_your_key",
});
```
### Option B: Local server (self-hosted)
The open-source server is a single Rust binary. It uses Redis for state, so start both containers:
```bash title="terminal"
# 1. Redis (state backend)
podman run -d --name dendri-redis -p 6379:6379 docker.io/redis:7
# 2. The signaling server on port 9876
podman run -d --name dendri --network host \
ghcr.io/afterrealism/dendri-server:latest \
--host 127.0.0.1 --port 9876 \
--redis_url redis://127.0.0.1:6379 \
--enable_relay --allow_discovery
```
Or run it from source with Cargo (Redis still required, default `redis://127.0.0.1:6379`):
```bash title="terminal"
git clone https://github.com/afterrealism/dendri-server
cd dendri-server
cargo run -- --host 127.0.0.1 --port 9876 --enable_relay --allow_discovery
```
Verify it's alive: `curl http://127.0.0.1:9876/health`. Full flags, TLS, and TURN are covered in [Self-hosting](https://dendri.dev/docs/self-hosting/).
## Step 2: Install the SDK
```bash title="terminal"
npm install @afterrealism/dendri-client
```
Starting a project from scratch? A Vite vanilla template is the fastest sandbox:
```bash title="terminal"
npm create vite@latest dendri-chat -- --template vanilla
cd dendri-chat
npm install @afterrealism/dendri-client
npm run dev
```
## Step 3: Build the chat
Replace the generated `index.html` and `main.js` with the following.
```html title="index.html"
Dendri chat
Status: connecting… ·
```
```js title="main.js"
import { createDendriStore } from "@afterrealism/dendri-client";
// Point at YOUR endpoint. Hosted: url + apiKey. Local: the server from step 1.
const store = createDendriStore({
url: "ws://127.0.0.1:9876",
fetchTurnCredentials: true, // use the server's TURN credentials if configured
enableRelay: true, // tier-4 encrypted relay fallback for hostile networks
});
const status = document.querySelector("#status");
const peers = document.querySelector("#peers");
const log = document.querySelector("#log");
const form = document.querySelector("#form");
const input = document.querySelector("#input");
// 1. React to connection / peer changes.
store.subscribe(() => {
const s = store.getSnapshot();
status.textContent = s.connectionState;
peers.textContent = `${s.peerCount} peer(s) — you are ${s.isHost ? "host" : "client"}`;
});
// 2. Receive messages published on the "chat" topic.
store.subscribe("chat", (data, peerId) => {
append(peerId.slice(0, 6), data.text);
});
// 3. Send messages to everyone in the room.
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
store.broadcast({ text }, { topic: "chat" });
append("me", text);
input.value = "";
});
function append(from, text) {
const li = document.createElement("li");
li.textContent = `${from}: ${text}`;
log.appendChild(li);
}
// 4. Join the room. First tab becomes host; later tabs connect to it.
store.join("quickstart-room");
```
## Step 4: Run it
Start the dev server (`npm run dev`) and open `http://localhost:5173` in **two browser tabs**. Each tab is an independent peer. Type in one tab and the message appears in the other, delivered directly over a WebRTC data channel.
### What just happened?
1. Each tab connected to your signaling server and got a peer ID.
2. `join("quickstart-room")` made the first tab the **host**; the second tab discovered it and opened a direct data connection.
3. `broadcast(…, { topic: "chat" })` fanned the message out to every peer subscribed to `"chat"`.
4. If WebRTC had been blocked (VPN, strict NAT), delivery would have fallen back to TURN or the encrypted relay automatically.
> **Tip:** **Close the tab, watch the host migrate.** Open three tabs, close the first one (the host), and watch the `isHost` flag flip in another tab: deterministic host migration with no server-side coordination.
## No build step? Use the CDN global
The package ships a browser global (IIFE) build for script-tag workflows:
```html title="index.html"
```
The global build exposes `Dendri` and `util` on `window.dendri`. Prefer npm imports for framework apps.
## Next steps
- [Configuration](https://dendri.dev/docs/configuration/) — All client options: transports, TURN, relay, auth, debugging.
- [Framework guides](https://dendri.dev/docs/frameworks/) — The same app in React, Vue, and Svelte with the official adapters.
- [Building the examples](https://dendri.dev/docs/examples/) — Cursors, presence, whiteboard, video, file transfer, and a game.
- [Self-hosting](https://dendri.dev/docs/self-hosting/) — Production deployment: TLS, TURN, Redis, and scaling.
Source: https://dendri.dev/docs/quickstart/
---
# Configuration
One options object controls everything: where the client connects, which signaling transport it uses, how it authenticates, and how aggressively it falls back when networks misbehave.
## Passing options
The same options object is accepted by `new Dendri(options)`, `new Dendri(id, options)`, and `createDendriStore(options)`. The simplest form is a single `url`:
```ts title="app.ts"
import { Dendri, createDendriStore } from "@afterrealism/dendri-client";
// Store (recommended for UI apps)
const store = createDendriStore({ url: "wss://signal.example.com" });
// Low-level peer, with an explicit peer ID
const peer = new Dendri("alice-laptop", { url: "wss://signal.example.com" });
```
`url` is shorthand for `host` / `port` / `secure` / `path`. If you pass any of those explicitly, the explicit value wins over the url-derived one.
## Option reference
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `url` | string | — | Full server URL, e.g. `wss://signal.example.com` or `http://127.0.0.1:9876`. Shorthand for host/port/secure/path. |
| `host` | string | — | Server hostname, e.g. `signal.example.com`. |
| `port` | number | — | Server port, e.g. `443` or `9876`. |
| `path` | string | `"/"` | URL path prefix; must match the server's `--path`. |
| `secure` | boolean | — | `true` for `wss/https`, `false` for `ws/http`. |
| `apiKey` | string | — | API key for hosted / multi-tenant deployments. Sent with every signaling and REST request. |
| `key` | string | `"dendri"` | Connection key namespace; must match the server's `--key`. |
| `jwt` | string | — | JWT for authenticated connections (server started with `--jwt_secret`). |
| `token` | string | random | Session token used to authenticate reconnects for the same peer ID. |
| `signalingTransport` | `"websocket" \| "sse" \| "polling" \| "auto"` | `"websocket"` | Signaling transport. `"auto"` tries WebSocket → SSE → long-poll. |
| `fetchTurnCredentials` | boolean | `false` | Auto-fetch ephemeral TURN credentials from the server's `/turn-credentials` endpoint. |
| `enableRelay` | boolean | `false` | Enable the tier-4 encrypted WebSocket relay when WebRTC is unavailable. |
| `config` | `RTCConfiguration` | — | Custom ICE configuration passed to every `RTCPeerConnection` (your own STUN/TURN servers). |
| `validateMetadata` | `(meta: unknown) => boolean` | — | Validate incoming connection metadata before accepting a connection. |
| `referrerPolicy` | `ReferrerPolicy` | — | Referrer policy for the SDK's HTTP requests. |
| `debug` | number | `0` | Log verbosity, `0` (silent) to `3` (verbose). |
## Configuring from environment variables
Apps should never hardcode a server URL. The pattern used by every official example reads a single env var and derives the parts, so the same build runs against local, staging, and production servers:
```ts title="dendri-options.ts (Vite)"
// .env.local → VITE_DENDRI_URL=https://signal.example.com
// VITE_DENDRI_API_KEY=dk_your_key (hosted tier only)
const serverUrl = new URL(import.meta.env.VITE_DENDRI_URL ?? "http://127.0.0.1:9876");
export const DENDRI_OPTIONS = {
host: serverUrl.hostname,
port: Number(serverUrl.port) || (serverUrl.protocol === "https:" ? 443 : 80),
secure: serverUrl.protocol === "https:",
path: serverUrl.pathname,
apiKey: import.meta.env.VITE_DENDRI_API_KEY,
fetchTurnCredentials: true,
enableRelay: true,
debug: 0,
};
```
> **Note:** **No hidden defaults.** If no URL is configured, fail fast with a clear error. The SDK will never fall back to a vendor-operated endpoint on its own.
## Signaling transports
Signaling (the handshake channel) is separate from the data path. Dendri supports three transports against the same server, selected with `signalingTransport`:
| Value | Mechanism | Use when |
| --- | --- | --- |
| `"websocket"` | Persistent WebSocket (default) | Normal networks. Lowest latency. |
| `"sse"` | Server-Sent Events + HTTP POST | Proxies that terminate WebSockets. |
| `"polling"` | HTTP long-poll | The most restrictive corporate networks. |
| `"auto"` | WS → SSE → polling | You don't control your users' networks. Recommended for public apps. |
## Connectivity & fallback
### TURN credentials
For peers behind symmetric NAT, WebRTC needs a TURN relay. If your server is configured with `--turn_secret`, set `fetchTurnCredentials: true` and the client automatically fetches short-lived credentials before connecting. Alternatively, pass your own ICE servers:
```ts title="custom ICE servers"
const store = createDendriStore({
url: "wss://signal.example.com",
config: {
iceServers: [
{ urls: "stun:stun.example.com:3478" },
{
urls: "turn:turn.example.com:3478",
username: "user",
credential: "pass",
},
],
},
});
```
### Encrypted TLS relay
With `enableRelay: true`, room broadcasts also flow through the signaling server as a last-resort delivery path (tier 4), necessary on networks where ICE reports success but packets silently vanish (VPNs, UDP-blocked networks). Relayed payloads are end-to-end encrypted with ECDH-derived AES-256-GCM keys, so the server relays ciphertext it cannot read. Duplicate deliveries are de-duplicated on the receiving side.
## Connection lifecycle
The client runs a connection state machine, exposed as `peer.connectionState` / `store.connectionState` and via the `connectionStateChanged` event:
| State | Meaning |
| --- | --- |
| `initialized` | Created, not yet connected. |
| `connecting` | Attempting to reach the signaling server. |
| `connected` | Connected and ready. |
| `disconnected` | Temporarily offline; auto-reconnect with exponential backoff and jitter. |
| `suspended` | Extended offline (5+ consecutive failures); slower retry schedule. |
| `closed` | Explicitly closed by your code. |
| `failed` | Unrecoverable error. |
After a successful reconnect the SDK re-sends room membership and presence automatically, so a server restart or a laptop lid-close doesn't leave peers in a ghost state.
## Room and store options
`createDendriStore()` also accepts an advanced shape when you need room-level settings or want to inject a custom client class in tests:
```ts title="advanced store options"
const store = createDendriStore({
dendriOptions: { url: "wss://signal.example.com", enableRelay: true },
roomOptions: {
// How long to wait for a new host to become reachable
// during host migration (default 10 000 ms).
migrationTimeout: 5000,
},
});
```
## Multi-tab behaviour
When two tabs use the same `peer_id` against the same server:
- **Same peer ID, same token**: treated as a reconnection. The server adopts the new socket; room memberships and queued messages survive.
- **Same peer ID, different token**: rejected with `ID_TAKEN`, which prevents accidental or malicious ID hijacking.
For multi-tab apps, either coordinate a single connection with `BroadcastChannel`, or let each tab be its own peer and communicate through a room.
## Debugging
Set `debug: 3` for verbose logs of signaling, ICE negotiation, and transport fallbacks. `peer.diagnostics()` returns a snapshot of connection internals suitable for bug reports.
Source: https://dendri.dev/docs/configuration/
---
# Framework guides
The SDK core is plain TypeScript with zero framework coupling. Official adapters ship as subpath exports for React, Vue, and Svelte — each one is a thin bridge from the store's `subscribe`/`getSnapshot` contract to the framework's reactivity system.
## The store contract
Every guide below starts from the same primitive: `createDendriStore(options)` returns a `DendriStore` whose reactive snapshot contains:
| Field | Type | Meaning |
| --- | --- | --- |
| `connectionState` | `ConnectionState` | `"connecting"`, `"connected"`, `"disconnected"`, … |
| `myPeerId` | `string \| null` | This peer's ID once joined. |
| `isHost` | `boolean` | Whether this peer is the room host. |
| `hostId` | `string \| null` | The current host's peer ID. |
| `peers` | `readonly string[]` | Remote peer IDs in the room. |
| `peerCount` | `number` | Number of remote peers. |
| `presences` | `ReadonlyMap` | Presence data per remote peer. |
All guides share this options module (see [Configuration](https://dendri.dev/docs/configuration/#env-pattern)):
```js title="src/dendri-options.js"
const serverUrl = new URL(import.meta.env.VITE_DENDRI_URL ?? "http://127.0.0.1:9876");
export const DENDRI_OPTIONS = {
host: serverUrl.hostname,
port: Number(serverUrl.port) || (serverUrl.protocol === "https:" ? 443 : 80),
secure: serverUrl.protocol === "https:",
path: serverUrl.pathname,
apiKey: import.meta.env.VITE_DENDRI_API_KEY,
fetchTurnCredentials: true,
enableRelay: true,
};
```
## React
**Live demo:** [react-basic.dendri.dev](https://react-basic.dendri.dev/) (source: [react-basic](https://github.com/afterrealism/dendri-examples/tree/main/react-basic)).
The React adapter is a hook built on `useSyncExternalStore`, so components re-render exactly when the snapshot changes — concurrent-mode safe, no manual subscriptions. React 18+ is an optional peer dependency.
```bash title="terminal"
npm create vite@latest dendri-react -- --template react
cd dendri-react
npm install @afterrealism/dendri-client
```
```jsx title="src/Chat.jsx"
import { createDendriStore } from "@afterrealism/dendri-client";
import { useDendriStore } from "@afterrealism/dendri-client/react";
import { useEffect, useRef, useState } from "react";
import { DENDRI_OPTIONS } from "./dendri-options";
export default function Chat() {
// Create the store once per component instance — never in render.
const storeRef = useRef(null);
if (storeRef.current === null) {
storeRef.current = createDendriStore(DENDRI_OPTIONS);
}
const store = storeRef.current;
// Reactive snapshot: re-renders on connection/peer/presence changes.
const { connectionState, isHost, peerCount, myPeerId } = useDendriStore(store);
const [messages, setMessages] = useState([]);
const [draft, setDraft] = useState("");
useEffect(() => {
// Receive messages published on the "chat" topic.
const unsubscribe = store.subscribe("chat", (data, peerId) => {
setMessages((prev) => [...prev, { from: peerId, text: data.text }]);
});
store.join("react-room");
return () => {
unsubscribe();
store.destroy(); // leaves the room and closes the connection
};
}, [store]);
function send() {
const text = draft.trim();
if (!text) return;
store.broadcast({ text }, { topic: "chat" });
setMessages((prev) => [...prev, { from: "me", text }]);
setDraft("");
}
return (
setDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
placeholder="Type a message…"
/>
);
}
```
> **Note:** **Next.js:** importing the package is SSR-safe, but WebRTC and WebSockets are browser side effects. Mark the component `"use client"` and keep `join()` inside `useEffect` — exactly as above.
## Vue
**Live demo:** [vue-basic.dendri.dev](https://vue-basic.dendri.dev/) (source: [vue-basic](https://github.com/afterrealism/dendri-examples/tree/main/vue-basic)).
The Vue adapter returns a read-only `shallowRef` that tracks the snapshot and automatically unsubscribes when the component unmounts. Vue 3.3+ is an optional peer dependency.
```bash title="terminal"
npm create vite@latest dendri-vue -- --template vue
cd dendri-vue
npm install @afterrealism/dendri-client
```
```vue title="src/Chat.vue"
```
## Svelte
The Svelte adapter converts the store to the standard Svelte store contract, so `$snapshot` auto-subscription just works — in Svelte 4 and Svelte 5. This adapter has no peer dependency at all.
```bash title="terminal"
npx sv create dendri-svelte
cd dendri-svelte
npm install @afterrealism/dendri-client
```
```svelte title="src/routes/+page.svelte"
e.key === "Enter" && send()} />
```
> **Tip:** **Svelte 5 runes:** the official Svelte examples wrap the store in a `.svelte.ts` module using `$state` and expose getters — see [the cursors demo](https://github.com/afterrealism/dendri-examples/tree/main/cursors) for the full pattern.
## Vanilla JavaScript
No adapter needed — subscribe and re-render:
```js title="main.js"
import { createDendriStore } from "@afterrealism/dendri-client";
import { DENDRI_OPTIONS } from "./dendri-options";
const store = createDendriStore(DENDRI_OPTIONS);
store.subscribe(() => {
const s = store.getSnapshot();
document.querySelector("#status").textContent =
`${s.connectionState} · ${s.peerCount} peers · ${s.isHost ? "host" : "client"}`;
});
store.subscribe("chat", (data, peerId) => {
console.log(`${peerId}:`, data.text);
});
store.join("vanilla-room");
```
## Any other framework
The adapters are each ~20 lines. If your framework isn't listed (Solid, Angular, Lit, Preact, …), wrap the same two functions:
```ts title="the whole integration surface"
// 1. Read the current state (immutable snapshot)
const snapshot = store.getSnapshot();
// 2. Get notified when it changes (returns an unsubscribe function)
const unsubscribe = store.subscribe(() => {
render(store.getSnapshot());
});
```
For example, in Solid: `createStore` + `onCleanup(store.subscribe(…))`; in Angular: a service exposing a `signal` updated inside the subscription.
## Cleanup rules (all frameworks)
- Create the store **once** per app/component lifetime — never in a render body.
- Call every unsubscribe function returned by `subscribe`/`onData`/`onPeerJoin` on teardown.
- Call `store.destroy()` last — it leaves the room, closes connections, and removes all listeners.
- In dev with hot-module reload, stale stores keep sockets open; destroying in the teardown hook (as in each example above) prevents ghost peers.
Source: https://dendri.dev/docs/frameworks/
---
# Building the examples
Twelve open-source demos ship in [dendri-examples](https://github.com/afterrealism/dendri-examples). This page walks through the core pattern behind each category so you can rebuild them (or build your own variant) in any framework.
## The demo catalogue
| Demo | Live | Framework | Concepts demonstrated |
| --- | --- | --- | --- |
| [chat](https://github.com/afterrealism/dendri-examples/tree/main/chat) | [chat.dendri.dev](https://chat.dendri.dev/) | Svelte | Topics, store, broadcast |
| [cursors](https://github.com/afterrealism/dendri-examples/tree/main/cursors) | [cursors.dendri.dev](https://cursors.dendri.dev/) | Svelte | Awareness, throttling, ephemeral state |
| [presence](https://github.com/afterrealism/dendri-examples/tree/main/presence) | [presence.dendri.dev](https://presence.dendri.dev/) | Svelte | Presence API, peer lifecycle |
| [whiteboard](https://github.com/afterrealism/dendri-examples/tree/main/whiteboard) | [whiteboard.dendri.dev](https://whiteboard.dendri.dev/) | Svelte | Topic streams, canvas, late-joiner sync |
| [collaborative-edit](https://github.com/afterrealism/dendri-examples/tree/main/collaborative-edit) | [collaborative-edit.dendri.dev](https://collaborative-edit.dendri.dev/) | Svelte | Yjs CRDT sync ([guide](https://dendri.dev/docs/collaboration/)) |
| [collaborative-layers](https://github.com/afterrealism/dendri-examples/tree/main/collaborative-layers) | [collaborative-layers.dendri.dev](https://collaborative-layers.dendri.dev/) | Svelte | Yjs, structured shared state |
| [file-transfer](https://github.com/afterrealism/dendri-examples/tree/main/file-transfer) | [file-transfer.dendri.dev](https://file-transfer.dendri.dev/) | Svelte | Direct data connections, binary chunks |
| [video-chat](https://github.com/afterrealism/dendri-examples/tree/main/video-chat) | [video-chat.dendri.dev](https://video-chat.dendri.dev/) | Svelte | Media calls, streams |
| [game](https://github.com/afterrealism/dendri-examples/tree/main/game) | [game.dendri.dev](https://game.dendri.dev/) | Svelte | RPC, host authority, host migration |
| [react-basic](https://github.com/afterrealism/dendri-examples/tree/main/react-basic) | [react-basic.dendri.dev](https://react-basic.dendri.dev/) | React | Store + React integration |
| [vue-basic](https://github.com/afterrealism/dendri-examples/tree/main/vue-basic) | [vue-basic.dendri.dev](https://vue-basic.dendri.dev/) | Vue | Store + Vue integration |
Every demo is a standalone Vite project: `cd` in, `pnpm install`, `pnpm dev`, and set `VITE_DENDRI_URL` (or `PUBLIC_DENDRI_URL` for the SvelteKit demos) to your server.
## Chat
**Live demo:** [chat.dendri.dev](https://chat.dendri.dev/). Open it in two tabs to see the sync.
*Concepts: topics, store.* Covered end-to-end in the [Quickstart](https://dendri.dev/docs/quickstart/#build-chat). The one-line summary: publish on a topic, subscribe to the topic, render. Everything else on this page is a variation of that loop.
## Live cursors
**Live demo:** [cursors.dendri.dev](https://cursors.dendri.dev/). Open it in two tabs to see the sync.
*Concepts: ephemeral state, throttling.* Cursor positions are high-frequency and disposable: never persist them, and throttle broadcasts so you don't flood slower peers. The simplest version uses a topic:
```js title="cursors.js"
import { createDendriStore } from "@afterrealism/dendri-client";
import { DENDRI_OPTIONS } from "./dendri-options";
const store = createDendriStore(DENDRI_OPTIONS);
const cursors = new Map(); // peerId → { x, y, name, color }
const THROTTLE_MS = 50; // 20 updates/sec is plenty for cursors
let lastSent = 0;
window.addEventListener("pointermove", (event) => {
const now = Date.now();
if (now - lastSent < THROTTLE_MS) return;
lastSent = now;
store.broadcast(
{ x: event.clientX / innerWidth, y: event.clientY / innerHeight },
{ topic: "cursor" },
);
});
store.subscribe("cursor", (data, peerId) => {
cursors.set(peerId, data);
render();
});
// Remove cursors of peers that leave.
store.onPeerLeave((peerId) => {
cursors.delete(peerId);
render();
});
function render() {
for (const [peerId, c] of cursors) {
const el = cursorElement(peerId); // create-or-get a positioned