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 ready-made 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-react 15
- stream-chat 10
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.
# 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-reactThen ask your agent:
/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. In the Markdown version, the code blocks holdREPLACE_WITH_*placeholders instead of credentials: ask the human for the values shown on the web page, or follow Option 1 under Step 1.
Important Building Blocks
These are the pieces the tutorial app is built from:
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.<Chat/>and<Channel/>- React context providers.<Chat/>holds the client and the theme.<Channel/>gives its children one channel that is already loaded, so the header, message list, and composer inside it show that channel.<Channel/>doesn't query or watch the channel, so the app loads the channel before passing it in.- Channel lists - a paginator loads a list page by page and holds the items it has loaded. A
ChannelPaginatorholds one channel list: its filters, its sort order, and the channels loaded so far. You register it onclient.channelManager, the client's channel manager, which keeps every registered list up to date as messages and other events arrive.<ChannelNavigation/>renders one list for each registered paginator. - Layout -
<ChatView/>, fromstream-chat-react/slot-layout, decides where a channel or a thread opens. It divides the screen into slots: named places that each hold one channel or one thread. Components such as the channel list ask to open a channel or a thread through workspace navigation, the interface that theuseWorkspaceNavigationhook returns, and<ChatView/>puts it in a slot. - UI components -
<ChannelHeader/>,<MessageList/>,<MessageComposer/>, and<Thread/>. Use them as-is, theme them, or replace them one at a time. <WithComponents/>- the customization surface. Swap any SDK-owned component 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.
node --version
npm --versionStep 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:
npm create vite chat-example -- --template react-ts
cd chat-example
npm install stream-chat stream-chat-reactyarn create vite chat-example --template react-ts
cd chat-example
yarn add stream-chat stream-chat-reactCheckpoint: 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):
curl -fsSL https://getstream.io/cli.sh | bash2. 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.
getstream initHuman 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.
getstream env --target viteRead 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):
getstream token tutorial_user
getstream token tutorial_user --ttl 1d5. (Optional) Seed a channel so your first launch isn't an empty list. Create the users first, then the channel:
getstream 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? On this web page, every code block that starts with the four apiKey, userId, userName and userToken constants 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 each token expires about five hours after the page fetched it. The page keeps the same credentials while its browser tab stays open, so for fresh ones, open the page in a new tab.
The blocks are filled in only on this web page. The Markdown version of the page (.md) shows REPLACE_WITH_API_KEY, REPLACE_WITH_USER_ID, REPLACE_WITH_USER_NAME and REPLACE_WITH_USER_TOKEN instead. Replace them with the values from the web page, or with your own from Option 1. The tutorial environment gives no user name: the web page uses the part of the user ID before the first -, so for the user ID soft-recipe-0 the name is soft.
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.
import { 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:
const apiKey = import.meta.env.VITE_STREAM_API_KEY;On Option 2, the four values are already filled in for you.
Note:
useCreateChatClientreturnsnulluntil the user is connected, so the app renders<Chat/>only onceclientis set.
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. Two files give you the full default chat experience: message list, composer with slash commands, reactions, threads, typing indicators, and file uploads.
1. 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:
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
}
#root {
display: flex;
}
.str-chat {
--str-chat__channel-list-width: 30%;
}
.str-chat__channel {
width: 100%;
}
/* A thread opened beside the channel takes 40% of the width. From Step 4 on, a thread opens
in a slot instead and fills it, so this rule doesn't apply there. */
.str-chat__chat-view__channels > .str-chat__thread {
flex: 0 0 40%;
}Note: Delete the
import './index.css'line fromsrc/main.tsx, so Vite's default rules don't interfere with your styling.
2. Build the app. Replace src/App.tsx with the core component setup:
import { useEffect, useState } from 'react';
import type { ClientUser, Channel as StreamChannel } from 'stream-chat';
import {
Channel,
ChannelHeader,
Chat,
MessageComposer,
MessageList,
ThreadHeader,
useCreateChatClient,
} from 'stream-chat-react';
import { ChatView, ThreadSlot } from 'stream-chat-react/slot-layout';
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: ClientUser = {
id: userId,
name: userName,
image: `https://getstream.io/random_png/?name=${userName}`,
};
// A thread opens in a slot of the `<ChatView/>` layout: a message's "Thread Reply" action asks
// workspace navigation to open it. The channel renders outside the slots, so the only slot is the
// thread's.
const chatViewLayouts = [{ id: 'channels' as const, slots: ['thread'] }];
const ChannelWorkspace = ({ channel }: { channel: StreamChannel }) => (
<>
<Channel channel={channel}>
<ChannelHeader />
<MessageList />
<MessageComposer />
</Channel>
{/* `<ThreadSlot/>` renders the thread open in the slot inside a `<Thread/>`, and nothing
while no thread is open. */}
<ThreadSlot slot='thread'>
<ThreadHeader />
<MessageList />
<MessageComposer />
</ThreadSlot>
</>
);
const App = () => {
const [channel, setChannel] = useState<StreamChannel>();
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: user,
});
useEffect(() => {
if (!client) return;
const initChannel = async () => {
// The id includes the user's id, so each user creates a channel of their own: `members`
// only applies when `Channel.watch()` creates the channel, not when it already exists.
const channel = client.channel('messaging', `react-tutorial-${userId}`, {
members: [{ user_id: userId }],
// the channel's own fields, such as its name and image, go under `custom`
custom: {
image: 'https://getstream.io/random_png/?name=react',
name: 'Talk about React',
},
});
// `<Channel/>` shows a channel but doesn't load it, so the app loads it here. A re-run of
// this effect gets the same channel object back from `client.channel()`. `channel.watch()`
// sends nothing for a channel that is already watched and loaded, and joins a watch in
// flight, so the re-run sends one request at most.
await channel.watch();
setChannel(channel);
};
initChannel().catch((error) => {
console.error('Failed to initialize tutorial channel', error);
});
}, [client]);
if (!client) return <div>Setting up client & connection...</div>;
// Shown until `channel.watch()` finishes: `<Channel/>` has no loading state of its own.
if (!channel) return <div>Loading tutorial channel...</div>;
return (
<Chat client={client}>
<ChatView
layouts={chatViewLayouts}
views={{ channels: <ChannelWorkspace channel={channel} /> }}
/>
</Chat>
);
};
export default App;The app opens one channel and shows it:
StreamChat.channel(), called asclient.channel(type, id, data), returns the object for one channel. The type - heremessaging- determines the enabled features and permissions associated with the channel. The id - herereact-tutorial-followed by the user's id - is a unique reference to that specific channel.members: [{ user_id: userId }]lists the connected user as a member of the channel. It's part of the channel data thatChannel.watch()sends to the server, and it applies only when that call creates the channel. Watching a channel that already exists doesn't make the user a member, which is why the id includes the user's id.customholds the channel's own fields. The SDK showschannel.data.custom.nameas the channel's title andchannel.data.custom.imageas its avatar.stream-chat-reactalready types both fields, so TypeScript needs no declaration for them.Channel.watch()loads the channel's messages, members, and read state, and subscribes to its events. The server creates the channel the first time the app watches it.<Channel/>loads nothing itself, so the app shows "Loading tutorial channel..." untilChannel.watch()finishes, and then renders the channel.userhas theClientUsertype, the shapeuseCreateChatClienttakes asuserData.
Opening a thread needs a layout, so the app renders <ChatView/> from this step on. When the user opens a thread from a message, the message asks workspace navigation to open the thread, and <ChatView/> puts the thread in a slot. Without a thread slot, opening a thread does nothing.
- A view is one screen of the layout.
chatViewLayoutsdeclares one view,channels, with one slot,thread. viewsgives each view its content: thechannelsview renders<ChannelWorkspace/>.<ThreadSlot/>renders the thread open in thethreadslot, and nothing while no thread is open.
Checkpoint: the channel renders with a header, a message list, and a composer. Send a message and hover it: the buttons next to it open the reaction selector, open a thread ("Open Thread"), and open the actions menu ("…"), whose "Thread Reply" item opens a thread too. The thread opens beside the channel. Unstyled or broken layout? Confirm both CSS imports are present and that you deleted the import './index.css' line from src/main.tsx.
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

Using the pre-filled credentials? With Option 2 from Step 1, the app runs against Stream's shared tutorial app, so its users and channels aren't yours and don't last. Create a free Stream app and swap in your own values with the CLI flow in Option 1. The rest of the tutorial works the same.
Step 4 - Add a channel list
A messaging app lets the user move between channels. In this step the app lists the user's channels, opens the one the user clicks, and opens a second one beside it on ⌘-click or ctrl-click.
Adjust src/App.tsx to render a list of channels:
import { useEffect } from 'react';
import type { ChannelFilters, ClientUser, SortParamRequest } from 'stream-chat';
import { ChannelPaginator } from 'stream-chat';
import {
Channel,
ChannelHeader,
ChannelNavigation,
Chat,
MessageComposer,
MessageList,
Thread,
ThreadHeader,
useCreateChatClient,
} from 'stream-chat-react';
import {
ChatView,
type ChatViewSlotRenderers,
type DeriveWorkspaceNavigation,
Slot,
} from 'stream-chat-react/slot-layout';
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: ClientUser = {
id: userId,
name: userName,
image: `https://getstream.io/random_png/?name=${userName}`,
};
const sort: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }];
const filters: ChannelFilters = {
type: 'messaging',
members: { $in: [userId] },
};
// One view ("channels") with two slots side by side. Each `<Slot/>` renders what is open in it, a
// channel or a thread, through `slotRenderers`. Declared outside the component, because
// `<ChatView/>` resets its layout when it gets a new array.
const chatViewLayouts = [{ id: 'channels' as const, slots: ['left', 'right'] }];
const slotRenderers: ChatViewSlotRenderers = {
channel: ({ source }) => (
<Channel channel={source}>
<ChannelHeader />
<MessageList />
<MessageComposer />
</Channel>
),
thread: ({ source }) => (
<Thread thread={source}>
<ThreadHeader />
<MessageList />
<MessageComposer />
</Thread>
),
};
// A plain click on a channel replaces the open one; ⌘/ctrl-click opens it beside, in the other slot.
const deriveWorkspaceNavigation: DeriveWorkspaceNavigation = (base) => ({
openChannel: (channel, options) =>
base.openChannel(channel, {
...options,
additive:
options?.additive ?? !!(options?.event?.metaKey || options?.event?.ctrlKey),
}),
});
const ChannelsWorkspace = () => (
<>
<ChannelNavigation />
{/* The slots' own container, so `layout.css` can react to the width they share. */}
<div className='channel-slots'>
<Slot slot='left' />
<Slot slot='right' />
</div>
</>
);
const App = () => {
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: user,
});
// A `ChannelPaginator` holds the channel list's filters and sort order, and the channels it has
// loaded. Registered on `client.channelManager`, the list stays up to date as messages and other
// events arrive. `<ChannelNavigation/>` renders one list per registered paginator.
useEffect(() => {
if (!client) return;
const paginator = new ChannelPaginator({
client,
filters,
id: 'channels:default',
paginatorOptions: { pageSize: 10 },
sort,
});
client.channelManager.insertPaginator({ paginator });
return () => {
client.channelManager.removePaginator(paginator);
};
}, [client]);
if (!client) return <div>Setting up client & connection...</div>;
return (
<Chat client={client}>
<ChatView
deriveWorkspaceNavigation={deriveWorkspaceNavigation}
layouts={chatViewLayouts}
slotRenderers={slotRenderers}
views={{ channels: <ChannelsWorkspace /> }}
/>
</Chat>
);
};
export default App;The channel list loads the user's channels, and the layout opens the one the user picks. The channel list's query loads each channel, so the channel setup from Step 3 is gone.
The channel list. A ChannelPaginator holds the list's query and the channels it has loaded. It takes these options:
| Option | What it sets |
|---|---|
filters |
Which channels the list loads. A minimal filter includes channel type and membership, so you load only channels related to the connected user. |
sort |
An array of sort fields. [{ direction: -1, field: 'last_message_at' }] puts the channel with the newest message first. |
paginatorOptions.pageSize |
How many channels one page loads, here 10. |
id |
The name the paginator is registered under, here channels:default. |
The effect registers the paginator with ChannelManager.insertPaginator() on client.channelManager, which keeps the list up to date as messages and other events arrive. The effect's cleanup unregisters it with ChannelManager.removePaginator(). <ChannelNavigation/> renders the list header, the search bar, and one channel list for each registered paginator. The list loads its first page when it mounts.
The layout. <ChatView/> places what the user opens:
chatViewLayoutsdeclares one view,channels, with two slots,leftandright. It's declared outsideApp, because<ChatView/>restarts its layout, and closes everything open, when it gets a new array.<Slot/>renders whatever its slot holds, a channel or a thread.slotRendererstells<Slot/>how to render each kind. The channel renderer gets the channel open in the slot assourceand passes it to<Channel/>as itschannelprop. The thread renderer passes the thread to<Thread/>the same way.deriveWorkspaceNavigationchanges how a channel opens. A plain click replaces the open channel. When the click held ⌘ or ctrl, the function adds theadditiveoption, so the channel opens beside the open one, in the other slot.
Nothing opens on load. Both slots stay empty until the user clicks a channel. <Channel/> still needs its channel prop, and the slot renderer is what passes it.
The slot styles. The two slots share the width beside the channel list. Add these rules at the end of src/layout.css:
/* The two channel slots share the width beside the channel list. When both hold something and
they have 560px or less together, only the second slot shows. Closing it brings the first one
back. */
.channel-slots {
container-type: inline-size;
display: flex;
flex: 1 1 auto;
min-width: 0;
height: 100%;
}
@container (max-width: 560px) {
.channel-slots:has(
> .str-chat__chat-view__slot:not(:empty) + .str-chat__chat-view__slot:not(:empty)
)
> .str-chat__chat-view__slot:first-child {
display: none;
}
/* The second slot then sits next to the channel list, which draws its own edge. */
.channel-slots
> .str-chat__chat-view__slot:not(:empty)
~ .str-chat__chat-view__slot:not(:empty) {
border-inline-start: 0;
}
}.channel-slots is the slots' container, rendered by <ChannelsWorkspace/>. When the container is 560px wide or narrower and both slots hold something, only the second slot shows. Closing it brings the first one back.
Checkpoint: the channel list renders on the left, and no channel is open yet. Click a channel to open it. ⌘-click (macOS) or ctrl-click another channel to open it beside the first. The list says "No conversations yet"? Your user has no channels yet - seed one with the getstream api GetOrCreateChannel command from Step 1. With the pre-filled credentials, put Step 3's App.tsx back with the same values and open the app once: loading the page creates the user's channel. Then return to this step's App.tsx.
Note: More detail in Channel list paginators, Workspace with ChatView, and the ⌘/ctrl-click opens beside recipe.
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. This step changes two files: src/layout.css below, and two lines of Step 4's src/App.tsx, shown after it.
Your src/layout.css should now look like this:
@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 {
--str-chat__channel-list-width: 30%;
}
.str-chat__channel {
width: 100%;
}
/* A thread opened beside the channel takes 40% of the width. From Step 4 on, a thread opens
in a slot instead and fills it, so this rule doesn't apply there. */
.str-chat__chat-view__channels > .str-chat__thread {
flex: 0 0 40%;
}
/* The two channel slots share the width beside the channel list. When both hold something and
they have 560px or less together, only the second slot shows. Closing it brings the first one
back. */
.channel-slots {
container-type: inline-size;
display: flex;
flex: 1 1 auto;
min-width: 0;
height: 100%;
}
@container (max-width: 560px) {
.channel-slots:has(
> .str-chat__chat-view__slot:not(:empty) + .str-chat__chat-view__slot:not(:empty)
)
> .str-chat__chat-view__slot:first-child {
display: none;
}
/* The second slot then sits next to the channel list, which draws its own edge. */
.channel-slots
> .str-chat__chat-view__slot:not(:empty)
~ .str-chat__chat-view__slot:not(:empty) {
border-inline-start: 0;
}
}Then edit Step 4's src/App.tsx: delete the import 'stream-chat-react/dist/css/index.css'; line, since layout.css imports it now, and pass the theme class name to the theme prop of <Chat/>:
import { 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 the search field, avatars and buttons have less rounded corners. Message bubbles keep their shape: their radius has tokens of its own. 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 Theming, and about the layers in its CSS layers section.
Step 6 - Replace an SDK component
Theming changes tokens. To replace an actual component, 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, read the message with the
useMessageContexthook, as the default message UI does.
Update src/App.tsx with the following code:
import { useEffect } from 'react';
import type { ChannelFilters, ClientUser, SortParamRequest } from 'stream-chat';
import { ChannelPaginator } from 'stream-chat';
import {
Channel,
ChannelAvatar,
ChannelHeader,
type ChannelListItemUIProps,
ChannelNavigation,
Chat,
MessageComposer,
MessageList,
SummarizedMessagePreview,
Thread,
ThreadHeader,
useCreateChatClient,
useMessageContext,
useWorkspaceNavigation,
WithComponents,
} from 'stream-chat-react';
import {
ChatView,
type ChatViewSlotRenderers,
type DeriveWorkspaceNavigation,
Slot,
} from 'stream-chat-react/slot-layout';
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: ClientUser = {
id: userId,
name: userName,
image: `https://getstream.io/random_png/?name=${userName}`,
};
const sort: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }];
const filters: ChannelFilters = {
type: 'messaging',
members: { $in: [userId] },
};
const ellipsis = {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const;
const CustomChannelListItem = ({
active,
channel,
displayImage,
displayTitle,
onSelect,
previewedMessage,
}: ChannelListItemUIProps) => {
const { openChannel } = useWorkspaceNavigation();
return (
<button
aria-pressed={active}
// A search result passes `onSelect`, which also loads the channel. Otherwise, passing the
// click event lets `deriveWorkspaceNavigation` below open the channel beside the current one
// on ⌘/ctrl-click.
onClick={(event) => (onSelect ? onSelect(event) : openChannel(channel, { event }))}
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?.custom?.image}
size='xl'
userName={displayTitle ?? channel.data?.custom?.name ?? 'Channel'}
/>
{/* `minWidth: 0` lets the text column shrink below its content, so a long title or preview
is cut off with an ellipsis instead of squeezing the avatar out of the row. */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={ellipsis}>
{displayTitle ?? channel.data?.custom?.name ?? 'Unnamed Channel'}
</div>
{previewedMessage ? (
<div style={{ ...ellipsis, fontSize: '14px', opacity: 0.75 }}>
<SummarizedMessagePreview latestMessage={previewedMessage} />
</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>
);
};
// One view ("channels") with two slots side by side. Each `<Slot/>` renders what is open in it, a
// channel or a thread, through `slotRenderers`. Declared outside the component, because
// `<ChatView/>` resets its layout when it gets a new array.
const chatViewLayouts = [{ id: 'channels' as const, slots: ['left', 'right'] }];
const slotRenderers: ChatViewSlotRenderers = {
channel: ({ source }) => (
<Channel channel={source}>
<ChannelHeader />
<MessageList />
<MessageComposer />
</Channel>
),
thread: ({ source }) => (
<Thread thread={source}>
<ThreadHeader />
<MessageList />
<MessageComposer />
</Thread>
),
};
// A plain click on a channel replaces the open one; ⌘/ctrl-click opens it beside, in the other slot.
const deriveWorkspaceNavigation: DeriveWorkspaceNavigation = (base) => ({
openChannel: (channel, options) =>
base.openChannel(channel, {
...options,
additive:
options?.additive ?? !!(options?.event?.metaKey || options?.event?.ctrlKey),
}),
});
const ChannelsWorkspace = () => (
<>
<ChannelNavigation />
{/* The slots' own container, so `layout.css` can react to the width they share. */}
<div className='channel-slots'>
<Slot slot='left' />
<Slot slot='right' />
</div>
</>
);
const App = () => {
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: user,
});
// A `ChannelPaginator` holds the channel list's filters and sort order, and the channels it has
// loaded. Registered on `client.channelManager`, the list stays up to date as messages and other
// events arrive. `<ChannelNavigation/>` renders one list per registered paginator.
useEffect(() => {
if (!client) return;
const paginator = new ChannelPaginator({
client,
filters,
id: 'channels:default',
paginatorOptions: { pageSize: 10 },
sort,
});
client.channelManager.insertPaginator({ paginator });
return () => {
client.channelManager.removePaginator(paginator);
};
}, [client]);
if (!client) return <div>Setting up client & connection...</div>;
return (
<WithComponents
overrides={{
ChannelListItemUI: CustomChannelListItem,
MessageUI: CustomMessage,
}}
>
<Chat client={client} theme='custom-theme'>
<ChatView
deriveWorkspaceNavigation={deriveWorkspaceNavigation}
layouts={chatViewLayouts}
slotRenderers={slotRenderers}
views={{ channels: <ChannelsWorkspace /> }}
/>
</Chat>
</WithComponents>
);
};
export default App;The custom row, <CustomChannelListItem/>, opens channels the way the default row does:
- It gets the row's props:
channel,active,displayTitle,displayImage, andpreviewedMessage, the message the row previews.<SummarizedMessagePreview/>renders that message as one line of text. - A click calls
onSelectwhen the row gets one. Search results pass it, and it also loads the channel. - Otherwise the click calls
WorkspaceNavigation.openChannel()with the click event, so thederiveWorkspaceNavigationfunction from Step 4 can open the channel beside the open one on ⌘-click or ctrl-click. - When the row gets no display title or image, it falls back to
channel.data.custom.nameandchannel.data.custom.image.
The custom row replaces the whole default row, so it drops the row's action buttons and context menu. To keep them, follow the Channel list UI recipe.
Checkpoint: channel rows render as rounded buttons with a large avatar, and messages render as your own bubbles. The custom message renders only the bubble, so hovering a message no longer shows the reaction, thread and actions buttons. Nothing changed? <WithComponents/> only applies to its descendants - it has to wrap the components whose parts you override.
Swap a single icon. To change only an icon, pass an icons map to <WithComponents/>. It's merged icon by icon, so you swap one icon without reimplementing the component that renders it:
<WithComponents overrides={{ icons: { IconFlag: MyFlagIcon, IconUser: MyUserIcon } }}>Icons you don't name keep their SDK default.
Step 7 - Enable the emoji picker and autocomplete
No chat experience is complete without emojis. This step adds two things to the composer: the SDK's <EmojiPicker/>, and emoji autocomplete as the user types a :shortcode. Both are built on the emoji-mart packages, so install those first.
npm i emoji-mart @emoji-mart/datayarn add emoji-mart @emoji-mart/dataImport the picker's stylesheet. Replace the first two lines of src/layout.css with these three:
@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, layout rules and slot rules stay exactly as they are */The picker's stylesheet goes in its own stream-plugins layer, between the SDK styles and your stream-overrides layer, so your theme tokens still win.
And now the actual code:
import { useEffect } from 'react';
import type {
ChannelFilters,
ClientUser,
SortParamRequest,
TextComposerMiddleware,
} from 'stream-chat';
import { ChannelPaginator } from 'stream-chat';
import {
Channel,
ChannelHeader,
ChannelNavigation,
Chat,
MessageComposer,
MessageList,
Thread,
ThreadHeader,
useCreateChatClient,
WithComponents,
} from 'stream-chat-react';
import {
ChatView,
type ChatViewSlotRenderers,
type DeriveWorkspaceNavigation,
Slot,
} from 'stream-chat-react/slot-layout';
import { createTextComposerEmojiMiddleware, 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: ClientUser = {
id: userId,
name: userName,
image: `https://getstream.io/random_png/?name=${userName}`,
};
const sort: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }];
const filters: ChannelFilters = {
type: 'messaging',
members: { $in: [userId] },
};
init({ data });
// One view ("channels") with two slots side by side. Each `<Slot/>` renders what is open in it, a
// channel or a thread, through `slotRenderers`. Declared outside the component, because
// `<ChatView/>` resets its layout when it gets a new array.
const chatViewLayouts = [{ id: 'channels' as const, slots: ['left', 'right'] }];
const slotRenderers: ChatViewSlotRenderers = {
channel: ({ source }) => (
<Channel channel={source}>
<ChannelHeader />
<MessageList />
<MessageComposer />
</Channel>
),
thread: ({ source }) => (
<Thread thread={source}>
<ThreadHeader />
<MessageList />
<MessageComposer />
</Thread>
),
};
// A plain click on a channel replaces the open one; ⌘/ctrl-click opens it beside, in the other slot.
const deriveWorkspaceNavigation: DeriveWorkspaceNavigation = (base) => ({
openChannel: (channel, options) =>
base.openChannel(channel, {
...options,
additive:
options?.additive ?? !!(options?.event?.metaKey || options?.event?.ctrlKey),
}),
});
const ChannelsWorkspace = () => (
<>
<ChannelNavigation />
{/* The slots' own container, so `layout.css` can react to the width they share. */}
<div className='channel-slots'>
<Slot slot='left' />
<Slot slot='right' />
</div>
</>
);
const App = () => {
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: user,
});
// Every composer - channel and thread, existing and new - gets emoji autocomplete: typing a
// `:shortcode` suggests matching emojis. The function returned from the setup function removes
// the middleware again, and the effect's cleanup unregisters the setup function.
useEffect(() => {
if (!client) return;
client.config.setSetupFunction('messageComposer', ({ composer }) => {
// One middleware per composer, so composers don't share suggestions.
const emojiMiddleware = createTextComposerEmojiMiddleware(
SearchIndex,
) as TextComposerMiddleware;
composer.textComposer.middlewareExecutor.insert({
middleware: [emojiMiddleware],
position: { before: 'stream-io/text-composer/mentions-middleware' },
unique: true,
});
return () => composer.textComposer.middlewareExecutor.remove(emojiMiddleware.id);
});
return () => client.config.setSetupFunction('messageComposer', null);
}, [client]);
// A `ChannelPaginator` holds the channel list's filters and sort order, and the channels it has
// loaded. Registered on `client.channelManager`, the list stays up to date as messages and other
// events arrive. `<ChannelNavigation/>` renders one list per registered paginator.
useEffect(() => {
if (!client) return;
const paginator = new ChannelPaginator({
client,
filters,
id: 'channels:default',
paginatorOptions: { pageSize: 10 },
sort,
});
client.channelManager.insertPaginator({ paginator });
return () => {
client.channelManager.removePaginator(paginator);
};
}, [client]);
if (!client) return <div>Setting up client & connection...</div>;
return (
<Chat client={client}>
<WithComponents overrides={{ EmojiPicker }}>
<ChatView
deriveWorkspaceNavigation={deriveWorkspaceNavigation}
layouts={chatViewLayouts}
slotRenderers={slotRenderers}
views={{ channels: <ChannelsWorkspace /> }}
/>
</WithComponents>
</Chat>
);
};
export default App;The setup works like this:
- The picker.
<WithComponents/>registersEmojiPickeras an override, and the composer shows a button that opens it. - Autocomplete. It comes from a text composer middleware: a function that runs on every text change in the composer.
createTextComposerEmojiMiddleware(SearchIndex)builds one that finds emojis with emoji-mart'sSearchIndex.init({ data })loads the emoji data the index searches. - Every composer gets it.
client.config.setSetupFunction('messageComposer', …)registers a setup function that runs for every composer, in channels and threads. It inserts the middleware, and returns a function that removes it again. The effect's cleanup unregisters the setup function by passingnull. The client keeps one setup function per key, so a second call replaces the first.
Note: To keep this block focused on the emoji setup, it renders
<Chat/>without thetheme='custom-theme'prop from Step 5 and without the custom components from Step 6. Add them back if you want to keep your theme and your custom channel rows and messages.
Checkpoint: the composer shows an emoji button that opens the picker, and typing : followed by a couple of letters brings up emoji autocomplete.
Note: More detail in Emoji and Composer middleware.
Verify the whole build
npm run devThen 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
- Collapsible sidebar
- Infinite scroll - how channel list paginators load more pages
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 components - 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 for showing your own kind of attachment. A custom <Attachment/> component renders its own UI when an attachment is of type product. To see it in action, the app watches a dedicated channel after the user connects, and sends a message carrying a product attachment if the channel has none yet. The custom component then renders it in the <MessageList/>.
This one stands apart from the main tutorial. It renders a single dedicated channel rather than combining with the channel list from Step 4, so treat it as a reference for custom attachment rendering rather than the next step in the app you just built.
First, declare the product's custom fields in src/stream-chat.d.ts. The recipe's code reads them, so it doesn't compile without this file:
import type { DefaultAttachmentData } from 'stream-chat-react';
declare module 'stream-chat' {
interface CustomAttachmentData extends DefaultAttachmentData {
image?: string;
name?: string;
url?: string;
}
}Then update src/App.tsx with the following code:
import { useEffect, useState } from 'react';
import type {
Attachment as AttachmentType,
ClientUser,
Channel as StreamChannel,
} from 'stream-chat';
import {
Attachment,
AttachmentPreviewList,
type AttachmentPreviewListProps,
type AttachmentProps,
Channel,
ChannelHeader,
Chat,
MessageComposer,
MessageList,
RemoveAttachmentPreviewButton,
ThreadHeader,
UnsupportedAttachmentPreview,
type UnsupportedAttachmentPreviewProps,
useCreateChatClient,
WithComponents,
} from 'stream-chat-react';
import { ChatView, ThreadSlot } from 'stream-chat-react/slot-layout';
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: ClientUser = {
id: userId,
name: userName,
image: `https://getstream.io/random_png/?name=${userName}`,
};
const attachments: AttachmentType[] = [
{
type: 'product',
// fields the attachment API doesn't define go under `custom`; ./stream-chat.d.ts declares
// their types
custom: {
image: 'https://images-na.ssl-images-amazon.com/images/I/71k0cry-ceL._SL1500_.jpg',
name: 'iPhone',
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.custom?.url} rel='noreferrer' target='_blank'>
<img
alt='custom-attachment'
height='120'
src={attachment.custom?.image}
style={{ borderRadius: '18px', marginTop: '8px', objectFit: 'cover' }}
/>
<div style={{ color: '#334155', marginTop: '8px' }}>
{attachment.custom?.name}
</div>
</a>
</div>
);
}
return <Attachment {...props} />;
};
// The composer has no preview of its own for a custom attachment type, so editing a message that
// carries a product would list it as unsupported. `<AttachmentPreviewList/>` takes the component
// for those: the product gets a card like the one in the message, and any other unknown type keeps
// the default.
const ProductAttachmentPreview = (props: UnsupportedAttachmentPreviewProps) => {
const { attachment, removeAttachments } = props;
if (attachment.type !== 'product') return <UnsupportedAttachmentPreview {...props} />;
return (
<div
style={{
alignItems: 'center',
background: '#ffffff',
borderRadius: '16px',
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.08)',
display: 'flex',
gap: '12px',
height: '72px',
padding: '8px 12px 8px 8px',
position: 'relative',
width: '290px',
}}
>
<img
alt=''
height='56'
src={attachment.custom?.image}
style={{ borderRadius: '12px', objectFit: 'cover' }}
width='56'
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: '#0f172a', fontSize: '12px', fontWeight: 700 }}>
Product recommendation
</div>
<div style={{ color: '#334155', marginTop: '4px' }}>
{attachment.custom?.name}
</div>
</div>
<RemoveAttachmentPreviewButton
onClick={() => removeAttachments([attachment.localMetadata.id])}
/>
</div>
);
};
const ProductAttachmentPreviewList = (props: AttachmentPreviewListProps) => (
<AttachmentPreviewList
{...props}
UnsupportedAttachmentPreview={ProductAttachmentPreview}
/>
);
// A thread opens in a slot of the `<ChatView/>` layout: a message's "Thread Reply" action asks
// workspace navigation to open it. The channel renders outside the slots, so the only slot is the
// thread's.
const chatViewLayouts = [{ id: 'channels' as const, slots: ['thread'] }];
const ChannelWorkspace = ({ channel }: { channel: StreamChannel }) => (
<>
<Channel channel={channel}>
<ChannelHeader />
<MessageList />
<MessageComposer />
</Channel>
{/* The custom attachment renders in the thread too: the `<WithComponents/>` in `App` wraps
both panels. */}
<ThreadSlot slot='thread'>
<ThreadHeader />
<MessageList />
<MessageComposer />
</ThreadSlot>
</>
);
const App = () => {
const [channel, setChannel] = useState<StreamChannel>();
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: user,
});
useEffect(() => {
if (!client) return;
const initChannel = async () => {
// One channel per user, as in Step 3: `members` only applies when the channel is created.
const channel = client.channel('messaging', `react-tutorial-products-${userId}`, {
members: [{ user_id: userId }],
custom: {
image: 'https://getstream.io/random_png/?name=products',
name: 'Product recommendations',
},
});
// `<Channel/>` shows a channel but doesn't load it, so the app loads it here.
// `channel.watch()` sends nothing for a channel that is already watched and loaded, and joins
// a watch in flight, so a re-run of this effect sends one request at most.
await channel.watch();
// the channel's message paginator holds the messages loaded so far
const hasProductMessage = (channel.messagePaginator.items ?? []).some((message) =>
message.attachments?.some(
(attachment) => 'type' in attachment && attachment.type === 'product',
),
);
if (!hasProductMessage) {
// `Channel.sendMessage()` takes the message under `message`
await channel.sendMessage({
message: {
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,
AttachmentPreviewList: ProductAttachmentPreviewList,
}}
>
<Chat client={client} theme='custom-theme'>
<ChatView
layouts={chatViewLayouts}
views={{ channels: <ChannelWorkspace channel={channel} /> }}
/>
</Chat>
</WithComponents>
);
};
export default App;What the recipe relies on:
- Custom fields. Fields that the attachment API doesn't define go under
custom. The product'simage,name, andurllive there, and<CustomAttachment/>reads them asattachment.custom.image,attachment.custom.name, andattachment.custom.url. - Sending the message.
Channel.sendMessage()takes the message undermessage. - Reading the loaded messages. The check for an existing product message reads
channel.messagePaginator.items, the messages the channel's message paginator has loaded. - Threads. As in Step 3, the channel renders inside
<ChatView/>with a thread slot, so a thread can open. TheAttachmentoverride applies in the thread too. - The composer preview. Editing a product message puts the product in the composer, which has no preview for a custom attachment type and lists it as unsupported.
<ProductAttachmentPreviewList/>passesProductAttachmentPreviewto<AttachmentPreviewList/>as itsUnsupportedAttachmentPreviewprop: a product gets a small card, and any other unknown type keeps the default preview. The Product card recipe builds the full flow, including adding a product from the composer.
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:
import { useEffect, useState } from 'react';
import type { ClientUser, Channel as StreamChannel } from 'stream-chat';
import {
Channel,
ChannelHeader,
Chat,
MessageComposer,
useCreateChatClient,
VirtualizedMessageList,
} 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: ClientUser = {
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', {
// the channel's own fields, such as its name and image, go under `custom`
custom: {
image: 'https://goo.gl/Zefkbx',
name: 'SpaceX launch discussion',
},
});
// `<Channel/>` shows a channel but doesn't load it, so the app loads it here.
// `spaceChannel.watch()` sends nothing for a channel that is already watched and loaded, and
// joins a watch in flight, so a re-run of this effect sends one request at most.
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>;
// No `<ChatView/>` and no thread slot: the channel renders on its own, so a thread has nowhere
// to open. The "Thread Reply" message action only logs a console warning here.
return (
<Chat client={chatClient} theme='str-chat__theme-dark'>
<Channel channel={channel}>
<ChannelHeader />
<VirtualizedMessageList />
<MessageComposer focus />
</Channel>
</Chat>
);
};
export default App;Four things differ from the main example:
- The
livestreamchannel type. Users don't have to be members to read and write in it. For a busy stream, turn off the type's read events and typing events on the server: Livestream chat lists the settings. - The
str-chat__theme-darktheme, which enables dark mode. - The
<VirtualizedMessageList/>component, which handles list virtualization out of the box and manages memory build-up under the high message volume of a livestream. - No
<ChatView/>. The channel renders on its own, so a thread has nowhere to open. The Thread Reply message action only logs a console warning here.
<MessageComposer/> is the same as in the main example, so commands, uploads, and the rest of the composer 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. With the pre-filled credentials from Option 2, the token expires after about five hours: open the page in a new tab and copy the new values.- Channel list says "No conversations yet", no errors - the user has no channels. Seed one with
getstream api GetOrCreateChannel(Step 1). With the pre-filled credentials, open the Step 3 app once with the same values: loading the page creates the user's channel. New pre-filled credentials are a new user, with no channels yet. - Nothing opens on load - by design. The slots from Step 4 stay empty until the user clicks a channel in the list.
- Unstyled or broken layout -
stream-chat-react/dist/css/index.cssisn't imported, orsrc/main.tsxstill imports Vite's defaultsrc/index.css. Delete theimport './index.css'line (Step 3). - 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 a channel's
imageorname- the fields belong undercustom. Pass them in thecustomobject ofclient.channel(), and read them aschannel.data.custom.nameandchannel.data.custom.image.stream-chat-reacttypes both. - TypeScript errors on a product attachment's
image,name, orurl-src/stream-chat.d.tsfrom the custom attachment recipe is missing, or the file is outsideincludeintsconfig.json. - A custom component never renders -
<WithComponents/>only applies to its descendants. It has to wrap the components whose parts you override. - Clicking a channel does nothing - the channel has no slot to open in. Declare the slots in
layouts, render a<Slot/>for each, and passslotRenderersto<ChatView/>, as in Step 4. A custom row has to callonSelectwhen it gets one, and otherwiseWorkspaceNavigation.openChannel(), as in Step 6. - Opening a thread does nothing - the layout has no thread slot. Render the channel inside
<ChatView/>with a slot a thread can open in: a<ThreadSlot/>as in Step 3, or a<Slot/>with athreadslot renderer as in Step 4. - Emoji picker opens but looks unstyled -
dist/css/emoji-picker.cssisn't imported. The picker's styles ship separately fromdist/css/index.css(Step 7). - Emoji autocomplete never triggers -
init({ data })wasn't called, or the emoji middleware wasn't inserted. Check the effect that callsclient.config.setSetupFunction('messageComposer', …)in Step 7.
Next steps
- AI chat experiences - add an AI assistant to a channel with streaming responses: AI components
- Push notifications - push notifications for your mobile apps
- 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
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

