This tutorial teaches you how to build a fully featured web messaging app with React and Stream's Chat React SDK - channel list, message composer, reactions, threads, typing indicators, and file uploads. You can use it as the foundation for any type of in-app chat.
The SDK ships with a redesigned interface, a cohesive design system, and clear customization surfaces, so you get to a polished chat experience quickly and can still make it feel like your product.
This page works for both humans and AI coding agents: every step is a file operation or a shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.
This chat tutorial uses Stream's edge network for optimal performance. Stream powers chat, activity feeds and video for thousands of apps and over a billion end users.
This tutorial has been tested with the following versions:
- React ^19
- Stream Chat SDK ^14
Choose your path
This tutorial has two paths. In Path A you let your AI agent build the app. In Path B you build it yourself, step by step, on Vite. Both paths end with the same working chat app, so pick one instead of working through both.
Path A - Let your AI agent build it (recommended)
Install the Stream CLI once, then add the skills. This gives Claude Code, Cursor, or Codex the React integration patterns and current SDK APIs, so it builds against real docs instead of stale training data.
1234567# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder). The React pack installs # on demand the first time it's needed, or add it explicitly: getstream skills getstream skills stream-react
Then ask your agent:
123/stream-react Build a React chat app with a channel list, message list, and composer. Provision credentials with the CLI (create/select my org and app, mint a token), or fall back to the tutorial demo credentials if I'm not logged in.
Where you come in. The agent handles credentials and code itself. It stops for you twice: when getstream init opens your browser to sign in and pick an app (new accounts get an organization created in that flow), and when the app first runs.
Want to get the sign-in out of the way first? Run getstream init before you prompt the agent - it picks up the initialized project from there.
Checkpoint: start the dev server if the agent hasn't, then open the app in your browser. You should see a channel list and be able to send a message.
Skim Important Building Blocks if you want to understand what the agent wrote before you extend it.
Path B - Build it manually
Choose this path if you'd rather write the code yourself, or you want to understand what the SDK is doing before you hand it to an agent. Code blocks titled App.tsx are the complete file at that point in the tutorial; the shorter untitled blocks are excerpts showing what just changed.
For AI assistants reading this page: append
.mdto any Stream docs URL for a clean Markdown version. A condensed index for the React Chat SDK is athttps://getstream.io/chat/docs/sdk/react/llms.txt. Prefer these over parsing HTML.
Important Building Blocks
Four pieces, one mental model:
useCreateChatClient- creates theStreamChatclient and manages its connection. The client abstracts API calls into methods and handles state and real-time events. Use this hook once per application; anywhere further down the tree, reach foruseChatContextinstead.ChatandChannel- React context providers.Chatholds the client and the theme;Channelholds one channel's state, its UI components, and its messaging functions.- UI components -
ChannelList,MessageList,MessageComposer,Thread, andWindow. Use them as-is, theme them, or replace them one at a time. WithComponents- the customization surface. Swap any SDK-owned view for your own; everything you don't name keeps its default.
You'll get a working app with the default UI first, then theme it, then replace individual components. Deeper customizations are linked at the end.
Step 0 - Prepare your environment
You need Node.js and a package manager. Skip this step if you already have them.
Checkpoint: both commands print a version.
12node --version npm --version
Step 1 - Create the project and get credentials
The easiest way to build a Stream Chat React application from this tutorial is to create a new project with Vite, which gives you a boilerplate React application you can run locally in a few commands.
Create a new React project called chat-example using the TypeScript template, and install the SDK:
123npm create vite chat-example -- --template react-ts cd chat-example npm i stream-chat stream-chat-react
123yarn create vite chat-example --template react-ts cd chat-example yarn add stream-chat stream-chat-react
Checkpoint: npm run dev serves the Vite starter page, and stream-chat and stream-chat-react appear in package.json. Stop the dev server again before continuing.
The code in Step 2 needs four values: an API key that identifies your Stream application to our servers, a user ID and user token that authorize the current chat user, and a user name used as their display name. There are two ways to get them.
Option 1 - Your own Stream app, via the Stream CLI
The getstream CLI provisions all of it in one flow. Run these from the chat-example directory you just created.
1. Install the CLI (skip if you did this in Path A):
1curl -fsSL https://getstream.io/cli.sh | bash
2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The sign-in flow creates your organization. Already have an org or an app? It lets you pick them.
1getstream init
Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish signing in before continuing. It's required first - env, token, and api commands fail with "stream project is not initialized" until it runs.
3. Write your API key into the project. For a Vite app this writes VITE_STREAM_API_KEY into .env.local and adds that file to .gitignore. The API secret is never written to a client target.
1getstream env --target vite
Read it in code with import.meta.env.VITE_STREAM_API_KEY instead of pasting the key into App.tsx.
4. Mint a user token for a user in your app (never expiring by default; add a TTL for production-like testing):
12getstream token tutorial_user getstream token tutorial_user --ttl 1d
5. (Optional) Seed a channel so your first launch isn't an empty list. Create the users first, then the channel:
1234getstream api UpdateUsers --request '{"users":{"tutorial_user":{"id":"tutorial_user","name":"Tutorial User"},"alice":{"id":"alice","name":"Alice"}}}' getstream api GetOrCreateChannel --type messaging --id general \ --request '{"data":{"created_by_id":"tutorial_user","members":[{"user_id":"tutorial_user"},{"user_id":"alice"}]}}'
Checkpoint: you have an API key in .env.local, a token printed by the CLI, and the user ID you minted it for. All of them belong to the same app.
You can also mint tokens with our token (JWT) generator utility. Learn more in the Tokens & Authentication documentation.
Option 2 - Pre-filled tutorial credentials, no account
Want to skip account setup entirely? Every code block below marked with a lock icon is filled in for you with working credentials against Stream's shared tutorial environment. Copy the block as-is and it runs.
Swap in your own credentials from Option 1 before building anything real. Tutorial credentials are shared and short lived.
Step 2 - Connect the client
Before working with the chat UI components, set up a StreamChat client instance. The useCreateChatClient hook handles instantiation and connection for you.
Replace the contents of the generated src/App.tsx with this code:
Note: Use the
useCreateChatClienthook only once per application. If you need the client instance somewhere down in the component tree, use theuseChatContexthook (exported by thestream-chat-reactSDK) to access it.
123456789101112131415161718192021import { Chat, useCreateChatClient } from "stream-chat-react"; // your Stream app information const apiKey = "REPLACE_WITH_API_KEY"; const userId = "REPLACE_WITH_USER_ID"; const userName = "REPLACE_WITH_USER_NAME"; const userToken = "REPLACE_WITH_USER_TOKEN"; const App = () => { const client = useCreateChatClient({ apiKey, tokenOrProvider: userToken, userData: { id: userId, name: userName }, }); if (!client) return <div>Setting up client & connection...</div>; return <Chat client={client}>Chat with client is ready!</Chat>; }; export default App;
On Option 1, replace the four constants with your own values, reading the API key from .env.local:
1const apiKey = import.meta.env.VITE_STREAM_API_KEY;
On Option 2, the four values are already filled in for you.
Note: The client has to have a connection established before you can pass it down to the
Chatcomponent, otherwise it won't work.
The hook is a convenience, not a requirement - see the useCreateChatClient source if you'd rather adapt the client setup yourself.
Start your application with npm run dev (or yarn dev).
Checkpoint: the page reads "Chat with client is ready!", and the Network tab of your developer tools shows a secure websocket connection to our servers. Stuck on "Setting up client & connection..."? Check the console for an auth error, which usually means the API key and token belong to different apps.
Step 3 - Get a working chat UI
Now that the connection is established, make the application interactable. Three files give you the full default chat experience: message list, composer with emoji and slash commands, reactions, threads, typing indicators, and file uploads.
1. Declare your custom data. The Stream API lets you store custom data on any entity (channel, message, user, and so on) - the image on a channel is one of them. Declare it once in src/stream-chat.d.ts and TypeScript will type-check and autocomplete it everywhere:
1234567import { DefaultChannelData } from "stream-chat-react"; declare module "stream-chat" { interface CustomChannelData extends DefaultChannelData { image?: string; } }
This extends the Channel type to include the image property. Add more properties to the CustomChannelData interface as you need them.
2. Set up the page layout. The SDK ships with polished component styling, but you still control the page layout. Create src/layout.css with a simple two-pane layout:
123456789101112131415161718192021html, body, #root { height: 100%; } body { margin: 0; } #root { display: flex; } .str-chat__channel-list { width: 30%; } .str-chat__channel { width: 100%; } .str-chat__thread { width: 45%; }
Note: Delete Vite's default
src/index.cssimport so its rules don't interfere with your styling.
3. Build the app. Extend src/App.tsx with the core component setup:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263import { useState, useEffect } from "react"; import { type User, Channel as StreamChannel } from "stream-chat"; import { useCreateChatClient, Chat, Channel, ChannelHeader, MessageComposer, MessageList, Thread, Window, } from "stream-chat-react"; import "stream-chat-react/dist/css/index.css"; const apiKey = "REPLACE_WITH_API_KEY"; const userId = "REPLACE_WITH_USER_ID"; const userName = "REPLACE_WITH_USER_NAME"; const userToken = "REPLACE_WITH_USER_TOKEN"; const user: User = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, }; const App = () => { const [channel, setChannel] = useState<StreamChannel>(); const client = useCreateChatClient({ apiKey, tokenOrProvider: userToken, userData: user, }); useEffect(() => { if (!client) return; const channel = client.channel("messaging", "custom_channel_id", { image: "https://getstream.io/random_png/?name=react", name: "Talk about React", members: [userId], }); setChannel(channel); }, [client]); if (!client) return <div>Setting up client & connection...</div>; return ( <Chat client={client}> <Channel channel={channel}> <Window> <ChannelHeader /> <MessageList /> <MessageComposer /> </Window> <Thread /> </Channel> </Chat> ); }; export default App;
Then import your layout stylesheet in src/App.tsx, right below the stream-chat-react CSS import:
123// other imports import "stream-chat-react/dist/css/index.css"; import "./layout.css";
Note how you create a channel with client.channel(type, id). The type - here messaging - determines the enabled features and permissions associated with the channel; the id is a unique reference to that specific channel.
Checkpoint: the channel renders with a header, a message list, and a composer. Send a message, hover it for reactions, and open a thread. Unstyled or broken layout? Confirm both CSS imports are present and that you removed Vite's default src/index.css.
What you get out of the box
Once the app is running, you'll notice these features without writing any more code:
- User online presence
- Typing indicators
- Message status indicators and failure states
- User role configuration
- Emoji support (opt-in)
- Unread indicators and read-state UI
- Threading and message replies
- Message reactions
- URL previews (send a YouTube link to see this in action)
- File uploads and previews
- Video playback
- Autocomplete-enabled search on users, emojis (opt-in), and commands
- Slash commands such as /giphy (custom commands are also supported)
- AI-powered spam and profanity moderation

Working chat, on borrowed credentials. Everything above is running against Stream's shared tutorial app, so the users and channels aren't yours and won't stick around. Create a free Stream app, then swap the four constants using the CLI flow in Step 1. It takes two commands and the rest of the tutorial works the same.
Step 4 - Add a channel list
The ChannelList component displays a list of channel previews and loads the channel data relevant to your user. Channels are loaded according to three props:
filter: filters the query that loads the channels. A minimal filter includes channel type and membership, so you load only channels related to the connected user.sort: sorts the channels selected by the filter - usually by the time of the last message.options: additional query options. Here, a limit of 10 channels.
Adjust src/App.tsx to render a list of channels:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768import type { User, ChannelSort, ChannelFilters, ChannelOptions, } from "stream-chat"; import { useCreateChatClient, Chat, Channel, ChannelHeader, ChannelList, MessageComposer, MessageList, Thread, Window, } from "stream-chat-react"; import "stream-chat-react/dist/css/index.css"; import "./layout.css"; const apiKey = "REPLACE_WITH_API_KEY"; const userId = "REPLACE_WITH_USER_ID"; const userName = "REPLACE_WITH_USER_NAME"; const userToken = "REPLACE_WITH_USER_TOKEN"; const user: User = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, }; const sort: ChannelSort = { last_message_at: -1 }; const filters: ChannelFilters = { type: "messaging", members: { $in: [userId] }, }; const options: ChannelOptions = { limit: 10, }; const App = () => { const client = useCreateChatClient({ apiKey, tokenOrProvider: userToken, userData: user, }); if (!client) return <div>Setting up client & connection...</div>; return ( <Chat client={client}> <ChannelList filters={filters} sort={sort} options={options} /> <Channel> <Window> <ChannelHeader /> <MessageList /> <MessageComposer /> </Window> <Thread /> </Channel> </Chat> ); }; export default App;
The channel instantiation is gone - ChannelList handles the channels now and sets the first one active automatically. You can still set channels programmatically as in Step 3; it's just unnecessary here.
This detail matters: when you want the ChannelList to control the active channel, render <Channel> without a fixed channel prop. Once you pass a specific channel instance to Channel, that instance becomes the source of truth instead of the list's selection.
Checkpoint: the channel list renders on the left, and clicking a channel switches the message list on the right. Empty list but no errors? Your user has no channels yet - seed one with the getstream api GetOrCreateChannel command from Step 1.
Note: More detail in the ChannelList documentation.
Step 5 - Theme it
Theming in the React SDK is done through CSS variables and design tokens. The default theme already looks polished, and you can brand it by overriding a small set of global variables.
Move the stream-chat-react CSS import from src/App.tsx into src/layout.css and load it into a stream css-layer. Your custom tokens go in a stream-overrides layer, so the browser applies your brand styles after the SDK defaults.
Your src/layout.css should now look like this:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253@layer stream, stream-overrides; @import "stream-chat-react/dist/css/index.css" layer(stream); @layer stream-overrides { .custom-theme { /* Accent color - used by mentions, read receipts, attachment actions, focus states, etc. */ --str-chat__accent-primary: #0d47a1; /* Message bubble colors */ --str-chat__chat-bg-outgoing: #1e3a8a; --str-chat__chat-bg-attachment-outgoing: #0d47a1; --str-chat__chat-bg-incoming: #dbeafe; --str-chat__chat-text-outgoing: #ffffff; --str-chat__chat-reply-indicator-outgoing: #93c5fd; /* Link colors (inside bubbles and elsewhere) */ --str-chat__text-link: #1e40af; --str-chat__chat-text-link: #93c5fd; /* Panel backgrounds */ --str-chat__background-core-elevation-1: #dbeafe; /* channel list and surrounding panels */ --str-chat__background-core-app: #c7dafc; /* message list background */ /* Focus ring */ --str-chat__border-utility-focused: #1e40af; /* Radii - the SDK uses --radius-max / --button-radius-full for pill shapes */ --str-chat__radius-max: 8px; --str-chat__button-radius-full: 6px; } } html, body, #root { height: 100%; } body { margin: 0; } #root { display: flex; } .str-chat__channel-list { width: 30%; } .str-chat__channel { width: 100%; } .str-chat__thread { width: 45%; }
Now pass the theme class name to the theme property of the Chat component:
1234567import { Chat } from "stream-chat-react"; // ... return ( <Chat client={client} theme="custom-theme"> {/* ... */} </Chat> );
Checkpoint: outgoing message bubbles are dark blue with white text, the channel list background is light blue, and corners are less rounded. Nothing changed? Check the layer order - @layer stream, stream-overrides; has to come first, and the import must carry layer(stream).
Read more about theming in our documentation.
Step 6 - Replace an SDK component
Theming changes tokens. To replace an actual view, use the WithComponents provider. Here you'll customize the channel list row and the message UI while keeping the rest of the app unchanged.
Note: Custom UI components receive the same props as their default counterparts. For message UIs, prefer
useMessageContext()so your component stays aligned with the current SDK data flow.
If all you want is a different icon, WithComponents also takes an icons map, merged slot by slot, so you can swap one glyph without reimplementing the component that renders it:
1<WithComponents overrides={{ icons: { IconFlag: MyFlagIcon, IconUser: MyUserIcon } }}>
Icons you don't name keep their SDK default.
Update src/App.tsx with the following code:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180import React, { useEffect, useState } from "react"; import type { ChannelFilters, ChannelOptions, ChannelSort, User, } from "stream-chat"; import { Chat, Channel, ChannelAvatar, ChannelHeader, ChannelList, MessageComposer, MessageList, Thread, Window, WithComponents, useCreateChatClient, useMessageContext, type ChannelListItemUIProps, } from "stream-chat-react"; import "./layout.css"; const apiKey = "REPLACE_WITH_API_KEY"; const userId = "REPLACE_WITH_USER_ID"; const userName = "REPLACE_WITH_USER_NAME"; const userToken = "REPLACE_WITH_USER_TOKEN"; const user: User = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, }; const sort: ChannelSort = { last_message_at: -1 }; const filters: ChannelFilters = { type: "messaging", members: { $in: [userId] }, }; const options: ChannelOptions = { limit: 10, }; const CustomChannelListItem = ({ active, channel, displayImage, displayTitle, latestMessagePreview, onSelect, setActiveChannel, }: ChannelListItemUIProps) => { const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { if (onSelect) { onSelect(e); return; } setActiveChannel?.(channel, undefined, e); }; return ( <button aria-pressed={active} onClick={handleClick} style={{ width: "100%", padding: "12px", display: "flex", gap: "12px", border: "none", background: active ? "#d3f2ef" : "transparent", textAlign: "left", cursor: "pointer", borderRadius: "20px", }} type="button" > <ChannelAvatar imageUrl={displayImage ?? channel.data?.image} size="xl" userName={displayTitle ?? channel.data?.name ?? "Channel"} /> <div style={{ flex: 1 }}> <div>{displayTitle ?? channel.data?.name ?? "Unnamed Channel"}</div> {latestMessagePreview ? ( <div style={{ fontSize: "14px", opacity: 0.75 }}> {latestMessagePreview} </div> ) : null} </div> </button> ); }; const CustomMessage = () => { const { message } = useMessageContext(); const isOwnMessage = message.user?.id === userId; return ( <div style={{ display: "flex", justifyContent: isOwnMessage ? "flex-end" : "flex-start", padding: "4px 8px", }} > <div style={{ background: isOwnMessage ? "#d3f2ef" : "#ffffff", borderRadius: "20px", boxShadow: "0 8px 24px rgba(15, 23, 42, 0.08)", maxWidth: "min(80%, 640px)", padding: "12px 16px", }} > <div style={{ color: "#0f172a", fontSize: "13px", fontWeight: 700 }}> {message.user?.name} </div> <div style={{ color: "#334155" }}>{message.text}</div> </div> </div> ); }; const App = () => { const [isReady, setIsReady] = useState(false); const client = useCreateChatClient({ apiKey, tokenOrProvider: userToken, userData: user, }); useEffect(() => { if (!client) return; const initChannel = async () => { const channel = client.channel("messaging", "react-tutorial", { image: "https://getstream.io/random_png/?name=react-v14", name: "Talk about React", members: [userId], }); await channel.watch(); setIsReady(true); }; initChannel().catch((error) => { console.error("Failed to initialize tutorial channel", error); }); }, [client]); if (!client) return <div>Setting up client & connection...</div>; if (!isReady) return <div>Loading tutorial channel...</div>; return ( <WithComponents overrides={{ ChannelListItemUI: CustomChannelListItem, MessageUI: CustomMessage, }} > <Chat client={client} theme="custom-theme"> <ChannelList filters={filters} sort={sort} options={options} /> <Channel> <Window> <ChannelHeader /> <MessageList /> <MessageComposer /> </Window> <Thread /> </Channel> </Chat> </WithComponents> ); }; export default App;
Because ChannelListItemUI replaces the entire channel row, this simplified example also replaces the default row affordances that normally come with the SDK-owned item, including the built-in action buttons and context menu. If you want to preserve those controls, compose the default channel-list item structure back into your custom row rather than replacing it with a bare button.
Checkpoint: channel rows render as pill-shaped buttons with a large avatar, and messages render as your own bubbles. Nothing changed? WithComponents only applies to its descendants - it has to wrap the component that renders the overridden view.
Step 7 - Enable the emoji picker and autocomplete
No chat experience is complete without emojis. Extend your MessageComposer with the SDK EmojiPicker component and emoji autocomplete (through the use of SearchIndex). Both are built on the emoji-mart packages, so install those first.
1npm i emoji-mart @emoji-mart/data
1yarn add emoji-mart @emoji-mart/data
Import the picker's stylesheet:
12345@layer stream, stream-plugins, stream-overrides; @import "stream-chat-react/dist/css/index.css" layer(stream); @import "stream-chat-react/dist/css/emoji-picker.css" layer(stream-plugins); /* your @layer stream-overrides block and layout rules stay exactly as they are */
And now the actual code:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788import { useEffect, useState } from "react"; import type { ChannelFilters, ChannelSort, User } from "stream-chat"; import { Chat, Channel, ChannelHeader, ChannelList, MessageComposer, MessageList, Thread, Window, WithComponents, useCreateChatClient, } from "stream-chat-react"; import { EmojiPicker } from "stream-chat-react/emojis"; import { init, SearchIndex } from "emoji-mart"; import data from "@emoji-mart/data"; import "./layout.css"; const apiKey = "REPLACE_WITH_API_KEY"; const userId = "REPLACE_WITH_USER_ID"; const userName = "REPLACE_WITH_USER_NAME"; const userToken = "REPLACE_WITH_USER_TOKEN"; const user: User = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, }; const sort: ChannelSort = { last_message_at: -1 }; const filters: ChannelFilters = { type: "messaging", members: { $in: [userId] }, }; init({ data }); const App = () => { const [isReady, setIsReady] = useState(false); const client = useCreateChatClient({ apiKey, tokenOrProvider: userToken, userData: user, }); useEffect(() => { if (!client) return; const initChannel = async () => { const channel = client.channel("messaging", "react-tutorial", { image: "https://getstream.io/random_png/?name=react-v14", name: "Talk about React", members: [userId], }); await channel.watch(); setIsReady(true); }; initChannel().catch((error) => { console.error("Failed to initialize tutorial channel", error); }); }, [client]); if (!client) return <div>Setting up client & connection...</div>; if (!isReady) return <div>Loading tutorial channel...</div>; return ( <Chat client={client}> <WithComponents overrides={{ EmojiPicker }}> <ChannelList filters={filters} sort={sort} /> <Channel> <Window> <ChannelHeader /> <MessageList /> <MessageComposer emojiSearchIndex={SearchIndex} /> </Window> <Thread /> </Channel> </WithComponents> </Chat> ); }; export default App;
Note: to keep this block focused on the emoji setup, it renders
<Chat>without thetheme="custom-theme"prop from Step 5 and without theoptionsprop onChannelList. Add both back if you want to keep your theme and the 10-channel limit.
Checkpoint: the composer shows an emoji button that opens the picker, and typing : followed by a couple of letters brings up emoji autocomplete.
Verify the whole build
1npm run dev
Then open the app and confirm the full loop: the channel list loads, click a channel, send a message, hover it for reactions, open a thread, drop in a file, and paste a YouTube link to watch it unfurl.
Customize further - cookbooks
For larger changes, Stream has full cookbooks. Each one is a short, self-contained recipe that swaps out a single piece of the chat UI.
Channel list
- Channel list UI - the pattern for customizing channel previews
- Search customization and the search menu
- Collapsible sidebar
- Infinite scroll
Messages and composer
- Message UI and message actions
- Reactions customization
- Message composer UI and the attachment selector
- Audio recorder - voice messages in the composer
- Link previews, typing indicator, and channel header
Theming
- Theming introduction - the full theming model
- Component, palette, and global variables
- Localization
Beyond the basics
- Threads and the thread list
- AI integrations - streaming AI responses in a channel
- Location sharing and blocking users
- Channel read state and accessibility
Two longer recipes with full code follow below: a custom attachment type and a livestream-style chat app.
Optional - add a custom attachment type
A self-contained recipe: a custom Attachment component that renders its own UI when an attachment is of type product. To see it in action, the app queries the channel after the user connects and sends a message carrying a product attachment on mount, which the custom component then renders in the MessageList.
This one stands apart from the main tutorial. It renders a single dedicated channel rather than combining with ChannelList, so treat it as a reference for custom attachment rendering rather than the next step in the app you just built.
Update src/App.tsx with the following code:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147import { useEffect, useState } from "react"; import type { Attachment as AttachmentType, Channel as StreamChannel, User, } from "stream-chat"; import { Attachment, Chat, Channel, ChannelHeader, MessageComposer, MessageList, Thread, Window, WithComponents, useCreateChatClient, type AttachmentProps, } from "stream-chat-react"; import "./layout.css"; const apiKey = "REPLACE_WITH_API_KEY"; const userId = "REPLACE_WITH_USER_ID"; const userName = "REPLACE_WITH_USER_NAME"; const userToken = "REPLACE_WITH_USER_TOKEN"; const user: User = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, }; const attachments: AttachmentType[] = [ { image: "https://images-na.ssl-images-amazon.com/images/I/71k0cry-ceL._SL1500_.jpg", name: "iPhone", type: "product", url: "https://goo.gl/ppFmcR", }, ]; const isProductAttachment = ( attachment: AttachmentProps["attachments"] extends Array<infer T> ? T : never, ): attachment is AttachmentType => "type" in attachment && attachment.type === "product"; const CustomAttachment = (props: AttachmentProps) => { const { attachments } = props; const [attachment] = attachments || []; if (attachment && isProductAttachment(attachment)) { return ( <div style={{ background: "#ffffff", borderRadius: "24px", boxShadow: "0 10px 30px rgba(15, 23, 42, 0.08)", padding: "12px", }} > <div style={{ color: "#0f172a", fontSize: "12px", fontWeight: 700 }}> Product recommendation </div> <a href={attachment.url} rel="noreferrer" target="_blank"> <img alt="custom-attachment" height="120" src={attachment.image} style={{ borderRadius: "18px", marginTop: "8px", objectFit: "cover", }} /> <div style={{ color: "#334155", marginTop: "8px" }}> {attachment.name} </div> </a> </div> ); } return <Attachment {...props} />; }; const App = () => { const [channel, setChannel] = useState<StreamChannel>(); const client = useCreateChatClient({ apiKey, tokenOrProvider: userToken, userData: user, }); useEffect(() => { if (!client) return; const initChannel = async () => { const channel = client.channel("messaging", "react-tutorial-products", { image: "https://getstream.io/random_png/?name=products", name: "Product recommendations", members: [userId], }); await channel.watch(); const hasProductMessage = channel.state.messages.some((message) => message.attachments?.some( (attachment) => "type" in attachment && attachment.type === "product", ), ); if (!hasProductMessage) { await channel.sendMessage({ text: "Your selected product is out of stock, would you like to select one of these alternatives?", attachments, }); } setChannel(channel); }; initChannel().catch((error) => { console.error("Failed to initialize attachments", error); }); }, [client]); if (!client) return <div>Setting up client & connection...</div>; if (!channel) return <div>Loading tutorial channel...</div>; return ( <WithComponents overrides={{ Attachment: CustomAttachment }}> <Chat client={client} theme="custom-theme"> <Channel channel={channel}> <Window> <ChannelHeader /> <MessageList /> <MessageComposer /> </Window> <Thread /> </Channel> </Chat> </WithComponents> ); }; export default App;
As in Step 3, declare the custom attachment properties name, image, and url in your d.ts file for proper type-checking:
12345678910111213import { DefaultAttachmentData, DefaultChannelData } from "stream-chat-react"; declare module "stream-chat" { interface CustomAttachmentData extends DefaultAttachmentData { image?: string; name?: string; url?: string; } interface CustomChannelData extends DefaultChannelData { image?: string; } }
Checkpoint: the channel opens with a message showing a white product card, an iPhone image, and the label "Product recommendation" instead of the default attachment UI.
Optional - a livestream-style chat app
Livestream chat has different constraints: the interface tends to be more compact, and message seen/read states get noisy as volume increases. Update src/App.tsx with the following code to see a simple livestream example:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768import { useEffect, useState } from "react"; import type { Channel as StreamChannel, User } from "stream-chat"; import { Channel, ChannelHeader, Chat, MessageComposer, VirtualizedMessageList, Window, useCreateChatClient, } from "stream-chat-react"; import "./layout.css"; const apiKey = "REPLACE_WITH_API_KEY"; const userId = "REPLACE_WITH_USER_ID"; const userName = "REPLACE_WITH_USER_NAME"; const userToken = "REPLACE_WITH_USER_TOKEN"; const user: User = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, }; const App = () => { const [channel, setChannel] = useState<StreamChannel>(); const chatClient = useCreateChatClient({ apiKey, tokenOrProvider: userToken, userData: user, }); useEffect(() => { if (!chatClient) return; const initChannel = async () => { const spaceChannel = chatClient.channel("livestream", "spacex", { image: "https://goo.gl/Zefkbx", name: "SpaceX launch discussion", }); await spaceChannel.watch(); setChannel(spaceChannel); }; initChannel().catch((error) => { console.error("Failed to initialize livestream channel", error); }); }, [chatClient]); if (!chatClient) return <div>Setting up client & connection...</div>; if (!channel) return <div>Loading tutorial channel...</div>; return ( <Chat client={chatClient} theme="str-chat__theme-dark"> <Channel channel={channel}> <Window> <ChannelHeader /> <VirtualizedMessageList /> <MessageComposer focus /> </Window> </Channel> </Chat> ); }; export default App;
Three things differ from the main example:
- The
livestreamchannel type, which disables typing events and seen/read states. - The
str-chat__theme-darktheme, which enables dark mode. - The
VirtualizedMessageListcomponent, which handles list virtualization out of the box and manages memory build-up under the high message volume of a livestream.
You're still using the same MessageComposer surface, so commands, uploads, and the redesigned composer UX keep working in the more compact layout.
Troubleshooting
- Stuck on "Setting up client & connection..." - the client never connected. Check the console; an auth error usually means the API key and token belong to different apps.
token is invalid/ auth error - token minted for a different app, or expired. Re-mint withgetstream token <user-id>and confirm the API key matches.- Channel list is empty, no errors - the user has no channels. Seed one with
getstream api GetOrCreateChannel(Step 1). - Unstyled or broken layout -
stream-chat-react/dist/css/index.cssisn't imported, or Vite's defaultsrc/index.cssis still in play. Delete it. - Styles stopped applying after Step 5 - layer order.
@layer stream, stream-overrides;must come first, and the import must carrylayer(stream). stream project is not initialized- CLI onboarding not run. Rungetstream initin the project directory first.- TypeScript errors on
imageorname-src/stream-chat.d.tsis missing, or the file is outsideincludeintsconfig.json. - A custom component never renders -
WithComponentsonly applies to its descendants. It has to wrap the component that renders the overridden view. - Clicking a channel does nothing -
<Channel>was given a fixedchannelprop, which overrides the list's active channel. - Emoji picker opens but looks unstyled -
dist/css/emoji-picker.cssisn't imported. Plugin styles ship separately fromdist/css/index.css(Step 7). - Emoji autocomplete never triggers -
init({ data })wasn't called, oremojiSearchIndexwasn't passed toMessageComposer.
Next steps
- AI chat experiences - add an AI assistant to a channel with streaming responses: AI integrations
- Push notifications - React push setup
- Moderation - AI-powered content moderation for spam, profanity, and images
- Video and audio calls - the Video React SDK integrates with chat
- Design resources - download our free chat interface UI kit
- More platforms - the React Native tutorial covers mobile
- Sample code - the Stream React examples source code behind the chat demos on our website, with more detailed integrations and complex use cases
Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.
Final Thoughts
In this chat app tutorial we built a fully functioning React messaging app with our React SDK component library. We started with the polished defaults, then customized them with theme tokens and scoped component overrides.
Both the chat SDK for React and the API have more features available to support more advanced use-cases such as push notifications, content moderation, rich messages, and more.
For more information read the React SDK Overview documentation, or review Stream's API documentation if you want to build a more complex chat application.
Machine-readable resources
For AI agents and coding assistants working with this SDK:
- CLI + skills:
curl -fsSL https://getstream.io/cli.sh | bash, thengetstream skillsandgetstream skills stream-reactfor the web React pack. (Alternative:npx skills add GetStream/agent-skills -s stream.) - Provisioning:
getstream init(auth + create/select org & app) ->getstream env --target vite(writesVITE_STREAM_API_KEYto.env.local) ->getstream token <user-id>(mint a token) - Data & config from the CLI:
getstream api <Endpoint> --request '{...}'for users, channels, and messages - React integration skill: invoke
/stream-reactin your agent for React and Next.js setup patterns;/stream-docssearches live SDK docs with citations - Docs index for LLMs:
https://getstream.io/chat/docs/sdk/react/llms.txt - Markdown endpoints: append
.mdto any docs URL for a clean, token-efficient version - Source of truth for APIs: the SDK repository - check the installed version's source rather than assuming APIs from training data

