Learn how to quickly integrate rich Generative AI experiences directly into Stream Chat. Learn More

React Chat Tutorial

The following tutorial shows you how to quickly build a chat app leveraging Stream's Chat API and the Stream Chat React components. The underlying API is very flexible and allows you to build nearly any type of chat experience.

Prefer to skip the setup? Add the Stream skill and let your AI agent build this for you.

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:

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.

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.

Terminal (bash)
1
2
3
4
5
6
7
# 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:

Prompt (markdown)
1
2
3
/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 .md to any Stream docs URL for a clean Markdown version. A condensed index for the React Chat SDK is at https://getstream.io/chat/docs/sdk/react/llms.txt. Prefer these over parsing HTML.

Important Building Blocks

Four pieces, one mental model:

  • useCreateChatClient - creates the StreamChat client 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 for useChatContext instead.
  • Chat and Channel - React context providers. Chat holds the client and the theme; Channel holds one channel's state, its UI components, and its messaging functions.
  • UI components - ChannelList, MessageList, MessageComposer, Thread, and Window. 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.

  • Node.js (version 24 or higher)
  • npm (ships with Node) or Yarn

Checkpoint: both commands print a version.

Terminal (bash)
1
2
node --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:

shell
1
2
3
npm create vite chat-example -- --template react-ts cd chat-example npm i 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):

Terminal (bash)
1
curl -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.

Terminal (bash)
1
getstream 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.

Terminal (bash)
1
getstream 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):

Terminal (bash)
1
2
getstream 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:

Terminal (bash)
1
2
3
4
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? 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 useCreateChatClient hook only once per application. If you need the client instance somewhere down in the component tree, use the useChatContext hook (exported by the stream-chat-react SDK) to access it.

App.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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:

ts
1
const 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 Chat component, 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:

stream-chat.d.ts (ts)
1
2
3
4
5
6
7
import { 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:

layout.css (css)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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%; }

Note: Delete Vite's default src/index.css import so its rules don't interfere with your styling.

3. Build the app. Extend src/App.tsx with the core component setup:

App.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import { 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:

App.tsx (tsx)
1
2
3
// 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

Stream Chat React Overview with Pointers

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:

App.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import 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:

layout.css (css)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@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:

App.tsx (tsx)
1
2
3
4
5
6
7
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 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:

tsx
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:

App.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
import 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.

shell
1
npm i emoji-mart @emoji-mart/data

Import the picker's stylesheet:

layout.css (css)
1
2
3
4
5
@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:

App.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import { 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 the theme="custom-theme" prop from Step 5 and without the options prop on ChannelList. 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

Terminal (bash)
1
npm 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

Messages and composer

Theming

Beyond the basics

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:

App.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
import { 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:

stream-chat.d.ts (ts)
1
2
3
4
5
6
7
8
9
10
11
12
13
import { 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:

App.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import { 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 livestream channel type, which disables typing events and seen/read states.
  • The str-chat__theme-dark theme, 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.

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 with getstream 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.css isn't imported, or Vite's default src/index.css is still in play. Delete it.
  • Styles stopped applying after Step 5 - layer order. @layer stream, stream-overrides; must come first, and the import must carry layer(stream).
  • stream project is not initialized - CLI onboarding not run. Run getstream init in the project directory first.
  • TypeScript errors on image or name - src/stream-chat.d.ts is missing, or the file is outside include in tsconfig.json.
  • A custom component never renders - WithComponents only applies to its descendants. It has to wrap the component that renders the overridden view.
  • Clicking a channel does nothing - <Channel> was given a fixed channel prop, which overrides the list's active channel.
  • Emoji picker opens but looks unstyled - dist/css/emoji-picker.css isn't imported. Plugin styles ship separately from dist/css/index.css (Step 7).
  • Emoji autocomplete never triggers - init({ data }) wasn't called, or emojiSearchIndex wasn't passed to MessageComposer.

Next steps

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, then getstream skills and getstream skills stream-react for 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 (writes VITE_STREAM_API_KEY to .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-react in your agent for React and Next.js setup patterns; /stream-docs searches live SDK docs with citations
  • Docs index for LLMs: https://getstream.io/chat/docs/sdk/react/llms.txt
  • Markdown endpoints: append .md to 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

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding for free

No credit card required.
If you're interested in a custom plan or have any questions, please contact us.