
Stream React
- 145 installs
- 17 repo stars
- Updated August 4, 2026
- getstream/agent-skills
Builds, enhances, audits, or migrates React/Next.js web apps with Stream Chat, Video, Feeds, and Moderation SDKs across scaffold and integration tracks.
About
The default skill for all web React work with Stream, covering scaffolding a new Next.js app, enhancing an existing one, auditing a Video integration, or migrating an SDK version. A developer uses it to add or upgrade Stream chat, video, or feeds in a React app.
- Four tracks: scaffold, enhance, audit, and migrate
- Docs-driven migration that never upgrades from memory
Stream React by the numbers
- 145 all-time installs (skills.sh)
- Ranked #960 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/getstream/agent-skills --skill stream-reactAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 145 |
|---|---|
| repo stars | ★ 17 |
| Last updated | August 4, 2026 |
| Repository | getstream/agent-skills ↗ |
What it does
Builds, enhances, audits, or migrates React/Next.js web apps with Stream Chat, Video, Feeds, and Moderation SDKs across scaffold and integration tracks.
Files
Stream React (web) - scaffold + enhance + audit + migrate
Read first (every session): Read `RULES.md` (React/Next.js non-negotiables) and the cross-cutting `../stream/RULES.md`. Where the two files state the same rule differently (e.g. Strict mode protection, Package manager), this pack's [`RULES.md`](RULES.md) wins for React work. Glob../stream/SKILL.md; if empty, install withgetstream skills stream.
>
CLI: CLI usage (onboarding, credentials, auth, everygetstream apicall) lives in the root skill - its "Stream CLI" section in `../stream/SKILL.md`, with CLI safety in `../stream/RULES.md`. Glob../stream/SKILL.md; install silently if missing (getstream skills stream).
This skill builds, enhances, audits, and migrates Stream Chat, Video, and Feeds in React / Next.js web apps - the default pack for all web React work. It covers four tracks:
- Track A - Scaffold a new app: Steps 0-7 below. Use when the cwd is empty / new and the user said "build me a ... app".
- Track E - Enhance an existing app: see `enhance.md`. Skips scaffold + theme; reuses the same SDK wiring and component blueprints.
- Track F - Audit an existing integration (read-only): for "audit/review my video integration", "check my app against best practices", "is my video app production-ready?", "what am I missing before launch?". Video only: load the Integration best-practices audit section in `references/VIDEO.md` and follow its protocol - it has a Video-specific checklist + output contract. If the user asks to audit Chat or Feeds, say up front there is no dedicated best-practices checklist for those yet, then do a general docs-based review (fetch the relevant pages from `references/DOCS.md` and check the app against them) rather than applying the Video checklist. Skip onboarding, auth, the CLI, and all build steps - this track only reads the app and reports findings. Fix issues only if the user then asks.
- Track M - Migrate / upgrade an SDK version: see `migrate.md`. For "upgrade stream-chat-react to v14", "migrate to the new SDK", "bump my Stream version". Docs-driven: detect the installed version, fetch the matching release guide, apply it. Never migrate from memory.
Flow dispatch - choose exactly one
- Track A: run
getstream initto onboard (authenticate + select/create org + app + write credentials), then continue to Start and execute Steps 0-7. - Track E: run
getstream initto onboard (authenticate + select/create org + app + write credentials), then Read and execute `enhance.md`. Do not enter Start or any scaffold task. - Track F: skip onboarding and go directly to the audit in `references/VIDEO.md`. Do not enter Start or any build step.
- Track M: skip onboarding and Read `migrate.md` first; it fetches the live release guide before any edit. Do not enter Start or any scaffold task.
---
Docs-first triggers (consult docs before building)
For any feature that matches a UI component, cookbook, or advanced-guide topic, fetch the matching Stream docs page BEFORE writing code. The live docs are the source of truth for the current API and the recommended pattern; the bundled references/*-blueprints.md cover the common path only. Full keyword -> page map with exact URLs: `references/DOCS.md`. Enforced by `RULES.md` > Docs-first for cookbook / advanced features.
This skill is prebuilt-component-first: build the common path with the SDK's prebuilt React components and customize via the documented hooks/props - see `RULES.md` > Reference authority. The docs-first protocol covers both the component reference pages and the cookbook / advanced recipes:
- UI Cookbook (customization / theming): typing indicator, custom message UI, message actions, reactions customization, message composer / input UI, channel header, channel list preview, emoji picker, autocomplete / suggestion list, link previews, pin indicator, thread header, search, collapsible sidebar, system message / banner, mentions actions, attachment actions, hide channel history, localization / i18n; Video: replacing call controls, custom layouts, lobby preview, PiP, network quality, livestream watching, ringing.
- Advanced Guides: AI integrations (LangChain, AI SDK), advanced search, multiple lists, infinite scroll, read state, online status, location sharing, blocking, message reminders, notifications / web push, attachment previews, audio playback, date formatting, SDK state management, dialog management, TypeScript custom data, chat + video integration, recording, broadcasting, video filters.
When a request hits one of these: match -> `WebFetch` the page's `.md` URL from [`references/DOCS.md`](references/DOCS.md) -> implement to match. On fetch failure, hand to the stream-docs skill; if neither resolves the API, stop and ask the user - never build from memory.
---
Start
Track A only. Tracks E, F, and M branch in Flow dispatch above and never enter this section.
Once getstream init has onboarded (authenticated + selected/created org + app + written credentials), announce the network plan once, then immediately start executing Steps 0-7 - do not ask permission to begin (the user has authorized the build by asking for it). The only pauses for input are the theme + app pick (Step 1b) and the skill-pack consent (Task A.2).
Trust readout (announce, then continue on the same turn - do not wait)
Before the first network command, print this verbatim to the user, then proceed straight into Step 0 without stopping for a reply:
Scaffolding now. Network calls you'll see:
- npx shadcn@latest ... (Vercel) - scaffold + UI components from npm.-npm install <stream-packages> --legacy-peer-deps- Stream SDKs from npm (stream-chat-react,@stream-io/video-react-sdk, etc.).
-getstream env- local CLI, no network; writes.env.local(gitignored by the Next.js scaffold's default; Task B verifies).
>
Interrupt me at any point if something looks wrong. I'll pause twice for your input: the theme + Stream-app pick (Step 1b) and the optional third-party skill packs (Task A.2).
Full per-command audit (publisher, why unpinned, what each writes): section Install trust & integrity below. The user's continued silence after the readout is implicit consent for this scaffold; an objection or stop instruction aborts the run.
Shadcn/ui is always installed during Step 3. Third-party frontend skills (vercel-react-best-practices, web-design-guidelines, frontend-design) are installed only with explicit user consent - see Task A.2 for the disclosure script. If the user declines, Step 4 proceeds using Stream references only. Precedence (when the skills are present): Stream references win for SDK wiring; frontend skills guide generic React / UI polish.
---
Install trust & integrity
This builder runs three classes of network-touching commands. Each is listed here so a reviewer can audit before approving. CLI install instructions live in the root skill's "Stream CLI" section in `../stream/SKILL.md`.
| Command | Publisher | Why unpinned | What it writes |
|---|---|---|---|
npx shadcn@latest init ... (Task A) | Vercel - `shadcn-ui/ui` | Scaffolder; @latest is the maintainer's documented usage. Pinning ships outdated scaffolds. | Project files in cwd. Next.js scaffold's .gitignore ignores .env* by default. |
npx shadcn@latest add ... (Task A.1) | Vercel - same source as above | Same scaffolder; component sync depends on registry parity. | Component files under components/ui/. |
npm install <stream-packages> --legacy-peer-deps (Task C) | GetStream (npm) for @stream-io/* and stream-chat-react; transitive deps via standard npm trust | Latest published versions of GetStream's own SDKs - same trust model as the CLI itself. | Modules under node_modules/. Runtime SDKs + transitive deps. |
npx skills add <github> (Task A.2) | vercel-labs/agent-skills and anthropics/skills | Optional. Markdown-only skill packs; npx skills add is the published install path. | Markdown files in the user's skills directory. Gated by explicit user consent in Task A.2 - never runs without an affirmative answer. |
getstream env (Task B) | GetStream - install instructions in the root skill's "Stream CLI" section in `../stream/SKILL.md` | n/a (local CLI, no network at this step) | .env.local in the project root with NEXT_PUBLIC_STREAM_API_KEY + STREAM_API_SECRET. Task B verifies .gitignore covers .env* before writing (Next.js scaffold's default already does). The agent never reads .env.local (RULES.md > Secrets). |
Reviewer checklist:
- All
npxinvocations resolve to the publishers listed above; substitute a different publisher and the install fails. npx skills addruns only after the disclosure prompt in Task A.2 and an explicit user "yes.".env.localis written by the Stream CLI directly, not by the agent, and is not transmitted into the conversation.- If the user wants to pin a specific shadcn version, replace
@latestwith@<version>in Tasks A and A.1.
---
Builder Steps
Execute phases in order (later steps depend on earlier ones). Do not run independent phases in parallel. Shell discipline (one bash -c per phase, no bash -ce, getstream login standalone) lives in `../stream/RULES.md` > Shell discipline.
Two-call exception: If you must Read JSON (e.g. OrganizationRead) and then choose IDs, use one call for the read, one batched call for all creates.
Step 0: Package manager
Always use npm. Never use bun. (`RULES.md` > Package manager.)
Step 1: Auth
Run the Provisioning > Step 1: Auth flow in `builder.md` (auth probe via getstream api OrganizationRead; getstream login as its own invocation on exit 2; hang recovery). On exit 0, continue to Step 1b.
Step 1b: Theme + app pick
Ask both setup questions in one message before doing anything else - a single pause, the same "ask exactly once, then act" pattern the other platform packs use for credentials. Build the app options from what is already in context: the configured org/app from getstream init and the org list from Step 1's OrganizationRead output.
Quick setup - two questions:
1. Theme: I can use a random shadcn theme, or you can design your own at ui.shadcn.com/create and share the--presetvalue (e.g.--preset b1Gdi7z7r). Random, or do you have a preset?
2. Stream app: (an app is configured) Use the currently configured app `<name>` (default), pick another existing org/app, or create a fresh one? / (no app configured) You have these orgs: <list>. Pick one to use - I'll list its apps - or create a fresh org + app?STOP here and wait for the user's answer. Do not continue with any other step until the user responds. Asking a question and continuing to work in parallel is confusing - the user misses the question as output scrolls past.
- Theme - preset provided -> store it for Task A scaffold command. Random / doesn't care -> pick a random preset from
nova,vega,maia,lyra,mira,luma. - App - named choice, "default", or "don't care" -> Step 2 applies it (the configured app wins whenever one exists). Create new -> Step 2 runs the create flow.
- Account has no orgs at all -> drop question 2, announce that a fresh org + app will be created, and ask only the theme.
Step 2: Pick org + app
Run Provisioning in `builder.md`: getstream init handles auth and org/app selection-or-creation (including the Feeds v3 region choice). Let init drive it - interactively or via its command file; don't provision with raw getstream api calls.
Step 3: Scaffold + .env + SDKs + Configure - SEQUENTIALLY
Scaffold order
Order:
1. Steps 1-1b: Auth + theme/app pick (wait for answer). 2. Step 2: Apply the org/app choice (select existing or create). 3. Task A: Scaffold with Shadcn + Next.js using the chosen preset. 4. Task A.1: Add base Shadcn components. 5. Task A.2: Disclose + ask about third-party frontend skill installs; install only with user consent. 6. Continue with Task B (.env), Task C (SDKs), Task D (CLI config).
Task A: Scaffold - scaffolds Next.js + Tailwind + Shadcn/ui (Base UI) into the current directory. Use the theme preset chosen in Step 1b.
The scaffold command creates a new directory, so we scaffold into a temporary .scaffold subdirectory and move everything up:
npx shadcn@latest init -t next -b base -n .scaffold --no-monorepo -p <random-preset> && mv .scaffold/* .scaffold/.* . 2>/dev/null; rm -rf .scaffoldTask A.1: Add base Shadcn components:
npx shadcn@latest add button input textarea card avatar badge separatorAdd more components as the use case requires (e.g. dialog, dropdown-menu, tabs, popover).
Task A.2: Frontend skills - third-party skill packs. You must disclose and ask before installing. Do NOT construct your own command variant.
Print this disclosure verbatim, then stop and wait for the user's answer:
I'd like to install three third-party skill packs that improve generic UI quality:
- vercel-react-best-practices - from `vercel-labs/agent-skills`- web-design-guidelines - from `vercel-labs/agent-skills`- frontend-design - from `anthropics/skills`>
The packs are markdown only - no scripts execute. If you say yes, I'll runnpx skills add ... -yonce per pack from those GitHub repos at their currentmainbranch (-yskips the installer's own confirmation since you've consented here). These aren't required - Stream reference files cover SDK wiring either way. Install them?
- User agrees -> run:
npx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-best-practices -y && npx skills add https://github.com/vercel-labs/agent-skills --skill web-design-guidelines -y && npx skills add https://github.com/anthropics/skills --skill frontend-design -y- User declines -> skip silently and continue to Task B. Do not retry, do not bring it up again this session.
- Install fails -> continue with Stream reference files only; mention the failure briefly.
Do not modify layout.tsx or globals.css after scaffold - use Shadcn's defaults as-is (RULES.md > Theme).
Task B: .env - run AFTER scaffold so the .env lands inside the project directory.
*First, verify `.env is gitignored** ([../stream/RULES.md](../stream/RULES.md) > Secrets). The Next.js scaffold's default already includes it; this is a safety net for projects whose .gitignore was hand-edited or doesn't yet exist. Use the **file tools** (no shell) so no broad bash -c` permission is needed:
Grepfor^\.envin.gitignore(orReadit). If it already ignores.env*, do nothing.- If
.gitignoreexists but has no.enventry, `Edit` it to append a line.env*. - If
.gitignoredoes not exist, `Write` a new.gitignorecontaining.env*.
(Inspecting/editing .gitignore is fine; never Read or Edit .env itself - `../stream/RULES.md` > Secrets.)
Then write secrets:
getstream envgetstream env detects the Next.js project and writes NEXT_PUBLIC_STREAM_API_KEY + STREAM_API_SECRET to .env.local. The secret is server-side only - used by /api/token to mint tokens, never in the client bundle. The public API key may be read client-side from NEXT_PUBLIC_STREAM_API_KEY or returned via /api/token. The agent never reads .env.local (`RULES.md` > Env vars).
Task C: Install Stream SDKs + verify icons - Only what the use case needs:
# Chat: stream-chat stream-chat-react
# Video: @stream-io/video-react-sdk
# Feeds: @stream-io/feeds-react-sdk
# Server: @stream-io/node-sdk
npm install <packages> --legacy-peer-depsAfter installing SDKs, verify an icon package is available. Some Shadcn presets bundle one, others don't:
node -e "const p=['lucide-react','@phosphor-icons/react','@hugeicons/react'];console.log(p.some(m=>{try{require.resolve(m);return true}catch{return false}})?'ICONS_OK':'NO_ICONS')"If NO_ICONS, install lucide-react: npm install lucide-react --legacy-peer-deps. If an icon package is already present, use that one throughout the app - do not install a second.
Task D: Configure Stream - run the CLI commands from the relevant `references/<Product>.md` (App Integration -> Setup) for each product the use case needs.
Step 4: Generate code and UI
Prebuilt-component-first. Build the common path with the SDK's prebuilt React components and customize via the documented hooks/props (`RULES.md` > Reference authority). Hand-build markup only when the user explicitly wants fully bespoke UI.
Docs-first: before implementing any component, cookbook, or advanced feature (typing indicator, custom message UI, reactions, AI integrations, read state, notifications, call layouts, ...), follow the Docs-first triggers section above - WebFetch the matching `references/DOCS.md` page first, then build to match.
Load [`builder-ui.md`](builder-ui.md) and [`sdk.md`](sdk.md) (cross-cutting SDK wiring: token route, instantiation, CSS imports), plus only the relevant `references/<Product>.md` header + references/<Product>-blueprints.md for the sections you are implementing - not every reference file. Pull Use Case Matching and Page Flow from `builder.md` to choose products and navigation structure. For multi-product apps (Chat + Video, Chat + Feeds, Video + Feeds, etc.), also load [`references/CROSS-PRODUCT.md`](references/CROSS-PRODUCT.md) before writing AppShell - it has the canonical multi-client provider hierarchy and an error -> cause -> fix table.
Step 5: Verify
Type-check first (reports ALL errors at once, ~3s):
npx tsc --noEmitFix all type errors. Then run the full build:
npx next buildFix any remaining errors. Do NOT skip tsc --noEmit - it catches every type error in one pass, while next build stops at the first error per file and requires multiple rebuild cycles.
Step 6: Start dev server
Pick a random 5-digit port (10000-65535). Run the server using run_in_background:
PORT=$((RANDOM % 55536 + 10000))
npx next dev -p $PORTImportant: The dev server is a long-running process. When run in the background it will eventually emit a "completed" notification - this does not mean the server stopped. The server is still running and serving requests. Do not respond to the background-task completion notification by telling the user the server has stopped. If you receive that notification after Step 7, ignore it silently - do not output anything.
Step 7: Summary
Show the org/app used (created or selected), plus resources and files created. Include the local URL. Do NOT say "you can now start the dev server" - it's already running.
End with:
Open http://localhost:<PORT>, enter a username, and start testing. Open a second tab with a different username to test multi-user interactions.---
Use Case Matching and Page Flow
Both live in `builder.md` (Use Case Matching, Page Flow). Match the user's words to a use case there, then build only the products that use case needs and follow the hub-first navigation it describes. Moderation is configured via CLI during setup only - never build moderation review UI (`RULES.md` > Moderation is Dashboard-only).
---
Cross-Product Integration
When building apps that combine multiple products, read each relevant `references/<Product>.md` App Integration section. Key patterns:
- Combined token route:
/api/tokenreturns tokens for each product ({ chatToken, videoToken, feedToken, apiKey }). Upsert only the requesting user - never seed demo users (`../stream/RULES.md` > No auto-seeding). - Video + Feeds (Livestreaming): Feed hub separates
type === "live"activities as prominent live cards. "Go Live" posts a live activity via/api/feed/live. "End Stream" removes it. - Video + Chat (Livestreaming): Chat alongside video on the watch screen. Use
livestreamchannel type - one channel per stream, keyed by call ID. Create the chat channel in the/api/tokenroute. - Moderation (all use cases): Run Moderation CLI setup commands from `references/MODERATION.md` (App Integration -> Setup), adjusting channel type name. Never build moderation review UI (`RULES.md` > Moderation is Dashboard-only).
For multi-product provider nesting, load `references/CROSS-PRODUCT.md`.
---
Reference file paths
Blueprint files live in the references/ directory next to this SKILL.md. Resolve them relative to this skill's own directory, wherever the pack is installed (e.g. <skill-dir>/references/FEEDS.md). Do not hardcode machine-specific absolute paths or assume a repo-checkout layout.
Builder - UI shell and theme (Step 4)
Load when executing Step 4 (after scaffold). Rules: `RULES.md` (login screen first, theme, reference authority).
Step 4: Generate ALL code files
Write every file sequentially. Follow the UI Guidelines below for all visual styling. See RULES.md > Reference authority - reference files are the only source of truth for SDK wiring. Before writing each component, load the relevant references/<Product>-blueprints.md section.
Login Screen (required for every app - RULES.md > Login Screen first)
Centered card on a neutral background. No sidebar, no nav - just the login form.
Layout (top to bottom, all centered inside the card):
- App icon / logo
- App name (use-case label)
- Single
usernameinput (required, full card width) Continueprimary button (no arrow glyph in label - see UI Guidelines > Button labels)- Hint text below the button, in
text-muted-foreground text-sm: "Open this URL in another tab with a different username to test multi-user features."
Behavior:
- Username input is required
- On submit:
GET /api/token?user_id={username}-> store credentials in React state (not localStorage - each tab must be independent) - After successful token fetch, render the main app UI (state gate, not redirect)
- App name / use-case label above the input
App Header (required for every app)
Once logged in, every app MUST show a persistent header bar:
- Left: App name (derived from use case)
- Right: Avatar circle (initial letter) + username + "Switch User" button
- "Switch User" clears all token/client state and returns to the Login Screen
- The header sits above all product UI (chat sidebar, video player, feed, etc.)
This ensures the developer always knows which user they are operating as.
---
UI Guidelines
Stack
- Next.js 16, Tailwind v4, TypeScript (match scaffold defaults).
- Shadcn/ui with Base UI - scaffolded via
shadcn init -t next -b base -p <preset>(preset chosen in SKILL.md Step 1b - the user's own preset, or a random fallback; applied in Task A). Use Shadcn components (Button,Input,Textarea,Card, etc.) for all standard UI. Add more vianpx shadcn@latest add <component>as needed. - Icons: Use whichever icon package the scaffold installed (check
package.json). If none present,lucide-reactis installed during Step 3 Task C. Standard PascalCase imports:
import { Heart, Send, Bookmark, MoreHorizontal } from "lucide-react". If the project uses a different icon package (e.g. @phosphor-icons/react), use that one instead - do not mix icon packages.
- Tailwind utility classes for custom styling beyond Shadcn components - never inline styles.
- Theme: RULES.md > Theme -
next-themeswith system default (class-based dark mode, scaffolded automatically). -webkit-font-smoothing: antialiasedon html (set by scaffold).
Theme
Use whatever globals.css Shadcn generates. Do not add custom variables, custom themes, or dark mode overrides. The scaffold includes next-themes with ThemeProvider (system default, class-based toggle) - use it as-is.
Design
Use Shadcn components, Tailwind utilities, and - if the user approved them in Step 3 Task A.2 - the frontend skills to build a polished UI. No further opinions; use your best judgement. Stream references provide structure and wiring; frontend skills (when present) provide generic design guidance.
Button labels
Never put arrow characters in button text - no ASCII arrow sequences (like ->, >>) and no unicode arrow glyphs (any codepoint that renders as an arrow or chevron) in the label. If a button needs an arrow visually, use a proper icon component (e.g. lucide-react's <ArrowRight />, <ChevronRight />) rendered alongside the label. Otherwise, leave the label plain (e.g. Continue, not Continue ->).
Stream SDK CSS & Providers
- Chat: Import
stream-chat-react/css/index.css(v14+ preferred alias; v13 useddist/css/v2/index.css). UseuseCreateChatClientfromstream-chat-reactto instantiate. Match theme:useTheme()->str-chat__theme-darkorstr-chat__theme-lightto<Chat>. - Video: Import
@stream-io/video-react-sdk/dist/css/styles.css. InstantiateStreamVideoClientwith the canonicaluseState+useEffectpattern (NOTuseMemo- seereferences/VIDEO.md). - Feeds: No CSS import - headless SDK. Wrap app in
<StreamFeeds client={client}>, then per-feed in<StreamFeed feed={feed}>. UseuseCreateFeedsClient()for client creation - gate rendering on `client !== null` (returnsnulluntil connected). Callfeed.getOrCreate({ watch: true })insidesetTimeout(50ms)+mountedguard (strict mode protection) before passing to<StreamFeed>. Seereferences/FEEDS.mdfor complete patterns.
Provider hierarchy: mount all Stream providers - <Chat>, <StreamVideo>, <StreamFeeds> - once at AppShell, in any order. Per-screen components render <Channel>, <StreamCall>, or <StreamFeed> from the existing root providers. Never re-instantiate Stream clients per screen - the cleanup of one screen's effect will disconnect a client another screen is still using. For multi-product apps, see `references/CROSS-PRODUCT.md` for the full skeleton.
Moderation
Never build moderation review UI in the app (RULES.md > Moderation is Dashboard-only). All review happens in the Stream Dashboard. The app's role is CLI setup only (blocklists, automod config in Step 3).
Reference Blueprints
See RULES.md > Reference authority. Load references/<Product>.md (header) for setup + gotchas, and references/<Product>-blueprints.md for structure and wiring of each component. Load only the product(s) relevant to the current use case.
Prebuilt-first default (Chat + Video). Build the common path with the SDK's prebuilt React components - Chat: <Chat> / <ChannelList> / <Channel> / <Window> / <MessageList> / <MessageComposer> / <Thread> (v14 uses MessageComposer, not MessageInput); Video: <StreamVideo> / <StreamCall> / SpeakerLayout / PaginatedGridLayout / ParticipantView / CallControls. Customize via the documented hooks/props - register custom UI through <WithComponents overrides={{...}}> or pass a per-list <MessageList Message={Custom} />; read state with useChannelStateContext() / useCallStateHooks()) - fetch the matching cookbook page first (RULES.md > Docs-first). Drop to fully hand-built markup only when the user explicitly wants bespoke UI; the *-blueprints.md "Fully custom UI" fallback section has the raw element + wiring tables for that case. Feeds is headless (no prebuilt UI) - always build from its hooks.
Provisioning, Use Case Matching, and Page Flow
The pieces the React builder relies on that are not React-specific: provisioning (onboarding via getstream init, Step 1 of `SKILL.md`), use-case matching (which products a request needs), and page flow (hub-first navigation every app follows).
CLI execution: CLI usage and posture live in the root stream skill - its `SKILL.md` (Stream CLI section) and `../stream/RULES.md` (CLI safety: no guessing endpoints, read the CLI's output).Shell discipline: onebash -cper phase, nobash -ce/set -ein probes, and browser sign-in (getstream init/getstream login) always as its own unwrapped invocation (`../stream/RULES.md` > Shell discipline).
---
Provisioning
Run getstream init. It authenticates (opening a browser as its own invocation), then lets you select or create the org and app and writes the project credentials - follow its prompts and output. If the use case includes Feeds, choose a Feeds v3 region when getstream init offers the region list (other regions default to legacy v2, where v3 feed groups are unavailable). If getstream isn't installed, ask the user to install it from https://getstream.io and wait - never fetch or run an install script.
getstream init is the single onboarding step - it replaces any manual auth / org-create / app-create / config-set sequence. Don't provision orgs or apps with raw getstream api calls; let init drive the selection (interactively, or via its command file in non-interactive runs).
---
Use Case Matching
Only build with the products the user explicitly mentions. If unclear, ask.
| User says | Use case | Products |
|---|---|---|
| "Twitch", "YouTube Live", "Kick", "livestream" | Livestreaming | Video + Chat + Feeds |
| "Zoom", "Google Meet", "video call", "meeting" | Video Conferencing | Video [+ Chat] |
| "Slack", "Discord", "team chat", "channels" | Team Messaging | Chat |
| "WhatsApp", "iMessage", "DM", "messaging" | Direct Messaging | Chat [+ Video] |
| "Instagram", "Twitter", "social feed", "Reddit" | Social Feed | Feeds + Chat |
Moderation is configured via CLI during setup only. Never build moderation review UI in the app (`RULES.md` > Moderation is Dashboard-only) - review happens in the Stream Dashboard.
Video apps - decide the `video_primary_use_case` here. When this table selects Video, also decide the video_primary_use_case value using the table + precedence in `references/VIDEO.md` > Primary use case. Do not confuse it with call types - e.g. a Whatnot-style live-shopping app uses the livestream call type but sets video_primary_use_case: live-shopping, not livestreaming. This is pure metadata and does not change which products you build or which blueprints you load.
---
Page Flow
Every app needs a clear navigation structure. Users should always understand where they are and what they can do. Never drop a user into a camera/mic prompt, an empty state, or a feature-heavy screen without context.
Principle: Hub-first
After login, land on a hub - a home screen that shows what's happening and lets the user choose their path. The hub is the anchor; everything else is a destination the user navigates to intentionally.
Flow by use case
Livestreaming (Twitch, YouTube Live, Kick):
Login -> Feed hub (live streams + posts) -> Watch a stream (viewer: video + chat, no camera)
-> Go Live (explicit action -> then camera/mic setup -> streaming)- The feed hub shows live streams (if any) as prominent cards, plus regular posts below.
- Clicking a live card opens the watch view - video player + chat as a viewer. No camera permissions.
- "Go Live" is a deliberate action (button in header or dedicated screen). Only THEN prompt for camera/mic. The streamer sees a setup/preview before going live.
- Viewers and streamers are the same user type - the difference is the action they take, not the page they land on.
Video Conferencing (Zoom, Google Meet):
Login -> Lobby (list of calls or "start a call") -> Join call (camera/mic preview -> join)- Land on a lobby or call list - not directly in a call.
- Joining a call shows a preview screen (camera/mic toggles) before connecting. The user opts in.
Team Messaging (Slack, Discord):
Login -> Channel list + active channel -> Browse/search channels- Land on the channel list with the most recent channel open (or a welcome state if no channels).
Direct Messaging (WhatsApp, iMessage):
Login -> Conversation list -> Open a conversation -> Start new conversationSocial Feed (Instagram, Twitter):
Login -> Feed hub (follow users + composer + tabs: Timeline | My Posts) -> Comments -> User profiles- The user posts to their own
user:<userId>feed and reads fromtimeline:<userId>(aggregates followed users' posts). - Feed hub tabs: Use a
Tabscomponent with two views: - Timeline (default) - shows
timeline:<userId>(posts from followed users) - My Posts - shows
user:<userId>(the current user's own posts) - Refresh button: Place a refresh/reload button next to the tabs. On click, re-call
feed.getOrCreate({ watch: true })on the active feed to re-fetch the latest activities. This gives users an explicit way to refresh after follows or if real-time events are missed. - A Follow User input (username + follow button) must be visible so users can populate their timeline.
- Without following, the timeline is permanently empty - this component is not optional.
- Follow wiring: The Follow component must receive the timeline feed instance and call
timelineFeed.follow('user:targetId')- notclient.follow(). Using the feed instance keepsuseFeedActivities()in sync so the timeline updates immediately after following.
Key rules
- Camera/mic: opt-in only. Never request permissions on page load. Only when the user takes an explicit action (Go Live, Join Call).
- No empty ambiguity. If there's no content yet, show a clear empty state that tells the user what to do ("No live streams yet - be the first to Go Live").
- Navigation is visible. The user should always be able to get back to the hub. Use the App Header or a sidebar for navigation.
- One primary action per screen. The hub's primary action is browsing/discovering. The watch screen's primary action is viewing. The Go Live screen's primary action is streaming. Don't mix them.
Enhance an existing app (Track E)
For adding Stream products to an existing React project - Next.js or any other React stack (Vite, CRA, Remix, TanStack Start). Reuses the references files and SDK patterns from the scaffold flow but skips the scaffold entirely. Detect the framework first (E1) and adapt the Next.js-specific bits - the server token endpoint and the verify/build command differ on non-Next.js stacks; the Stream SDK wiring itself is identical.
Reviewing, not adding? If the user wants to audit/check an existing Stream Video integration against best practices ("is my video app production-ready?", "what am I missing?") rather than add a feature, run the Integration best-practices audit section in `references/VIDEO.md`. It is a read-only review with a fixed checklist + output contract - produce findings first, fix only if asked.
Rules: `RULES.md` (login screen first, package manager - preserve the existing project's) and the stream skill's `RULES.md` (secrets, no auto-seeding). Onboard first: run getstream init to authenticate and select or create the org + app before any npm installs, getstream env, or token routes. CLI usage and posture live in the root `../stream/SKILL.md` (Stream CLI section) and `../stream/RULES.md` > CLI safety. SDK wiring (shared with the scaffold flow): `sdk.md` and the relevant `references/<Product>.md` - enhance uses the same wiring patterns as scaffold; only the surrounding setup differs.
---
E1: Audit the existing project
Before writing any code, understand what's already in place:
1. Packages: check package.json for stream-chat, stream-chat-react, @stream-io/video-react-sdk, @stream-io/node-sdk. 2. Framework: detect from package.json - Next.js (has next; route handlers under app/ or pages/api; build = next build) vs another React stack (Vite/CRA/Remix/etc.; no Next.js /api routes; build = the project's own build script). This drives where the token endpoint lives (E3) and the verify command (E4). 3. Auth: does the app already have a server token endpoint (Next.js /api/token route, or an equivalent endpoint in the app's own backend)? If so, extend it with the new product's token - don't create a second token endpoint. 4. Credentials: check for .env with STREAM_API_KEY / STREAM_API_SECRET. If missing, run getstream init (if the dir isn't a Stream project yet) then getstream env to write them - never read or print the secret. 5. UI framework: confirm Tailwind, Shadcn, or whatever the project uses. Do not install Shadcn or change the styling setup unless the user asks. 6. Directory structure: note whether the project uses app/, src/app/, pages/, or src/ - match the existing convention.
E2: Install + configure
1. Install only the new SDKs with the project's detected package manager (from E1) - never introduce a second lockfile: npm -> npm install <new-packages> --legacy-peer-deps; yarn -> yarn add <new-packages>; pnpm -> pnpm add <new-packages>. --legacy-peer-deps is npm-only (yarn/pnpm resolve Stream peers without it). (the stream skill's `RULES.md` > Package manager.) 2. Configure via CLI: run setup commands from the relevant references/<Product>.md (App Integration -> Setup). Feeds needs feed groups created; Moderation needs blocklist + config. 3. Import CSS if the product needs it (Chat: stream-chat-react/css/index.css (v14+ preferred alias; v13 used dist/css/v2/index.css), Video: @stream-io/video-react-sdk/dist/css/styles.css).
E3: Integrate
1. Token endpoint: extend the existing token endpoint to return the new product's token alongside existing ones. On Next.js this is the /api/token route handler; on other React stacks it is the project's own server endpoint (Express/Fastify route, Remix loader/action, Vite server fn, etc.) - the server-side instantiation in `sdk.md` is identical, only the host differs. Tokens are always minted server-side (`RULES.md` > Env vars are server-side only). 2. API routes: add the product-specific server routes from references/<Product>.md (App Integration -> API Routes) - as Next.js route handlers on Next.js, or equivalent endpoints in the app's backend otherwise. Feeds needs several (/api/feed/get, /api/feed/post, etc.); Chat and Video typically only need the token endpoint. 3. Components: load the relevant references/<Product>-blueprints.md sections and build components using the existing project's patterns and styling conventions - not the `builder-ui.md` defaults. 4. State: if the app already manages user state (auth context, session), wire Stream tokens into that - don't add a separate Login Screen unless the app has no auth.
E4: Verify
Run with the project's detected package manager (do not introduce a second lockfile):
npx tsc --noEmit # or: yarn dlx tsc --noEmit / pnpm exec tsc --noEmit
npm run build # or: yarn build / pnpm build (runs the project's own build script)Fix any errors. Use the project's existing package manager and build script - do not assume npm or next build on a yarn/pnpm or non-Next.js project.
---
Key constraints
- Do not re-scaffold, re-initialize Shadcn, install frontend skills, or modify
globals.css/layout.tsx. - Do not overwrite or restructure existing files - add new files alongside them.
- Do not change the existing auth flow. Adapt Stream's token generation to fit the app's existing auth, not the other way around.
- If the project uses a different package manager (yarn, pnpm), match what it already uses - the npm-only rule applies to new scaffolds, not existing projects.
Migrate / upgrade an SDK version (Track M)
For upgrading an existing React / Next.js app's Stream SDK to a newer major/minor version ("upgrade stream-chat-react to v14", "migrate to the new SDK", "bump my Stream version"). Docs-driven and read-only until you've fetched the guide - never apply a migration from memory (`RULES.md` > Docs-first for cookbook / advanced features).
Rules: `RULES.md` (package manager, docs-first) and the cross-cutting `../stream/RULES.md`. This track does not scaffold, provision, or need to authenticate - it edits an existing project. Preserve the project's existing package manager + lockfile.
---
M1: Detect what's installed
Read the target versions from package.json (do not guess):
cat package.jsonIdentify which Stream packages are present and their current versions:
stream-chat,stream-chat-react@stream-io/video-react-sdk@stream-io/feeds-react-sdk@stream-io/node-sdk(server)
Establish from version -> to version for each package the user wants to move. If the user didn't name a target, the target is the latest published major - confirm it with npm view <pkg> version before proceeding.
Also detect the package manager from the lockfile (package-lock.json -> npm, yarn.lock -> yarn, pnpm-lock.yaml -> pnpm). Use it for every install/build below so you don't strand the active lockfile or create a stray package-lock.json.
M2: Fetch the matching release / upgrade guide (before any edit)
Match the upgrade to its guide and `WebFetch` it this turn. Known entry point:
- Chat React v13 -> v14: https://getstream.io/chat/docs/sdk/react/release-guides/upgrade-to-v14.md (also in `references/DOCS.md` > Chat Advanced Guides).
For any other package/version, discover the guide from the product llms.txt index (`references/DOCS.md` > Source indexes) - look for a release-guides/ or upgrade-* page that matches the from/to versions. Read the guide in full before touching code; note every breaking change, rename, removed export, and codemod it lists.
Hard gate ([`RULES.md`](RULES.md)): if no guide loads, hand the lookup to the stream-docs skill. If neither the guide nor stream-docs confirms the migration steps, stop and tell the user - report that you could not confirm the upgrade path and ask how to proceed. Do not migrate from memory; a guess that happens to compile is still a guess.
M3: Apply the documented changes
Work strictly from the fetched guide:
1. Bump only the packages being migrated, each to ITS OWN resolved target from M1. The Stream packages carry independent version numbers - never apply one target to several (e.g. stream-chat-react and stream-chat version separately; a single shared <target> can request a release that doesn't exist). Build the install list from the detected from->to pairs, including only the package(s) the user is actually upgrading:
# npm (examples; --legacy-peer-deps is npm-only). Chat = each package at its own target:
npm install stream-chat-react@<chatReactTarget> stream-chat@<chatTarget> --legacy-peer-deps
npm install @stream-io/video-react-sdk@<videoTarget> --legacy-peer-deps # Video
npm install @stream-io/feeds-react-sdk@<feedsTarget> --legacy-peer-deps # Feeds
# yarn: yarn add stream-chat-react@<target> stream-chat@<target>
# pnpm: pnpm add stream-chat-react@<target> stream-chat@<target>Use the detected package manager from M1 (yarn/pnpm don't need --legacy-peer-deps). Do not introduce a second lockfile or bump packages the migration doesn't touch. 2. Apply each breaking change the guide lists - renamed/removed exports (e.g. Chat v14: MessageInput -> MessageComposer; CSS path dist/css/v2/index.css -> css/index.css (the preferred aliased path; dist/css/index.css also resolves); overrides moved to <WithComponents>), changed prop/hook signatures, run any codemod the guide provides. 3. Search the codebase for each removed/renamed symbol so nothing is missed (grep -rn "<oldSymbol>" app/ src/ components/). 4. Do not introduce features the user didn't ask for; this track is an upgrade, not a redesign.
M4: Verify
npx tsc --noEmit # or: yarn dlx tsc --noEmit / pnpm exec tsc --noEmit
npm run build # or: yarn build / pnpm build (the project's own build script - do not assume next build)Fix every error the guide's changes surfaced. tsc --noEmit reports all type errors at once (renamed exports, changed signatures) - use it first. Re-run until both pass.
M5: Summarize
Report: packages bumped (from -> to), the breaking changes applied, files touched, and anything from the guide that needs manual follow-up (e.g. Dashboard config, server-side SDK bumps). Offer - do not auto-run - the natural next step (e.g. "want me to bump the server @stream-io/node-sdk too?").
Chat - component blueprints (prebuilt-first)
Setup, routes, and gotchas: CHAT.md. Rules: `../RULES.md` (reference authority, strict mode protection) and the cross-cutting ../../stream/RULES.md (secrets, no auto-seeding).
Build the common path with `stream-chat-react`'s prebuilt components and customize via the documented hooks/props. Only drop to the hand-built markup in Fully custom UI (fallback) below when the user explicitly wants fully bespoke UI - and even then, fetch the matching docs page first (`DOCS.md`).
---
Prebuilt components (default path)
CSS (once, in `app/layout.tsx` or the AppShell):
import 'stream-chat-react/css/index.css'; // v14+ preferred alias (v13 used dist/css/v2/index.css)
// If you use <EmojiPicker />, also import its stylesheet:
// import 'stream-chat-react/css/emoji-picker.css';Client (strict-mode-safe hook - never `getInstance()` on the client, [`../RULES.md`](../RULES.md) > Strict mode protection):
import { useCreateChatClient } from 'stream-chat-react';
const client = useCreateChatClient({ apiKey, tokenOrProvider: token, userData: { id: userId } });
if (!client) return null; // null until connected - gate renderingProvider hierarchy (the canonical layout):
import { Chat, ChannelList, Channel, Window, ChannelHeader,
MessageList, MessageComposer, Thread } from 'stream-chat-react';
<Chat client={client} theme={theme /* str-chat__theme-dark | str-chat__theme-light from next-themes */}>
<ChannelList filters={filters} sort={sort} options={options} />
<Channel>
<Window>
<ChannelHeader />
<MessageList />
<MessageComposer />
</Window>
<Thread />
</Channel>
</Chat>Chat and Channel are context providers; every Stream component must be a child of <Chat>. If you render `<ChannelList>`, do NOT pass a `channel` prop to `<Channel>` (the list sets the active channel); without a list, pass channel explicitly.
| Component | Purpose | Key props |
|---|---|---|
Chat | Root provider (client, active channel, theme, i18n) | client (req), theme, i18nInstance, customClasses |
ChannelList | Queries + renders channel previews; click sets active channel | filters, sort, options, showChannelSearch, setActiveChannelOnMount, customActiveChannel |
Channel | State/logic/UI for one channel; provides the channel contexts | channel (omit when using ChannelList), EmptyPlaceholder, markReadOnMount, doSendMessageRequest |
Window | Main-panel wrapper; adds the str-chat__main-panel--thread-open class when a Thread is open (it does not itself hide children) | thread (optional) |
MessageList | Standard scrollable message list | Message (per-list custom UI), messageActions, disableDateSeparator, hideDeletedMessages |
VirtualizedMessageList | Virtualized variant for high-volume channels | same message-level props + stickToBottomScrollBehavior, additionalVirtuosoProps (defaultItemHeight is deprecated - pass it via additionalVirtuosoProps) |
MessageComposer | Composer + default input UI (v14 name; not MessageInput) | audioRecordingEnabled, overrideSubmitHandler, hideSendButton, minRows/maxRows |
Thread | Parent message + replies (own list + composer) | Message, virtualized, additionalMessageListProps |
Message | Single-message logic + MessageContext (rarely rendered directly) | message, Message (custom UI), showAvatar |
ChannelHeader | Default channel header bar | title, image, Avatar |
Customization (v14 mechanism): register custom UI through the WithComponents provider, which writes to ComponentContext for its subtree - not via per-component Avatar/Input/Message props on <Channel>:
import { WithComponents } from 'stream-chat-react';
<WithComponents overrides={{ Avatar: CustomAvatar, MessageComposerUI: CustomInput, MessageUI: CustomMessage }}>
<Channel>...</Channel>
</WithComponents>MessageUI is the canonical ComponentContext key for a custom message component (Message still works as a deprecated alias). MessageList, VirtualizedMessageList, and Thread still accept a per-list Message={CustomMessage} prop for a one-off override. Read state with the documented hooks: useChatContext() (client, channel, setActiveChannel), useChannelStateContext() (messages, ...), useChannelActionContext() (sendMessage, openThread), useMessageContext() (message, isMyMessage(), ...), useComponentContext(), useTypingContext().
Bonus: ChatView (+ ChatView.Selector / .Channels / .Threads) and ThreadList / ThreadListItem for a channel/thread switcher.
Beyond the common path (docs-first expansion points) - features the blueprints above don't cover; fetch the matching `DOCS.md` row before building:
- Polls - prebuilt
Pollcomponent; create / vote (needspollsenabled on the channel type) - AI assistant / streaming responses - typewriter effect +
AIStateIndicator(AI Integrations, LangChain, Vercel AI SDK) - Voice messages - record in the composer (
audioRecordingEnabled) and play back the voice-recording attachment - Shared location - static + live location messages
- Message reminders - "remind me" / saved-for-later
- Blocking users - block / unblock a user
- Threads manager -
ChatViewchannel/thread switcher +ThreadList(unread-threads inbox) - Moderation bounce - let a user review / edit / retry their own message bounced by moderation (
MessageBounce)
Docs-first: for any customization (custom message UI, theming, reactions, composer UI, channel header, search, AI, ...) fetch the matching page from `DOCS.md` before writing - the prebuilt props and customization API evolve. Component reference pages live at https://getstream.io/chat/docs/sdk/react/components/{category}/{component}.md.
---
Fully custom UI (fallback)
Use these only when the user explicitly wants bespoke, fully hand-built UI (not the prebuilt components above). Each section gives the raw element structure + a wiring table mapping DOM to SDK calls. Still fetch the matching `DOCS.md` page first. The BEM class names are a structural spec (elements + conditional states) - implement with Shadcn components and Tailwind utilities; do not ship the BEM classes or hand-written CSS.
Channel List
Sidebar listing channels the user belongs to. Shows last message preview, unread count, and online presence.
Blueprint
<div class="channel-list">
<div class="channel-list__header">
<img class="channel-list__user-avatar" src="" alt="" />
<h2 class="channel-list__title">Messages</h2>
<button class="channel-list__compose" aria-label="New message"></button>
</div>
<!-- OPTIONAL: search -->
<div class="channel-list__search">
<input class="channel-list__search-input" type="search" placeholder="Search" />
</div>
<div class="channel-list__items" role="listbox">
<button class="channel-list__item channel-list__item--active" role="option" aria-selected="true">
<!-- Modifiers: --active (selected), --unread (has unread), --muted -->
<div class="channel-list__item-avatar">
<img src="" alt="" />
<!-- CONDITIONAL: 1:1 channel -> other user's avatar; group -> channel image or stacked avatars -->
<!-- CONDITIONAL: other user is online -->
<span class="channel-list__item-presence channel-list__item-presence--online"></span>
</div>
<div class="channel-list__item-content">
<div class="channel-list__item-top">
<span class="channel-list__item-name"></span>
<time class="channel-list__item-time"></time>
</div>
<div class="channel-list__item-bottom">
<p class="channel-list__item-preview"></p>
<!-- CONDITIONAL: channel has unread messages -->
<span class="channel-list__item-unread">3</span>
</div>
</div>
</button>
</div>
<!-- States: channel-list__loading (skeleton), channel-list__empty, channel-list__error -->
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
channel-list__items | client.queryChannels(filter, sort, { watch: true, state: true }) | - | Returns array of channel objects |
channel-list__item-avatar (1:1) | Channel members | - | Other member's user.image |
channel-list__item-avatar (group) | channel.data.image | channel.update({ image }) | channel.data.image |
channel-list__item-name (1:1) | Channel members | - | Other member's user.name |
channel-list__item-name (group) | channel.data.name | channel.update({ name }) | channel.data.name |
channel-list__item-preview | Channel state | - | channel.state.messages[last].text - truncated |
channel-list__item-time | Channel state | - | channel.state.messages[last].created_at |
channel-list__item-unread | channel.countUnread() | channel.markRead() | Returns integer |
channel-list__item-presence | User presence events | - | user.online (boolean) |
--active modifier | Client-side selection state | - | Set when user clicks channel |
| New channel | - | client.channel(type, id, { name, members }) then channel.watch() | - |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Channel queries | client.queryChannels(filter, sort, options) | Available |
| Real-time updates | { watch: true } in query options | On by default - both watch and state default to true in queryChannels |
| Presence | Dashboard -> Channel Type -> "Connect Events" enabled | Off per channel type |
| Unread counts | channel.countUnread() | Available - relies on channel.markRead() being called |
| Search | client.search(filter, query) or client.queryChannels with name filter | Available |
---
Channel Header
Top bar of an active channel. Shows channel identity, members, and actions.
Blueprint
<header class="channel-header">
<div class="channel-header__info">
<img class="channel-header__avatar" src="" alt="" />
<!-- CONDITIONAL: 1:1 -> presence dot on avatar -->
<span class="channel-header__presence channel-header__presence--online"></span>
<div class="channel-header__meta">
<h3 class="channel-header__name"></h3>
<!-- CONDITIONAL: 1:1 channel -> "Online" / "Last seen 2h ago" -->
<!-- CONDITIONAL: group channel -> "3 members, 2 online" -->
<span class="channel-header__status"></span>
</div>
</div>
<div class="channel-header__actions">
<button class="channel-header__action channel-header__action--search" aria-label="Search"></button>
<button class="channel-header__action channel-header__action--members" aria-label="Members"></button>
<!-- OPTIONAL: video/audio call -->
<button class="channel-header__action channel-header__action--call" aria-label="Call"></button>
<button class="channel-header__action channel-header__action--menu" aria-label="More"></button>
</div>
</header>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
channel-header__avatar | channel.data or member data | - | channel.data.image (group) or other member's user.image (1:1) |
channel-header__name | channel.data or member data | - | channel.data.name (group) or other member's user.name (1:1) |
channel-header__status (1:1) | Presence events | - | user.online -> "Online"; user.last_active -> "Last seen X ago" |
channel-header__status (group) | channel.state.members, channel.state.watcher_count | - | Count members + online watchers |
channel-header__action--call | - | Initiates Stream Video call (see VIDEO.md) | Cross-product integration |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Presence / last active | "Connect Events" enabled on channel type | Off |
| Watcher count | { watch: true, presence: true } on channel.watch() | Must pass explicitly |
| Video/audio calls | Stream Video product enabled | Separate product |
---
Message List
Scrollable container for messages. Handles date separators, scroll-to-bottom, and real-time message injection.
Blueprint
<div class="message-list" role="log" aria-live="polite">
<!-- CONDITIONAL: older messages available -->
<div class="message-list__load-older">
<button class="message-list__load-older-btn">Load older messages</button>
<!-- Or: IntersectionObserver sentinel at top for infinite scroll -->
</div>
<!-- Date separator -->
<div class="message-list__date-separator">
<span class="message-list__date-label">March 17, 2026</span>
</div>
<!-- System/event message -->
<div class="message-list__event">
<span class="message-list__event-text">Jane added Alex to the channel</span>
</div>
<!-- Messages grouped by sender (consecutive messages from same user) -->
<div class="message-list__group message-list__group--other">
<!-- Modifiers: --own (current user) | --other -->
<!-- First message in group shows avatar + name, rest are compact -->
<div class="message-list__item message-list__item--first">
<!-- Insert Message component -->
</div>
<div class="message-list__item message-list__item--continuation">
<!-- Insert Message component (no avatar/name, compact spacing) -->
</div>
</div>
<!-- CONDITIONAL: typing indicator -->
<div class="message-list__typing">
<!-- See Typing Indicator component -->
</div>
<!-- CONDITIONAL: user has scrolled up, new messages below -->
<button class="message-list__scroll-to-bottom">
<!-- CONDITIONAL: unread count badge -->
<span class="message-list__new-count">5</span>
</button>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Messages (initial) | channel.watch() or channel.query({ messages: { limit: 25 } }) | - | channel.state.messages |
| Messages (older) | channel.query({ messages: { limit: 25, id_lt: oldestMessageId } }) | - | Prepend to message list |
| Messages (real-time) | channel.on('message.new', callback) | - | Append new message to list |
| Message groups | Client-side grouping | - | Group consecutive messages by message.user.id within a time window |
| Date separators | Client-side | - | Insert when message.created_at crosses a day boundary |
| System events | channel.on('member.added', ...), channel.on('member.removed', ...) | - | Render as message-list__event |
| Typing indicator | channel.on('typing.start', ...), channel.on('typing.stop', ...) | - | channel.state.typing - map of userId -> event |
| Scroll to bottom | Client-side scroll position tracking | - | Show when scrollTop < threshold |
message-list__new-count | Track messages received while scrolled up | - | Client-side counter |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Message history | channel.watch() or channel.query() | Available |
| Real-time | channel.watch() establishes websocket | Automatic when channel is watched |
| Typing events | "Typing Events" enabled on channel type in Dashboard | On for most types |
| Read events | "Read Events" enabled on channel type | On for most types |
| System events | Automatic on member add/remove | Available |
---
Message
The core content unit in chat. A single message with author info, text, attachments, reactions, and thread.
Blueprint
<div class="message">
<!-- Modifiers: message--own | message--other | message--deleted | message--pinned | message--highlighted | message--system -->
<!-- message--first (first in group, shows avatar/name) -->
<!-- message--continuation (same sender, compact) -->
<!-- CONDITIONAL: message--first in group only -->
<a class="message__actor" href="/user/{user.id}">
<img class="message__avatar" src="" alt="" />
</a>
<div class="message__content">
<!-- CONDITIONAL: message--first in group only -->
<div class="message__header">
<span class="message__author"></span>
<time class="message__time" datetime=""></time>
</div>
<!-- CONDITIONAL: message.pinned === true -->
<div class="message__pinned-badge">
Pinned by <span class="message__pinned-by"></span>
</div>
<div class="message__bubble">
<!-- CONDITIONAL: message.quoted_message exists (reply/quote) -->
<div class="message__quoted">
<span class="message__quoted-author"></span>
<p class="message__quoted-text"></p>
</div>
<!-- Parse @mentions -> <a class="message__mention">, URLs -> <a class="message__link"> -->
<p class="message__text"></p>
<!-- CONDITIONAL: message.attachments has type "image" -->
<div class="message__images">
<!-- Modifiers: message__images--single | --grid -->
<figure class="message__image-item">
<img src="" alt="" />
</figure>
</div>
<!-- CONDITIONAL: message.attachments has type "video" -->
<div class="message__video">
<video class="message__video-player" src="" controls></video>
</div>
<!-- CONDITIONAL: message.attachments has type "file" -->
<div class="message__files">
<a class="message__file" href="" download>
<span class="message__file-icon"></span>
<span class="message__file-name"></span>
<span class="message__file-size"></span>
</a>
</div>
<!-- CONDITIONAL: message.attachments has og_scrape_url (link preview) -->
<a class="message__og" href="" target="_blank" rel="noopener">
<img class="message__og-image" src="" alt="" />
<div class="message__og-content">
<span class="message__og-title"></span>
<span class="message__og-description"></span>
<span class="message__og-domain"></span>
</div>
</a>
</div>
<!-- CONDITIONAL: message.deleted_at exists -->
<div class="message__deleted">This message was deleted.</div>
<!-- Reactions row (inline, beneath bubble) -->
<!-- CONDITIONAL: message has any reactions -->
<div class="message__reactions">
<!-- One pill per reaction type -->
<button class="message__reaction">
<!-- Modifier: message__reaction--own when user has reacted with this type -->
<span class="message__reaction-emoji"></span>
<span class="message__reaction-count"></span>
</button>
<!-- Add reaction button -->
<button class="message__reaction message__reaction--add" aria-label="Add reaction"></button>
</div>
<!-- CONDITIONAL: message.reply_count > 0 -->
<button class="message__thread-reply">
<div class="message__thread-avatars">
<!-- Stacked avatars of thread participants -->
<img class="message__thread-avatar" src="" alt="" />
</div>
<span class="message__thread-count"></span>
<time class="message__thread-last" datetime=""></time>
</button>
<!-- Message status (own messages only) -->
<!-- CONDITIONAL: message--own -->
<div class="message__status">
<!-- Modifiers: message__status--sending | --sent | --delivered | --read -->
<!-- Read: show stacked read receipt avatars -->
</div>
<!-- Hover/long-press action bar -->
<div class="message__actions">
<button class="message__action message__action--react" aria-label="React"></button>
<button class="message__action message__action--reply" aria-label="Reply in thread"></button>
<button class="message__action message__action--quote" aria-label="Quote"></button>
<!-- CONDITIONAL: message.user.id === currentUserId -->
<button class="message__action message__action--edit" aria-label="Edit"></button>
<button class="message__action message__action--delete" aria-label="Delete"></button>
<!-- Always visible -->
<button class="message__action message__action--pin" aria-label="Pin"></button>
<button class="message__action message__action--flag" aria-label="Flag"></button>
</div>
</div>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
message__avatar | In message payload | - | message.user.image |
message__author | In message payload | - | message.user.name |
message__time | In message payload | - | message.created_at |
message__text | In message payload | - | message.text |
message__pinned-badge | In message payload | - | message.pinned, message.pinned_by.name |
message__quoted | In message payload | - | message.quoted_message.user.name, message.quoted_message.text |
message__image-item | In message payload | - | message.attachments[].image_url where type === 'image' |
message__video-player | In message payload | - | message.attachments[].asset_url where type === 'video' |
message__file | In message payload | - | message.attachments[].asset_url, .title, .file_size where type === 'file' |
message__og-* | In message payload | - | message.attachments[].og_scrape_url, .title, .text, .image_url |
message__deleted | In message payload | - | message.deleted_at (truthy = deleted) |
message__reaction | In message payload | - | message.reaction_groups (keyed by type; each has count, sum_scores, first_reaction_at, last_reaction_at) - preferred. message.reaction_counts still works. Also message.own_reactions[] |
| Reaction - add | - | channel.sendReaction(message.id, { type: 'like' }) | Supports { enforce_unique: true } option as third arg to replace all user's existing reactions |
| Reaction - remove | - | channel.deleteReaction(message.id, 'like') | Removes current user's reaction of that type |
message__thread-count | In message payload | - | message.reply_count |
message__thread-avatars | In message payload | - | message.thread_participants[].image |
message__thread-last | Thread replies | - | Most recent reply's created_at (from channel.getReplies() or thread state) |
message__status (read) | channel.state.read | - | Map of userId -> { last_read, user } - compare with message.created_at |
| Edit | - | client.updateMessage({ id: message.id, text: newText }) | - |
| Delete | - | client.deleteMessage(message.id) | Sets message.deleted_at. Pass { hardDelete: true } for permanent deletion |
| Pin | - | client.pinMessage(message, timeoutOrExpiration) | Accepts a message object or message id. Second arg is optional: timeout in seconds, expiration date, or null for no expiry |
| Unpin | - | client.unpinMessage(message) | Accepts a message object or message id |
message__mentioned-users | In message payload | - | message.mentioned_users - enriched user objects for @mentions in the message |
| Flag | - | client.flagMessage(message.id) | See MODERATION.md |
| Mute user | - | client.muteUser(userId, null, { timeout: 60 }) | Three args: userId, null, options object. timeout is in minutes |
| Quote | - | Send new message with quoted_message_id: message.id | - |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Attachments | "Uploads" enabled on channel type | On |
| Reactions | "Reactions" enabled on channel type in Dashboard | On |
| Replies/threads | "Replies" enabled on channel type | On |
| Read receipts | "Read Events" enabled on channel type | On for most types |
| URL enrichment | "URL Enrichment" enabled on channel type | On - auto-scrapes OG data server-side |
| Pinning | "Pinning" enabled on channel type | Off |
| Quoting | quoted_message_id on sendMessage | Available - no config needed |
| Message editing | - | Available - own messages by default, admin can edit any |
| Message deletion | - | Available - own messages by default, admin can delete any |
---
Message Input
Text input for composing and sending messages. Handles attachments, mentions, slash commands, and edit mode.
Blueprint
<div class="message-input">
<!-- Modifier: message-input--editing (when editing an existing message) -->
<!-- Modifier: message-input--disabled (when user lacks send permission) -->
<!-- Modifier: message-input--thread (when in thread view) -->
<!-- CONDITIONAL: editing a message -->
<div class="message-input__edit-banner">
Editing message
<button class="message-input__edit-cancel" aria-label="Cancel edit"></button>
</div>
<!-- CONDITIONAL: replying with quote -->
<div class="message-input__quote-preview">
<span class="message-input__quote-author"></span>
<p class="message-input__quote-text"></p>
<button class="message-input__quote-remove" aria-label="Remove quote"></button>
</div>
<!-- CONDITIONAL: user has selected files to upload -->
<div class="message-input__attachments">
<div class="message-input__attachment">
<!-- Modifiers: --image | --file | --uploading | --error -->
<img class="message-input__attachment-preview" src="" alt="" />
<button class="message-input__attachment-remove" aria-label="Remove"></button>
<div class="message-input__attachment-progress">
<div class="message-input__attachment-progress-bar" style="width: 0%"></div>
</div>
</div>
</div>
<div class="message-input__composer">
<div class="message-input__tools-left">
<button class="message-input__tool message-input__tool--attach" aria-label="Attach file"></button>
</div>
<div class="message-input__text-area">
<div class="message-input__text" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Send a message"></div>
<!-- CONDITIONAL: user types "@" + characters -->
<div class="message-input__mention-dropdown">
<button class="message-input__mention-option">
<img class="message-input__mention-avatar" src="" alt="" />
<span class="message-input__mention-name"></span>
</button>
</div>
<!-- CONDITIONAL: user types "/" (slash commands) -->
<div class="message-input__command-dropdown">
<button class="message-input__command-option">
<span class="message-input__command-name">/giphy</span>
<span class="message-input__command-desc">Post a random GIF</span>
</button>
</div>
</div>
<div class="message-input__tools-right">
<button class="message-input__tool message-input__tool--emoji" aria-label="Emoji"></button>
<button class="message-input__send" aria-label="Send" disabled></button>
</div>
</div>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
message-input__text | - (user input) | Becomes message.text | - |
message-input__mention-dropdown | channel.queryMembers({ name: { $autocomplete: query } }) | - | Match typed query against channel members |
message-input__command-dropdown | channel.getConfig() | - | channel.config.commands[] - name + description |
message-input__attachment (image) | Local blob preview | channel.sendImage(file) -> CDN URL | Collect into message.attachments[] with type: 'image' |
message-input__attachment (file) | Local blob preview | channel.sendFile(file) -> CDN URL | Collect into message.attachments[] with type: 'file' |
| Attachment remove (image) | - | channel.deleteImage(url) | Deletes uploaded image from CDN when user removes before sending |
| Attachment remove (file) | - | channel.deleteFile(url) | Deletes uploaded file from CDN when user removes before sending |
| Send (new) | - | channel.sendMessage({ text, attachments, quoted_message_id?, mentioned_users? }) | mentioned_users is an array of user IDs referenced via @mentions in the text |
| Send (edit) | - | client.updateMessage({ id, text, attachments }) | - |
| Send (thread) | - | channel.sendMessage({ text, parent_id: parentMessage.id }) | - |
| Typing events | - | channel.keystroke() on input, channel.stopTyping() on pause | Debounced - SDK handles interval |
--disabled | channel.data.own_capabilities | - | Check if 'send-message' is in capabilities array |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| File uploads | "Uploads" enabled on channel type | On |
| Slash commands | Commands configured on channel type in Dashboard | /giphy available by default |
| @Mentions | Channel members queryable | Available - searches channel members |
| Typing indicators | "Typing Events" enabled on channel type | On for most |
| Message length | channel.config.max_message_length | 5000 chars (server-side default) |
| Slow mode | channel.data.cooldown (seconds) | Off - set per channel |
---
Thread
Reply thread on a specific message. Opens as a side panel or overlay.
Blueprint
<div class="thread">
<header class="thread__header">
<h3 class="thread__title">Thread</h3>
<span class="thread__count"></span>
<button class="thread__close" aria-label="Close thread"></button>
</header>
<!-- Parent message (the message being replied to) -->
<div class="thread__parent">
<!-- Insert Message component (with thread-reply button hidden) -->
</div>
<div class="thread__separator">
<span class="thread__reply-count"></span>
</div>
<!-- Reply list (same structure as Message List, but for thread replies) -->
<div class="thread__replies" role="log">
<div class="thread__reply">
<!-- Insert Message component -->
</div>
</div>
<!-- Thread-specific message input -->
<div class="thread__input">
<!-- Insert Message Input component with message-input--thread modifier -->
<!-- OPTIONAL: "Also send to channel" checkbox -->
<label class="thread__send-to-channel">
<input type="checkbox" />
Also send to #channel-name
</label>
</div>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
thread__parent | Already in message list | - | The message with reply_count > 0 |
thread__replies | channel.getReplies(parentMessage.id, { limit: 25 }) | - | Returns { messages: [...] } |
thread__replies (older) | channel.getReplies(parentId, { limit: 25, id_lt: oldestReplyId }) | - | Cursor pagination |
thread__replies (real-time) | channel.on('message.new', cb) - filter where message.parent_id === parentId | - | Append to reply list |
thread__reply-count | Parent message | - | parentMessage.reply_count |
| Reply - send | - | channel.sendMessage({ text, parent_id: parentMessage.id }) | - |
| Reply - send to channel | - | channel.sendMessage({ text, parent_id: parentMessage.id, show_in_channel: true }) | Shows reply in main channel too |
| All threads | client.queryThreads() | - | Lists all threads the current user participates in - supports pagination and filtering |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Replies/threads | "Replies" enabled on channel type in Dashboard | On |
| Thread participants | Automatic | Tracked in message.thread_participants |
---
Typing Indicator
Shows who is currently typing in the channel.
Blueprint
<!-- CONDITIONAL: channel.state.typing has entries (excluding current user) -->
<div class="typing-indicator">
<div class="typing-indicator__avatars">
<img class="typing-indicator__avatar" src="" alt="" />
<!-- Max 2-3 avatars -->
</div>
<div class="typing-indicator__dots">
<span class="typing-indicator__dot"></span>
<span class="typing-indicator__dot"></span>
<span class="typing-indicator__dot"></span>
</div>
<span class="typing-indicator__text">
<!-- 1 user: "Jane is typing" -->
<!-- 2 users: "Jane and Alex are typing" -->
<!-- 3+: "3 people are typing" -->
</span>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Typing users | channel.on('typing.start', cb), channel.on('typing.stop', cb) | channel.keystroke() / channel.stopTyping() | channel.state.typing - map of userId -> { user }, excludes current user. For threads: channel.keystroke(threadId) sends thread-specific typing events |
typing-indicator__avatar | In typing event | - | event.user.image |
| Auto-expiry | Client-side | - | Remove user from typing state after ~7s with no new typing.start event |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Typing events | "Typing Events" enabled on channel type | On for most types |
---
Emoji Reaction Picker
Overlay for selecting a reaction to add to a message. Typically triggered from message action bar or existing reaction row.
Blueprint
<div class="reaction-picker">
<!-- Quick reactions row (most common) -->
<div class="reaction-picker__quick">
<button class="reaction-picker__emoji" data-type="like">👍</button>
<button class="reaction-picker__emoji" data-type="love">❤</button>
<button class="reaction-picker__emoji" data-type="haha">😂</button>
<button class="reaction-picker__emoji" data-type="wow">😲</button>
<button class="reaction-picker__emoji" data-type="sad">😢</button>
<button class="reaction-picker__emoji" data-type="angry">😡</button>
</div>
<!-- OPTIONAL: full emoji picker with categories and search -->
<button class="reaction-picker__more" aria-label="More reactions"></button>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Emoji click | - | channel.sendReaction(message.id, { type: 'like' }) | Type is the data-type attribute. Pass { enforce_unique: true } as third arg to replace user's existing reactions |
| Toggle off | Check message.own_reactions for existing reaction of same type | channel.deleteReaction(message.id, 'like') | - |
| Available types | - | Any string works as reaction type | No configuration needed for custom types |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Reactions | "Reactions" enabled on channel type | On |
---
Search
Search messages across channels or within a specific channel.
Blueprint
<div class="search">
<div class="search__input-area">
<span class="search__icon"></span>
<input class="search__input" type="search" placeholder="Search messages" />
<!-- CONDITIONAL: query is non-empty -->
<button class="search__clear" aria-label="Clear search"></button>
</div>
<!-- CONDITIONAL: search has results -->
<div class="search__results">
<button class="search__result">
<img class="search__result-avatar" src="" alt="" />
<div class="search__result-content">
<div class="search__result-top">
<span class="search__result-author"></span>
<span class="search__result-channel">#general</span>
<time class="search__result-time"></time>
</div>
<p class="search__result-text">
<!-- Highlight matching text with <mark class="search__highlight"> -->
</p>
</div>
</button>
</div>
<!-- States: search__loading, search__empty ("No results for ...") -->
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
search__results | client.search({ members: { $in: [userId] } }, query, { limit: 20 }) | - | Returns { results: [{ message }] } |
search__results (in-channel) | client.search({ cid: channel.cid }, query, { limit: 20 }) | - | Filter by specific channel |
search__result-avatar | In result | - | result.message.user.image |
search__result-author | In result | - | result.message.user.name |
search__result-channel | In result | - | result.message.channel.name or result.message.channel.id |
search__result-text | In result | - | result.message.text - add highlights client-side |
| Result click | - | Navigate to message in channel | result.message.channel + result.message.id |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Search | "Search" enabled on channel type | On |
| Cross-channel | client.search() with filter across channels | Available |
Chat - Setup & Integration
Stream Chat provides pre-built UI components via React, React Native, Flutter, Swift, and Kotlin SDKs. This file covers setup, server routes, client patterns, and gotchas. For full component structure and wiring, see CHAT-blueprints.md.
Rules: ../RULES.md (login screen first, strict mode protection, reference authority) and the cross-cutting ../../stream/RULES.md (secrets, no auto-seeding).
- Blueprint - HTML with BEM classes defining structure and conditional rendering
- Wiring - API calls to read/write each element, exact property paths
- Requirements - Dashboard settings, API params, and prerequisites. The Default column in each Requirements table reflects server-side channel-type defaults (set in the Dashboard / via the API), not values enforced by the SDK.
Quick ref
- Packages:
stream-chat,stream-chat-react; importstream-chat-react/css/index.css(v14+ preferred alias; v13 useddist/css/v2/index.css). - First: App Integration -> Setup (CLI / channel types) before UI.
- Per feature: Jump to section (Channel List, Message List, ...) when implementing that screen.
- Below the next rule: full blueprints - do not load past it until you implement that component.
Full component blueprints: CHAT-blueprints.md - load only the section you are implementing.
---
App Integration
Everything needed to wire the UI components above into a working Next.js application.
Setup
Packages: stream-chat + stream-chat-react (client), stream-chat (server via StreamChat.getInstance)
No CLI commands needed - built-in channel types (messaging, team, livestream) work out of the box.
Server Routes
| Route | Method | Params | Action | Response |
|---|---|---|---|---|
/api/token | GET | ?user_id=xxx | client.upsertUsers([{ id, name, role: 'user' }]), client.createToken(userId) | { chatToken, apiKey } |
See RULES.md > No auto-seeding.
import { StreamChat } from 'stream-chat';
const client = StreamChat.getInstance(process.env.STREAM_API_KEY!, process.env.STREAM_API_SECRET!);Client Patterns
- Login Screen first: See RULES.md > Login Screen first + builder-ui.md > Login Screen.
- App Header: Show the current username + avatar (initial letter) + "Switch User" in a persistent header above the chat layout. See `builder-ui.md` -> App Header.
- Use `useCreateChatClient`: the SDK ships an official hook that handles strict-mode, instantiation,
connectUser, and cleanup. Never wireconnectUser/disconnectUsermanually - they race with strict-mode double-mount and produce "You can't use a channel after client.disconnect was called".
import { useCreateChatClient } from "stream-chat-react";
const chatClient = useCreateChatClient({
apiKey,
tokenOrProvider: chatToken,
userData: { id: userId, name },
});
if (!chatClient) return <Loading />;- Hoist `<Chat>` to AppShell: mount
<Chat client={chatClient}>once at the app root, alongside<StreamVideo>/<StreamFeeds>. Per-screen components only render<Channel channel={...}>from the existing client. Never instantiate a new `StreamChat` per screen - the cleanup of one screen's effect will disconnect the client another screen is still using. See `CROSS-PRODUCT.md` for the full multi-product AppShell skeleton. - Channel switching: the client is long-lived; only swap the
channelprop on<Channel>when the conversation changes. On per-channel unmount callchannel.stopWatching()- neverclient.disconnectUser(). - Theme:
useTheme()fromnext-themes- passstr-chat__theme-darkorstr-chat__theme-lightto<Chat>based onresolvedTheme. - Strict mode: See RULES.md > Strict mode protection.
useCreateChatClientalready handles this for you.
Gotchas
- Always generate real tokens server-side via
client.createToken()- neverdevToken() StreamChat.getInstance(apiKey, apiSecret)is fine server-side (singleton OK)client.channel(type, id, { name, image, members })- the 3rd arg accepts custom channel data (ChannelData); Stream's own tutorial doesclient.channel('livestream', 'spacex', { name, image }).- SDK uses module augmentation for custom data types. A custom channel field like
nameand a customchannel.sendEvent({ type: 'bid.placed', ... })both raise a type error by default. Declare custom fields and events using module augmentation and interface merging:
import "stream-chat"
declare module "stream-chat" {
interface CustomChannelData {
name?: string // add your custom channel fields here
}
interface CustomEventTypes {
"bid.placed": true // your custom event type
}
interface CustomEventData {
payload?: Record<string, unknown> // your custom event payload shape
}
}- Listen for
user.bannedevent to show banned state in UI - Import
stream-chat-react/css/index.cssfor default styles - the preferred aliased path (dist/css/index.cssalso resolves; v14+, the/v2/subpath was removed). If you useEmojiPicker, also importstream-chat-react/css/emoji-picker.css MessageInputwas renamed/removed in v14 - useMessageComposerfromstream-chat-reactinstead. Note: the React<MessageComposer />UI component is distinct from theMessageComposerstate class instream-chat(same name, different thing)- Token endpoint as
GET /api/token?user_id=xxx upsertUserstakes an array of user objects:client.upsertUsers([{ id, name, role }])- NOT an object keyed by ID<Chat>lives at app root;<Channel>is what swaps per conversation. Don't construct/destructStreamChatper screen.
Cross-product AppShell - canonical pattern
When using two or more of Chat / Video / Feeds in the same app, mount all clients once at AppShell and provide them at the root. Per-screen components only render <Channel>, <StreamCall>, or <StreamFeed> from the existing providers - never re-instantiate the clients.
Source of truth: video/react/10-advanced/06-chat-with-video.md from the Stream docs (the messenger-clone reference app).
AppShell skeleton
"use client";
import { useEffect, useState } from "react";
import { Chat, useCreateChatClient } from "stream-chat-react";
import { StreamVideo, StreamVideoClient } from "@stream-io/video-react-sdk";
import {
StreamFeeds,
useCreateFeedsClient,
type Feed,
} from "@stream-io/feeds-react-sdk";
import { useTheme } from "next-themes";
import "stream-chat-react/css/index.css";
import "@stream-io/video-react-sdk/dist/css/styles.css";
type Auth = {
apiKey: string;
userId: string;
name: string;
chatToken: string;
feedToken: string;
// videoToken is not stored - the video client's tokenProvider re-fetches it from /api/token
};
export default function AppShell({ auth, children }: { auth: Auth; children: React.ReactNode }) {
const { resolvedTheme } = useTheme();
// CHAT - official hook handles strict-mode + lifecycle
const chatClient = useCreateChatClient({
apiKey: auth.apiKey,
tokenOrProvider: auth.chatToken,
userData: { id: auth.userId, name: auth.name },
});
// FEEDS - official hook handles strict-mode + lifecycle
const feedsClient = useCreateFeedsClient({
apiKey: auth.apiKey,
tokenOrProvider: auth.feedToken,
userData: { id: auth.userId, name: auth.name },
});
// VIDEO - useState + useEffect (NOT useMemo) - replica of the canonical snippet in
// VIDEO.md > Client Patterns; keep in sync (edit there first)
const [videoClient, setVideoClient] = useState<StreamVideoClient>();
useEffect(() => {
// tokenProvider INSIDE the effect (identity trap - see VIDEO.md); re-fetches on expiry
const tokenProvider = () =>
fetch(`/api/token?user_id=${auth.userId}`)
.then((r) => r.json())
.then((d) => d.videoToken as string);
const c = new StreamVideoClient({
apiKey: auth.apiKey,
user: { id: auth.userId, name: auth.name },
tokenProvider,
});
setVideoClient(c);
return () => {
c.disconnectUser().catch(console.error);
setVideoClient(undefined);
};
}, [auth.apiKey, auth.userId, auth.name]);
if (!chatClient || !feedsClient || !videoClient) return <Loading />;
const themeClass = resolvedTheme === "dark" ? "str-chat__theme-dark" : "str-chat__theme-light";
return (
<Chat client={chatClient} theme={themeClass}>
<StreamVideo client={videoClient}>
<StreamFeeds client={feedsClient}>{children}</StreamFeeds>
</StreamVideo>
</Chat>
);
}The order of <Chat> / <StreamVideo> / <StreamFeeds> doesn't matter - they don't depend on each other. Each provides a context that the per-screen components read.
useCreateChatClient and useCreateFeedsClient also accept a provider function as tokenOrProvider - pass one instead of the static token for long-lived sessions where chat/feed tokens may expire.
Per-screen pattern
Inside any screen (Hub, Watch, GoLive, etc.):
import { useChatContext, Channel, Window, MessageList, MessageComposer } from "stream-chat-react";
import type { Channel as StreamChannel } from "stream-chat";
import { useStreamVideoClient, StreamCall } from "@stream-io/video-react-sdk";
import { useFeedsClient, StreamFeed } from "@stream-io/feeds-react-sdk";
function WatchScreen({ callId }: { callId: string }) {
const { client: chatClient } = useChatContext(); // from <Chat>
const videoClient = useStreamVideoClient(); // from <StreamVideo>
const feedsClient = useFeedsClient(); // from <StreamFeeds>
// create a per-screen channel/call/feed from the long-lived clients
const [channel, setChannel] = useState<StreamChannel | null>(null);
useEffect(() => {
if (!chatClient) return;
const ch = chatClient.channel("livestream", callId);
ch.watch().then(() => setChannel(ch));
return () => { ch.stopWatching().catch(() => {}); };
}, [chatClient, callId]);
// ... etc
}Cleanup is per-resource, not per-client:
- Channel:
channel.stopWatching()(NEVERchatClient.disconnectUser()). - Call:
call.leave()(NEVERvideoClient.disconnectUser()). - Feed: usually no cleanup needed; the
<StreamFeeds>provider keeps state alive.
Common error -> cause -> fix
| Symptom | Cause | Fix |
|---|---|---|
User token is not set... disconnect was called (video) | useMemo for StreamVideoClient; strict-mode disconnects the same instance reused on remount | useState + useEffect with empty cleanup; setClient(undefined) |
You can't use a channel after client.disconnect was called (chat) | new StreamChat() created per screen; cleanup races with channel.watch() | Hoist <Chat> to root via useCreateChatClient; per-screen only does client.channel(...).watch() + stopWatching() |
user_id is required for server side requests | Server-side client.feeds.* mutation missing user_id | Pass acting user's id; required for addActivity, updateActivity, addComment, etc (NOT deleteActivity). See FEEDS.md |
No permission to publish VIDEO / AUDIO (livestream) | livestream call_member/host roles default to *-owner grants only | Grant unrestricted send-video + send-audio to `user`, `call_member`, AND `host` roles; join with data: { members: [{ user_id, role: "host" }] }. See VIDEO.md |
| "Setting up your camera..." never clears | useEffect bails on strict-mode remount due to useRef lock | Use mounted-flag cleanup; setCall after join, then enable camera/mic in independent try/catch blocks |
MessageInput undefined import (chat) | Renamed in stream-chat-react v14 | Use MessageComposer from stream-chat-react |
Module not found: stream-chat-react/dist/css/v2/index.css | v14 removed the /v2/ subpath | Import stream-chat-react/css/index.css (preferred alias; dist/css/index.css also works) |
Token route
Single /api/token endpoint that mints all needed tokens in one round-trip:
import { NextRequest, NextResponse } from "next/server";
import { StreamClient } from "@stream-io/node-sdk";
import { StreamChat } from "stream-chat";
const apiKey = process.env.STREAM_API_KEY!;
const apiSecret = process.env.STREAM_API_SECRET!;
const videoClient = new StreamClient(apiKey, apiSecret);
const chatClient = StreamChat.getInstance(apiKey, apiSecret);
export async function GET(req: NextRequest) {
const userId = req.nextUrl.searchParams.get("user_id");
if (!userId) return NextResponse.json({ error: "user_id required" }, { status: 400 });
const sanitized = userId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
await Promise.all([
videoClient.upsertUsers([{ id: sanitized, name: userId, role: "user" }]),
chatClient.upsertUsers([{ id: sanitized, name: userId, role: "user" }]),
]);
return NextResponse.json({
apiKey,
userId: sanitized,
name: userId,
chatToken: chatClient.createToken(sanitized),
videoToken: videoClient.generateUserToken({ user_id: sanitized }),
feedToken: videoClient.generateUserToken({ user_id: sanitized }),
});
}Only upsert the requesting user - never seed demo users (RULES.md > No auto-seeding).
Stream React (web) - docs-first manifest
Best practices live in the live docs, not in training data. Before implementing any feature that matches a trigger row below, fetch the matching docs page first, read its current guidance, then build to match. Stream's React SDKs evolve quickly; the component reference, cookbook, and advanced-guide pages are the authority for the current API and the recommended pattern.
This skill is prebuilt-component-first (`../RULES.md` > Reference authority): build the common path with the SDK's prebuilt components and customize via the documented hooks/props on the pages below.
Docs-first protocol
1. Match the user's request against a trigger row below (Chat / Video / Feeds; component, cookbook, or advanced guide), or against the relevant index when it does not match a row. 2. Fetch before coding - WebFetch the page's .md URL (every row links the raw markdown). Read it this turn. 3. Implement to match the fetched guidance - do not write the feature from memory or from the bundled blueprints alone. The blueprints in *-blueprints.md cover the common path; these pages cover the customization/advanced path and override memory. 4. On fetch failure - hand the lookup to the stream-docs skill (live docs with citations) before coding. If neither the page nor stream-docs resolves it, stop and ask the user - do not implement from memory. Never guess the API.
This protocol is a non-negotiable rule - see `../RULES.md` > Docs-first for cookbook / advanced features. It also governs migration (Track M) - fetch the release/upgrade guide before applying an upgrade (`../migrate.md`).
Source indexes (llms.txt)
The full per-product page list lives in these manifests. Fetch the index when a request does not match a row below, or to discover newer pages:
- Chat React (SDK): https://getstream.io/chat/docs/sdk/react/llms.txt
- Chat React (API): https://getstream.io/chat/docs/react/llms.txt
- Video React: https://getstream.io/video/docs/react/llms.txt
- Feeds React: https://getstream.io/activity-feeds/docs/react/llms.txt
---
Chat React - prebuilt components (build the common path with these)
| Trigger keywords | Component | Fetch first |
|---|---|---|
| chat root, provider, client setup, useCreateChatClient | Chat | https://getstream.io/chat/docs/sdk/react/components/core-components/chat.md |
| channel list, channel sidebar, list of channels | ChannelList | https://getstream.io/chat/docs/sdk/react/components/core-components/channel-list.md |
| channel, active channel, channel provider | Channel | https://getstream.io/chat/docs/sdk/react/components/core-components/channel.md |
| message list | MessageList | https://getstream.io/chat/docs/sdk/react/components/core-components/message-list.md |
| virtualized list, large channel, high volume messages | VirtualizedMessageList | https://getstream.io/chat/docs/sdk/react/components/core-components/virtualized-list.md |
| message composer, message input, send box (v14: MessageComposer, not MessageInput) | MessageComposer | https://getstream.io/chat/docs/sdk/react/components/message-composer/message-composer.md |
| thread, replies panel | Thread | https://getstream.io/chat/docs/sdk/react/components/core-components/thread.md |
| message component, message UI | Message | https://getstream.io/chat/docs/sdk/react/components/message-components/message.md |
| window, main panel layout | Window | https://getstream.io/chat/docs/sdk/react/components/utility-components/window.md |
| channel state context, read channel state in a custom component | ChannelStateContext | https://getstream.io/chat/docs/sdk/react/components/contexts/channel-state-context.md |
| message context, read message state in a custom component | MessageContext | https://getstream.io/chat/docs/sdk/react/components/contexts/message-context.md |
Chat React - feature components (beyond the common path)
First-class prebuilt features the common-path blueprints don't cover. Most need a Dashboard/channel-type toggle (e.g. polls) - fetch the page before building.
| Trigger keywords (in the user's request) | Topic | Fetch first |
|---|---|---|
| poll, create poll, vote, poll message | Poll | https://getstream.io/chat/docs/sdk/react/components/message-components/poll.md |
| voice message, record audio, audio recorder, record voice in composer | Audio Recorder (composer) | https://getstream.io/chat/docs/sdk/react/components/message-composer/audio-recorder.md |
| voice recording attachment, play voice message, waveform | Voice Recording Attachment | https://getstream.io/chat/docs/sdk/react/components/message-components/attachment/voice-recording.md |
| chat view, channel + thread switcher, ChatView.Selector/.Channels/.Threads | ChatView | https://getstream.io/chat/docs/sdk/react/components/utility-components/chat-view.md |
| thread list, threads manager, unread threads, ThreadList/ThreadListItem | ThreadList | https://getstream.io/chat/docs/sdk/react/components/core-components/thread-list.md |
| message bounce, review bounced message, moderation prompt on own message | MessageBounceContext | https://getstream.io/chat/docs/sdk/react/components/contexts/message-bounce-context.md |
Chat React - UI Cookbook (customization + theming recipes)
| Trigger keywords (in the user's request) | Topic | Fetch first |
|---|---|---|
| typing indicator, "user is typing", typing dots | Typing Indicator | https://getstream.io/chat/docs/sdk/react/guides/customization/typing-indicator.md |
| customize message UI, custom message component, message rendering, renderText | Message UI | https://getstream.io/chat/docs/sdk/react/guides/theming/message-ui.md |
| message actions, message context menu, custom action, message hover menu | Message Actions | https://getstream.io/chat/docs/sdk/react/guides/theming/actions/message-actions.md |
| reactions, custom reactions, emoji reaction set, reaction selector | Reactions Customization | https://getstream.io/chat/docs/sdk/react/guides/theming/reactions.md |
| message composer UI, input UI, custom composer, message input styling | Message Composer UI | https://getstream.io/chat/docs/sdk/react/guides/theming/input-ui.md |
| channel header, custom channel header | Channel Header | https://getstream.io/chat/docs/sdk/react/guides/customization/channel-header.md |
| channel list UI, channel preview, channel list item, custom preview | Channel List UI | https://getstream.io/chat/docs/sdk/react/guides/customization/channel-list-preview.md |
| emoji picker, custom emoji picker | Emoji Picker | https://getstream.io/chat/docs/sdk/react/guides/customization/emoji-picker.md |
| autocomplete, suggestion list, command autocomplete, mention suggestions | Autocomplete Suggestions | https://getstream.io/chat/docs/sdk/react/guides/customization/suggestion-list.md |
| link preview, URL preview, OG preview in composer | Link Previews in Message Composer | https://getstream.io/chat/docs/sdk/react/guides/customization/link-previews.md |
| pin indicator, pinned message badge | Pin Indicator | https://getstream.io/chat/docs/sdk/react/guides/customization/pin-indicator.md |
| thread header, customize thread header | Thread Header | https://getstream.io/chat/docs/sdk/react/guides/customization/thread-header.md |
| search menu, app menu | Search Menu | https://getstream.io/chat/docs/sdk/react/guides/customization/app-menu.md |
| search customization, customize channel search | Search Customization | https://getstream.io/chat/docs/sdk/react/guides/customization/channel-search.md |
| collapsible sidebar, collapse sidebar | Collapsible Sidebar | https://getstream.io/chat/docs/sdk/react/guides/customization/collapsible-sidebar.md |
| system message, custom system message | System Message | https://getstream.io/chat/docs/sdk/react/guides/customization/system-message.md |
| system notification banner, connection banner | System notification banner | https://getstream.io/chat/docs/sdk/react/guides/customization/system-notification-banner.md |
| mentions actions, @mention click, mention handler | Mentions Actions | https://getstream.io/chat/docs/sdk/react/guides/theming/actions/mentions-actions.md |
| attachment actions, custom attachment action | Attachment Actions | https://getstream.io/chat/docs/sdk/react/guides/theming/actions/attachment-actions.md |
| hide channel history, hide history for new members | Hide Channel History For Newly Added Members | https://getstream.io/chat/docs/sdk/react/guides/customization/hide-channel-history-for-new-members.md |
| localization, i18n, translations, language, locale | Localization | https://getstream.io/chat/docs/sdk/react/guides/theming/translations.md |
Chat React - Advanced Guides
| Trigger keywords (in the user's request) | Topic | Fetch first |
|---|---|---|
| AI assistant, AI chat, streaming AI response, typewriter effect, AI bot | AI Integrations | https://getstream.io/chat/docs/sdk/react/guides/ai-integrations.md |
| LangChain | Stream Chat LangChain SDK | https://getstream.io/chat/docs/sdk/react/guides/ai-integrations/stream-chat-langchain-sdk.md |
| AI SDK (Vercel), Stream Chat AI SDK | Stream Chat AI SDK | https://getstream.io/chat/docs/sdk/react/guides/ai-integrations/stream-chat-ai-sdk.md |
| advanced search, message search, search filters | Advanced Search | https://getstream.io/chat/docs/sdk/react/guides/advanced-search.md |
| multiple channel lists, multiple lists, several channel lists | Multiple Lists | https://getstream.io/chat/docs/sdk/react/guides/multiple-channel-lists.md |
| channel list infinite scroll, paginate channels, load more channels | Infinite Scroll | https://getstream.io/chat/docs/sdk/react/guides/channel-list-infinite-scroll.md |
| read state, read receipts, unread count, mark read | Channel Read State | https://getstream.io/chat/docs/sdk/react/guides/channel-read-state.md |
| online status, presence, member list, who is online | Channel Members and Online Status | https://getstream.io/chat/docs/sdk/react/guides/channel-user-lists.md |
| location sharing, share location, live location | Location Sharing | https://getstream.io/chat/docs/sdk/react/guides/location-sharing.md |
| blocking users, block user, unblock | Blocking Users | https://getstream.io/chat/docs/sdk/react/guides/blocking-users.md |
| message reminders, remind me, saved for later | Message Reminders | https://getstream.io/chat/docs/sdk/react/guides/message-reminders.md |
| notifications, web push, push notifications, browser notifications | Notifications | https://getstream.io/chat/docs/sdk/react/guides/notifications.md |
| attachment previews, composer attachment preview | Attachment Previews in Message Composer | https://getstream.io/chat/docs/sdk/react/guides/message-composer/attachment-previews.md |
| audio playback, voice message playback, play audio attachment | Audio Playback | https://getstream.io/chat/docs/sdk/react/guides/audio-playback.md |
| date formatting, time formatting, timestamp format, dayjs | Date and time formatting | https://getstream.io/chat/docs/sdk/react/guides/date-time-formatting.md |
| state management, state store, useStateStore, subscribe to state | SDK State Management | https://getstream.io/chat/docs/sdk/react/guides/sdk-state-management.md |
| dialog management, dialogs, modal management | Dialog Management | https://getstream.io/chat/docs/sdk/react/guides/dialog-management.md |
| custom data types, custom fields, TypeScript generics, typing custom data | TypeScript & Custom Data Types | https://getstream.io/chat/docs/sdk/react/guides/typescript-and-custom-data-types.md |
| chat plus video, call from chat, video in chat, audio call in chat | Video & Audio by Stream | https://getstream.io/chat/docs/sdk/react/guides/video-integration/video-integration-stream.md |
| upgrade to v14, migrate chat react, v13 to v14, breaking changes | Upgrade to v14 | https://getstream.io/chat/docs/sdk/react/release-guides/upgrade-to-v14.md |
---
Video React - prebuilt components (build the common path with these)
| Trigger keywords | Component | Fetch first |
|---|---|---|
| video root provider, StreamVideo, video client setup | StreamVideo | https://getstream.io/video/docs/react/ui-components/core/stream-video.md |
| StreamCall, call provider | StreamCall | https://getstream.io/video/docs/react/ui-components/core/stream-call.md |
| call layout, SpeakerLayout, PaginatedGridLayout, LivestreamLayout, grid/speaker view | Call layout | https://getstream.io/video/docs/react/ui-components/core/call-layout.md |
| participant tile, ParticipantView, render one participant | ParticipantView | https://getstream.io/video/docs/react/ui-components/core/participant-view.md |
| call controls, mic/camera/screenshare buttons, leave button | Call Control Actions | https://getstream.io/video/docs/react/ui-components/call/call-controls.md |
| device settings, camera/mic/speaker selector | Device settings | https://getstream.io/video/docs/react/ui-components/participants/device-settings.md |
| video preview, lobby self-view | Video preview | https://getstream.io/video/docs/react/ui-components/participants/video-preview.md |
Video React - UI Cookbook (customization recipes)
| Trigger keywords (in the user's request) | Topic | Fetch first |
|---|---|---|
| replace call controls, custom control bar, custom buttons | Replacing Call Controls | https://getstream.io/video/docs/react/ui-cookbook/replacing-call-controls.md |
| lobby, pre-join screen, preview before joining | Lobby Preview | https://getstream.io/video/docs/react/ui-cookbook/lobby-preview.md |
| picture in picture, PiP, pop-out video | Picture-in-Picture | https://getstream.io/video/docs/react/ui-cookbook/picture-in-picture.md |
| network quality, connection indicator, signal bars | Network Quality Indicator | https://getstream.io/video/docs/react/ui-cookbook/network-quality-indicator.md |
| watch livestream, viewer, HLS player, LivestreamPlayer | Watching a livestream | https://getstream.io/video/docs/react/ui-cookbook/watching-a-livestream.md |
| ringing, incoming call, accept/reject call | Ringing Call | https://getstream.io/video/docs/react/ui-cookbook/ringing-call.md |
Custom call layouts, runtime layout switching, transcriptions, closed captions, audio rooms, and more cookbook recipes are not listed above - fetch the Video React index (top of this file) and look for ui-cookbook/<slug>.md before implementing.Video React - Advanced Guides
| Trigger keywords (in the user's request) | Topic | Fetch first |
|---|---|---|
| best practices, production readiness, audit (Track F anchor) | Integration Best Practices | https://getstream.io/video/docs/react/advanced/integration-best-practices.md |
| chat with video, chat alongside call, call + chat | Chat Integration | https://getstream.io/video/docs/react/advanced/chat-with-video.md |
| recording, record call, start/stop recording | Recording | https://getstream.io/video/docs/react/advanced/recording.md |
| broadcasting, HLS/RTMP egress, restream to YouTube/Twitch | Broadcasting (HLS/RTMP egress only - NOT the WebRTC host flow) | https://getstream.io/video/docs/react/advanced/broadcasting.md |
| video filters, background blur, audio filters | Video & Audio filters | https://getstream.io/video/docs/react/advanced/apply-video-filters.md |
---
Feeds React (v3) - headless; build from hooks
Feeds has no prebuilt UI components - always build from its hooks (useFeedActivities, useActivityComments, ...) per `FEEDS-blueprints.md`. Fetch the page first for the current hook/method API.
| Trigger keywords (in the user's request) | Topic | Fetch first |
|---|---|---|
| feeds setup, install, client, useCreateFeedsClient, quick start | Quick Start | https://getstream.io/activity-feeds/docs/react/react.md |
| installation, packages | Installation | https://getstream.io/activity-feeds/docs/react/installation.md |
| post, activity, addActivity, create post | Activities | https://getstream.io/activity-feeds/docs/react/activities.md |
| read a feed, timeline, useFeedActivities, getOrCreate, pagination | Feeds | https://getstream.io/activity-feeds/docs/react/feeds.md |
| comments, useActivityComments, addComment, reply | Comments | https://getstream.io/activity-feeds/docs/react/comments.md |
| reactions, like, addActivityReaction | Reactions | https://getstream.io/activity-feeds/docs/react/reactions.md |
| bookmarks, save post, addBookmark | Bookmarks | https://getstream.io/activity-feeds/docs/react/bookmarks.md |
| follow, unfollow, timeline feed follow | Follow and Unfollow | https://getstream.io/activity-feeds/docs/react/follows.md |
| notification feed, aggregated, useAggregatedActivities, useNotificationStatus | Notification Feeds | https://getstream.io/activity-feeds/docs/react/notification-feeds.md |
| polls, vote, poll activity | Polls | https://getstream.io/activity-feeds/docs/react/polls.md |
| mentions, @mention, user mentions in posts | User Mentions | https://getstream.io/activity-feeds/docs/react/user-mentions.md |
| url preview, link preview, OG enrichment | URL Previews | https://getstream.io/activity-feeds/docs/react/url-previews.md |
| for you feed, algorithmic feed, personalized | For You Feed | https://getstream.io/activity-feeds/docs/react/for-you-feed.md |
| ranking, custom ranking, sort activities | Ranking | https://getstream.io/activity-feeds/docs/react/custom-ranking.md |
When a request names a feature not in the tables above, fetch the matching product index (top of this file) and find the page before implementing.
Moderation - component blueprints (end-user actions only)
Setup, routes, and gotchas: MODERATION.md. Rules: `../RULES.md` > Moderation is Dashboard-only.
This file covers end-user moderation actions only (report, block, mute, blocked-list). Moderation review UI - review queue, flagged-item cards, auto-mod status panels - is deliberately not bundled: review happens exclusively in the Stream Dashboard, and the builder must never recreate those blueprints (`../RULES.md` > Moderation is Dashboard-only).
The BEM class names below are a structural spec (elements + conditional states) - implement with Shadcn components and Tailwind utilities; do not ship the BEM classes or hand-written CSS.
---
Report Modal
End-user dialog for reporting content or users. Triggered from message/activity context menus.
Blueprint
<dialog class="report-modal">
<header class="report-modal__header">
<h3 class="report-modal__title">Report</h3>
<button class="report-modal__close" aria-label="Close"></button>
</header>
<form class="report-modal__form">
<p class="report-modal__context">
<!-- Shows what is being reported: message preview, activity preview, or user name -->
</p>
<fieldset class="report-modal__reasons">
<legend class="report-modal__reasons-label">Why are you reporting this?</legend>
<label class="report-modal__reason">
<input type="radio" name="reason" value="spam" />
Spam
</label>
<label class="report-modal__reason">
<input type="radio" name="reason" value="harassment" />
Harassment
</label>
<label class="report-modal__reason">
<input type="radio" name="reason" value="inappropriate" />
Inappropriate content
</label>
<label class="report-modal__reason">
<input type="radio" name="reason" value="other" />
Other
</label>
</fieldset>
<!-- CONDITIONAL: "other" selected -->
<textarea class="report-modal__details" placeholder="Provide additional details..." rows="3"></textarea>
<div class="report-modal__actions">
<button class="report-modal__cancel" type="button">Cancel</button>
<button class="report-modal__submit" type="submit" disabled>Report</button>
</div>
</form>
<!-- Success state after submission -->
<div class="report-modal__success">
<span class="report-modal__success-icon"></span>
<p class="report-modal__success-text">Thanks for reporting. We'll review this shortly.</p>
<button class="report-modal__success-close">Done</button>
</div>
</dialog>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Report message (Chat) | - | client.flagMessage(message.id) | Flags message for admin review |
| Report user (Chat) | - | client.flagUser(userId) | Flags user |
| Report activity (Feeds v3) | - | Docs-first: fetch the current Moderation API page before wiring (hand to stream-docs on fetch failure) - v3 flagging goes through the Moderation API, not reactions | Never wire this call from memory (`../RULES.md` > Docs-first) |
| Report reason | - | Pass as reason param or in custom data | Client-side value from radio selection |
| Report details | - | Include in flag custom data | Optional text from textarea |
--submit enabled | At least one reason selected | - | Client-side validation |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Chat message flagging | - | Available - client.flagMessage() always available |
| Chat user flagging | - | Available - client.flagUser() always available |
| Feeds flagging (v3) | Moderation API - fetch the current docs page first | Not reaction-based |
| Flag review | Dashboard -> Moderation dashboard | Available - flags appear in admin dashboard |
| Custom reasons | Client-side | No config - include reason in flag custom data |
| Webhook on flag | Dashboard -> Webhooks -> message.flagged / user.flagged events | Off - enable to notify external systems |
---
Block / Mute Controls
End-user controls for blocking and muting other users or channels. Typically surfaced in user profile popovers or channel settings.
Blueprint
<!-- User-level block/mute (in user profile popover or settings) -->
<div class="user-moderation">
<button class="user-moderation__btn user-moderation__btn--mute" aria-pressed="false">
<!-- aria-pressed="true" + --active when user is muted -->
<span class="user-moderation__icon user-moderation__icon--mute"></span>
Mute user
</button>
<button class="user-moderation__btn user-moderation__btn--block" aria-pressed="false">
<span class="user-moderation__icon user-moderation__icon--block"></span>
Block user
</button>
</div>
<!-- Channel-level mute (in channel settings) -->
<div class="channel-moderation">
<button class="channel-moderation__btn channel-moderation__btn--mute" aria-pressed="false">
<span class="channel-moderation__icon channel-moderation__icon--mute"></span>
Mute channel
<!-- Muted channels don't trigger notifications; still visible in channel list with --muted modifier -->
</button>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Mute user (Chat) | client.mutedUsers | client.muteUser(userId) | client.mutedUsers[] - array of { target, created_at } |
| Unmute user (Chat) | client.mutedUsers | client.unmuteUser(userId) | - |
| Block user (1:1) | - | client.blockUser(userId) | Hides DM channels, stops push notifications - for 1:1 blocking between end users |
| Unblock user (1:1) | - | client.unBlockUser(userId) | Reverses blockUser - restores DM visibility |
| Ban user (channel) | - | channel.banUser(userId) | Prevents posting in the channel - different from 1:1 blocking |
| Unban user (channel) | - | channel.unbanUser(userId) | - |
| Ban user (global) | - | client.banUser(userId, { banned_by_id: currentUserId }) | Global ban across all channels |
| Shadow ban (channel) | - | channel.shadowBan(userId) | User can post but messages only visible to them |
| Remove shadow ban | - | client.removeShadowBan(userId) or channel.removeShadowBan(userId) | Reverses shadow ban |
| Mute channel | client.mutedChannels | channel.mute() | client.mutedChannels[] |
| Unmute channel | client.mutedChannels | channel.unmute() | - |
| Check if muted | client.mutedUsers.find(m => m.target.id === userId) | - | Truthy = muted |
| Check channel muted | channel.muteStatus() | - | Returns { muted, createdAt, expiresAt } |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| User mute | - | Available - muted user's messages hidden client-side |
| User block (1:1) | - | Available - client.blockUser() hides DM channels and stops push |
| User ban (channel) | User must have 'ban-members' capability | Admins/moderators by default |
| User ban (global) | Server-side only or admin user | Requires server auth |
| Channel mute | - | Available - suppresses notifications, channel still accessible |
| Shadow ban | channel.shadowBan(userId) | Available - user can post but messages only visible to them |
| Remove shadow ban | client.removeShadowBan(userId) or channel.removeShadowBan(userId) | Available - reverses shadow ban |
---
Blocked Users List
End-user settings page showing users they've blocked or muted, with unblock/unmute actions.
Blueprint
<div class="blocked-list">
<header class="blocked-list__header">
<h3 class="blocked-list__title">Blocked & Muted</h3>
</header>
<!-- Tab toggle -->
<div class="blocked-list__tabs">
<button class="blocked-list__tab blocked-list__tab--active" data-tab="blocked">Blocked</button>
<button class="blocked-list__tab" data-tab="muted">Muted</button>
</div>
<div class="blocked-list__items">
<div class="blocked-list__item">
<img class="blocked-list__item-avatar" src="" alt="" />
<div class="blocked-list__item-info">
<span class="blocked-list__item-name"></span>
<time class="blocked-list__item-since"></time>
<!-- CONDITIONAL: muted with expiry -->
<span class="blocked-list__item-expires">Expires in 3 days</span>
</div>
<button class="blocked-list__item-action">
<!-- "Unblock" or "Unmute" depending on active tab -->
</button>
</div>
</div>
<!-- States: blocked-list__empty ("You haven't blocked anyone") -->
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Blocked users | client.getBlockedUsers() | - | Returns { blocks: [...] } - the users the current user has blocked. NOT queryBannedUsers() - bans are an admin op, blocking is the 1:1 end-user feature |
| Muted users | client.mutedUsers | - | Available on client.connectUser() response |
| Muted channels | client.mutedChannels | - | Available on client.connectUser() response |
blocked-list__item-since | Block/mute data | - | block.created_at or mute.created_at |
blocked-list__item-expires | Mute data | - | mute.expires - null if permanent; blocks have no expiry |
| Unblock | - | client.unBlockUser(userId) | Reverses client.blockUser() |
| Unmute | - | client.unmuteUser(userId) | - |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| List blocked users | client.getBlockedUsers() - requires an active client.connectUser() connection | Returns { blocks } |
| Muted users list | client.mutedUsers | Populated on connect |
| Muted channels list | client.mutedChannels | Populated on connect |
Moderation - CLI setup + end-user actions
Cross-product moderation for Chat, Feeds, and Video. Moderation review (queue, flagged items, approve/ban) is handled exclusively in the [Stream Dashboard](https://beta.dashboard.getstream.io) - never build review UI in the app (RULES.md > Moderation is Dashboard-only).
Rules: ../RULES.md (moderation is Dashboard-only).
Quick ref
- Builder default: Use Setup below for CLI-only config during scaffold. Do not build any moderation review UI.
- End-user actions (report, block, mute): see MODERATION-blueprints.md - Report Modal, Block/Mute Controls, and Blocked Users List.
- Review UI blueprints (review queue, flagged items, auto-mod status) are deliberately not bundled - do not recreate them. Review happens in the Dashboard.
---
App Integration
Setup
Packages: @stream-io/node-sdk (server), stream-chat (server - for chat message deletion)
CLI commands (run when moderation is included):
# Create blocklist (NOT idempotent - check first to avoid 400 error):
getstream api ListBlockLists 2>&1 | grep -q '"profanity"' || \
getstream api CreateBlockList --request '{"name":"profanity","words":[<generate a comprehensive list of common profanity>]}'
# Attach blocklist to the channel type being used (e.g., livestream, team, messaging):
getstream api UpdateChannelType --name livestream --request '{"max_message_length":5000,"blocklist":"profanity","blocklist_behavior":"flag","automod":"disabled","automod_behavior":"flag"}'
# Enable blocklist in moderation config (required for review queue population):
getstream api UpsertConfig --request '{"key":"chat","block_list_config":{"enabled":true,"rules":[{"name":"profanity","action":"flag"}]}}'
# If Feeds is also used - chat config does NOT cover feeds:
getstream api UpsertConfig --request '{"key":"feeds","block_list_config":{"enabled":true,"rules":[{"name":"profanity","action":"flag"}]}}'IMPORTANT: Do NOT use 2>/dev/null || true to suppress CreateBlockList errors - this also swallows the CLI's own error output, causing silent failure. The blocklist must exist before UpsertConfig runs, or it will fail with "Blocklist not found".
Server Routes
No moderation-specific server routes needed - review happens in the Dashboard. The app only needs the standard /api/token route from the primary product (Chat, Feeds, etc.).
Gotchas
- Blocklist alone doesn't populate review queue - MUST also
UpsertConfigwithblock_list_config.enabled: true - Need BOTH
key: "chat"ANDkey: "feeds"configs if both products are used - chat config doesn't cover feeds - Use
flagnotblockforblocklist_behavior- flag delivers content AND flags it in review queue CreateBlockListis NOT idempotent - returns 400 if exists. Check withListBlockListsfirst. Do NOT use2>/dev/null || true- it swallows CLI confirmation prompts- Generate real profanity for the blocklist - not placeholders like "badword1"
- Custom rules (
upsert_moderation_rule) return 403 on free plans - use blocklist + config instead - Flagged content review: Stream Dashboard - never in-app