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:
- Your backend — already exists, already authenticates your users. Gains one new endpoint: mint a Relay user token for whoever is signed in.
- The Relay service — one shared Communication Service, reachable at a URL you configure. Owns chat storage, presence, the realtime gateway, and brokers WebRTC calls. You don't write any of this.
@relay/core/@relay/react— an npm package your existing app depends on. Talks to the Relay service directly (not through your backend, and not through your existing API) for everything after the initial token exchange.
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 + mount — RelayProvider 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
secretKeylives only on your backend (Step 2), never in client code or git.- Set a CORS origin allowlist for your project (
PATCH /projects/me/settings) so only your own origins can open a connection with your public key. - Decide who can message whom in your own backend, before minting a token or before your UI offers a conversation — Relay has no friend graph or block list of its own, so any two users in the same project can otherwise open a chat with each other.
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
- Full API reference:
packages/web/react/README.md(React) / headless@relay/corereference inpackages/web/README.md. - Backend/token details and every security note:
service/README.md. - The reference integration is a real, running app:
sample-apps/web-demo.