Guide: Realtime chat
A complete per-room chat channel: message history, sending, presence, and membership —
built entirely on @portalsdk/core and @portalsdk/react's published surface.
The room component
// file: chat-room.tsx
"use client";
import { useState, type FormEvent } from "react";
import { useChannel } from "@portalsdk/react";
import type { UseChannelResult } from "@portalsdk/react";
interface ChatMessage {
body: string;
}
export function ChatRoom({ roomId }: { roomId: string }) {
const [draft, setDraft] = useState("");
const {
messages,
send,
loadPrevious,
hasPrevious,
isLoadingPrevious,
presence,
typing,
sendTyping,
status,
} = useChannel<ChatMessage>({ channelId: roomId, history: 30 });
async function onSubmit(e: FormEvent) {
e.preventDefault();
if (draft.trim() === "") return;
await send({ content: { body: draft } });
setDraft("");
}
return (
<div>
<p>status: {status}</p>
{hasPrevious && (
<button disabled={isLoadingPrevious} onClick={() => loadPrevious()}>
{isLoadingPrevious ? "Loading…" : "Load older messages"}
</button>
)}
<ul>
{messages.map((m) => (
<li key={m.id}>
<strong>{m.sender.id}</strong>: {m.content.body}
</li>
))}
</ul>
{typing.length > 0 && <p>{typing.join(", ")} typing…</p>}
<form onSubmit={onSubmit}>
<input
value={draft}
onChange={(e) => {
setDraft(e.target.value);
sendTyping();
}}
/>
<button type="submit">Send</button>
</form>
<PresenceList presence={presence} />
</div>
);
}
function PresenceList({ presence }: { presence: UseChannelResult<ChatMessage>["presence"] }) {
if (presence?.kind === "detailed") {
return (
<ul>
{presence.participants.map((p) => (
<li key={p.id}>{p.username ?? p.id}</li>
))}
</ul>
);
}
if (presence?.kind === "aggregate") {
return <p>{presence.count} online</p>;
}
return null;
}
Mount it wherever your router hands you a room id:
// file: app.tsx
"use client";
import { Portal } from "@portalsdk/core";
import { PortalProvider } from "@portalsdk/react";
import { ChatRoom } from "./chat-room";
const portal = new Portal({ apiKey: "pk_your_publishable_key" });
export function App({ roomId }: { roomId: string }) {
return (
<PortalProvider client={portal}>
<ChatRoom roomId={roomId} />
</PortalProvider>
);
}
History
history: 30 on useChannel backfills the last 30 messages on connect. hasPrevious
starts true and flips to false once loadPrevious() has walked back to the
beginning of the channel — the "Load older messages" button above disables itself
naturally once there's nothing left to load.
Presence
presence narrows on .kind: small/standard rooms get a full, "detailed" roster
(participants, each with id, anon, and optional username/metadata); larger
channels degrade to "aggregate" (count plus recent join/leave deltas only, no full
list). Branch on kind rather than assuming one shape, as the example above does.
Membership
Whether a user is allowed into a given room — and who's a member of it — is a
server-side concern, configured through portal.config.ts (anonymous, authz; see
Authoring portal.config.ts). The client SDK doesn't expose
a "join" or "add member" call; from the client, you can only:
-
read the roster with
members()(a fetched directory, not live state — merge it withpresencefor online/offline), and -
catch
NotMemberErrorif a user without a membership row tries to connect to a membership-gated channel.
import { NotMemberError } from "@portalsdk/core";
import type { ChannelHandle } from "@portalsdk/core";
async function loadRoster(room: ChannelHandle) {
try {
return await room.members();
} catch (err) {
if (err instanceof NotMemberError) {
return [];
}
throw err;
}
}
Adding or removing members from a channel happens on your backend, outside this SDK's surface.