R Relay ← Back to relay docs

Integrating Relay into an existing web app

This is the complete path for a team that already has a web app and wants to add chat + calling to it — not a new demo app. Everything you need is on this page; the package's own packages/web/react/README.md has the full API reference if you need more than what's shown here.

For Android or iOS, see integration-android.md / integration-ios.md instead. A shorter, cross-platform overview lives at integration.md.

The mental model

Relay is a separate service your existing backend talks to, plus a client library your web app depends on. It does not replace your login system, your user database, or your backend — it has no idea who your users are until you tell it, on every request, via a short-lived token your own backend mints. Three actors:

Nothing here requires a rewrite. <ChatWindow /> is a normal React component you place inside a screen you already have; if you're on Vue/Svelte/vanilla JS, or already have your own chat UI, use @relay/core directly and keep your UI.

Step 1 — Create a project and get your keys

Once, against the Relay service (ask whoever runs it for the URL, or run service/ yourself — see service/README.md):

curl -s -X POST https://relay.example.com/projects \
  -H 'content-type: application/json' \
  -d '{"name":"My App"}'
# {
#   "publicKey": "pk_5f1e…",   ← ships in your browser bundle, safe to embed
#   "secretKey": "sk_9c2a…"    ← backend only, shown exactly once — store it in your secrets manager now
# }

publicKey identifies your project to Relay and is fine in a browser bundle — it grants nothing by itself. secretKey mints user tokens for anyone in your project; it must never reach client code, a browser, or git. If you lose it, rotate it (POST /projects/me/keys) rather than trying to recover it — it's stored as a hash.

Step 2 — Add a token endpoint to your existing backend

This is the one piece of backend work, and the only place Relay touches your existing auth. Add a route your already-signed-in users can call:

POST /api/relay-token          (your backend, behind your existing session/auth middleware)
  → looks up the current user from YOUR session (however you already do that)
  → calls Relay:  POST /users/token  { "externalId": "<your user id>", "displayName": "...", "avatarUrl": "..." }
                  Authorization: Bearer sk_9c2a…
  → returns { "userToken": "...", "expiresAt": "..." } to your browser

externalId is your user id — whatever you already use as a primary key. Relay never invents its own identity; every userId your app sees on the wire (message senders, call participants, presence) is that same id. Tokens expire in ~15 minutes; @relay/core calls this endpoint again automatically when one expires, so this route just needs to exist and be fast. sample-apps/web-demo/token-server.mjs is a complete, 60-line reference implementation of this exact endpoint. Full details: service/README.md.

Step 3 — Add the SDK to your app

Install:

npm install @relay/react @relay/core react react-dom

react / react-dom 18.2+ or 19 are peer dependencies. On a non-React stack, install @relay/core alone.

Initialize + mountRelayProvider fits around whatever part of your existing component tree should have chat, the same as any other context provider you already use (a router outlet, a dashboard panel, a modal):

import { RelayProvider, ChatWindow } from '@relay/react';
import '@relay/react/styles.css';

const fetchToken = () =>
  fetch('/api/relay-token', { method: 'POST' }).then((r) => r.json()).then((j) => j.userToken); // Step 2

function MyExistingChatPanel({ userId }: { userId: string }) {
  return (
    // key={userId}: switching users must create a fresh client, not reuse the old socket.
    <RelayProvider key={userId} config={{ baseUrl: 'https://relay.example.com', publicKey: 'pk_5f1e…', token: fetchToken }}>
      <div style={{ height: '100%' }}><ChatWindow /></div>
    </RelayProvider>
  );
}

ChatWindow fills its container, so give the parent a height.

Headless, if you already have chat UI, or you're on Vue/Svelte/vanilla — use the hooks (React) or @relay/core's store directly (anything else) over your own markup:

// React
const { messages, send } = useMessages(conversationId);
// Framework-agnostic
import { createRelayClient } from '@relay/core';
const relay = createRelayClient({ baseUrl: '...', publicKey: 'pk_5f1e…', token: fetchToken });
await relay.connect();
relay.chat.subscribe(() => render(relay.chat.getSnapshot()));

Step 4 — Calling (1:1 and group, one API)

<ChatWindow /> already shows call buttons in every thread header and mounts the call overlay (incoming banner, full-screen view, errors) by default — pass calls={false} to turn that off. One call-start API handles both 1:1 and group calls — the SDK looks at the conversation you pass it and shows the right layout itself:

import { useCall, CallOverlay } from '@relay/react';

function Header({ conversation }) {
  const { call, start } = useCall();            // call: null | { phase, callId, peerId, type, … }
  return <button disabled={!!call} onClick={() => start(conversation, 'video')}>Video call</button>;
}
// somewhere once, inside <RelayProvider> — only needed if you set calls={false} above and want your own overlay:
<CallOverlay />

Group calls (up to 6 participants) render as a grid — 2 stacked, 3 as 2-over-1, up to 6 as three rows of 2 — with per-tile name badges, mute indicators, tap-to-fullscreen, and a per-participant "mute for me" control that only affects what you hear locally, without touching what anyone else hears. Media is peer-to-peer WebRTC; the service hands out TURN credentials automatically once configured for your project.

Step 5 — Wire your own "add participants" picker

Group chats need a way to pick who to add. Relay does not ship a directory of your users — it has no idea who they are beyond the ids that show up in tokens — so this is always a callback into your own app. Two ways to hook it in:

Your own picker UI entirely (fastest to wire, if you already have a contacts/user list screen) — return the ids yourself, in whatever UI you want:

<ChatWindow onPickGroupMembers={async () => await showMyOwnContactPicker()} />

Relay's built-in picker, backed by your directory — you supply a search function, Relay renders the search/list/selection UI itself:

<ChatWindow onSearchPeople={async (query) => myUserDirectory.search(query)} />

onSearchPeople?: (query: string) => Promise<RelayUser[]> is called once with an empty query when the screen opens (a starting list) and again as the user types; return RelayUsers (id, display name, avatar, online status) from whatever directory your app already has. If you supply both, onSearchPeople wins; if you supply neither, "Add people" is simply hidden.

Step 6 — Make it look like your app

Every color, most icons, and text sizing are overridable, with defaults equal to Relay's own look — so this step is optional, and doing nothing is a supported choice. Colors and fonts are plain CSS variables; icons are a prop, since swapping which glyph renders is something CSS can't do.

:root {
  --relay-accent: #7c3aed;
  --relay-bubble-me: #7c3aed;
  --relay-font: Inter, system-ui, sans-serif;
  --relay-font-scale: 1.1;
}
<RelayProvider config={config} icons={{ micOn: <MyMicIcon />, callEnd: <MyHangUpIcon /> }}>

This covers chat and call screens uniformly — the call screen used to be the one place still hardcoded to Relay's own dark-navy palette; its colors now read from the same --relay-* variables as the rest (--relay-call-scrim-start/-end for the backdrop, --relay-online/--relay-danger for answer/decline). Full variable list, dark mode (data-theme="dark"), and every relay-* class name for deeper CSS overrides: packages/web/react/README.md#theming.

Security checklist before you ship

What you still own

Relay doesn't do: authentication (you already have this — it just needs a way to mint tokens for whoever is signed in), a user directory or search (Step 5 is how your existing one plugs in), or push certificate/key management beyond what you configure once in the admin panel (web calling works while a tab is open; there is no web-push wake-up for calls, unlike iOS/Android).

Where to go next