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

React Native Chat App Expo Tutorial

Build a fully functioning messaging app using Stream's React Native SDK component library. By the end of this tutorial, you'll have a mobile app that you can easily customize in both behavior and style with minimal code changes.

Prefer to skip the setup? Add the Stream skill and let your AI agent build your React Native chat app.

This tutorial teaches you how to build a fully featured mobile messaging app with Expo and Stream's Chat Expo SDK - channel list, message composer, reactions, threads, typing indicators, and file uploads, running on both iOS and Android. You can use it as the foundation for any type of in-app chat.

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:

The current Stream Chat Expo SDK supports the React Native New Architecture only. If your app still uses the old architecture, migrate that first before integrating the SDK.

We also have a sample project available if you want to compare your setup against a working app.

Using the React Native CLI instead of Expo? Follow the React Native CLI chat tutorial.

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 Expo. 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 Expo 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 Native pack installs # on demand the first time it's needed, or add it explicitly: getstream skills getstream skills stream-react-native

Then ask your agent:

Prompt (markdown)
1
2
3
4
/stream-react-native Build an Expo chat app with Expo Router, a channel list screen, a channel screen, and a thread screen. 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 on a simulator or emulator - launching one and trusting a native build is a human call.

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: build a development build if the agent hasn't (npx expo run ios or npx expo run android). You should see a channel list, be able to open a channel, and 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. Titled code blocks show the parts of that file that changed at that point in the tutorial; comments such as // ...rest of the code mark everything you keep as is.

For AI assistants reading this page: append .md to any Stream docs URL for a clean Markdown version. A condensed index for the React Native Chat SDK is at https://getstream.io/chat/docs/sdk/react-native/llms.txt. Prefer these over parsing HTML.

Important Building Blocks

Five 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.

  • OverlayProvider - the outermost Stream component, mounted near the root of your app. It hosts the full-screen image viewer and the message context menu.

  • Chat and Channel - React context providers. Chat holds the client and connection state; Channel holds one channel's state and acts as the bridge between the message list and the composer.

    Theme and translations come from both OverlayProvider and Chat - each one provides ThemeContext and TranslationContext, so components read whichever is closest above them.

  • UI components - ChannelList, MessageList, MessageComposer, and Thread. Use them as-is, or replace them as you see fit.

  • WithComponents - the customization surface. Swap any SDK-owned view for your own; everything you don't name keeps its default. It nests, so inner overrides merge over outer ones and the closest provider wins.

The SDK does not drill props - components read what they need from context, via hooks such as useChannelContext, useMessageContext, and useTheme. The contexts documentation lists every context, the hook that reads it, and the component that provides it.

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.

Prerequisites

You need a working Expo development environment. Follow Expo's Set Up Your Environment guide, choosing a development build as the target - stream-chat-expo ships native code, so it needs a development build rather than Expo Go.

Step 0 - Set up the project

To get started, create a new application with the Expo CLI:

bash
1
2
3
4
5
# Initialize the app npx create-expo-app MyStreamChatApp --template blank-typescript # Navigate to the app directory cd MyStreamChatApp

Install the Stream Chat Expo SDK:

bash
1
npx expo install stream-chat-expo

The SDK already includes the low-level stream-chat client, so you do not need to install stream-chat separately.

Stream Chat requires a set of peer dependencies for the core React Native UI experience. Follow the installation steps for each dependency to ensure everything is configured correctly.

Install the required peer dependencies:

bash
1
npx expo install @react-native-community/netinfo expo-image-manipulator react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-svg react-native-teleport react-native-worklets -- --force

Application level setup

The most important steps to get started are:

  • If you are using react-native-reanimated version >=4.3.0, add the following reanimated config to your package.json:
package.json (json)
1
2
3
4
5
6
7
{ "reanimated": { "staticFeatureFlags": { "FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false } } }

The FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS flag changes how Reanimated commits animated styles and the order in which those updates are applied. At the moment, that behavior can conflict with the SDK, so it is safer to keep it disabled. Check your installed version with npm ls react-native-reanimated.

  • Import react-native-gesture-handler at the top of your app entry. With Expo Router, we will do that in app/_layout.tsx in Step 2.

  • Wrap your app root with both SafeAreaProvider and GestureHandlerRootView. We will also do that in app/_layout.tsx.

  • Add a scheme to app.json. Expo Router needs it for deep linking, and it has to be set before the first native build:

app.json (json)
1
2
3
4
5
6
{ "expo": { "name": "MyStreamChatApp", "scheme": "mystreamchatapp" } }

If you enable optional features such as audio recording, camera access, or media library access, make sure you also add the required platform permissions for those features.

Now build and run the app. This compiles a development build, installs it, and starts the bundler for you:

iOS
Android
bash
1
npx expo run ios

Once the development build is installed, you can start the bundler on its own with npx expo start --dev-client instead of rebuilding, and only re-run npx expo run after you add or change a native dependency.

Checkpoint: stream-chat-expo and the peer dependencies appear in package.json, and the development build launches and shows the Expo starter screen on a simulator or emulator.

Step 1 - Define the type system

Declare the SDK interfaces in your app so Stream's extended types resolve correctly. The module declaration needs to take place so that types are properly resolved within your application. You can read more about this in the TypeScript guide.

To do this, you can create a new TypeScript declaration file and add the following code to it:

custom-types.d.ts (typescript)
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
import { DefaultAttachmentData, DefaultChannelData, DefaultCommandData, DefaultEventData, DefaultMemberData, DefaultMessageData, DefaultPollData, DefaultPollOptionData, DefaultReactionData, DefaultThreadData, DefaultUserData, } from "stream-chat-expo"; declare module "stream-chat" { /* eslint-disable @typescript-eslint/no-empty-object-type */ interface CustomAttachmentData extends DefaultAttachmentData {} interface CustomChannelData extends DefaultChannelData {} interface CustomCommandData extends DefaultCommandData {} interface CustomEventData extends DefaultEventData {} interface CustomMemberData extends DefaultMemberData {} interface CustomUserData extends DefaultUserData {} interface CustomMessageData extends DefaultMessageData {} interface CustomPollOptionData extends DefaultPollOptionData {} interface CustomPollData extends DefaultPollData {} interface CustomReactionData extends DefaultReactionData {} interface CustomThreadData extends DefaultThreadData {} /* eslint-enable @typescript-eslint/no-empty-object-type */ }

This will make sure that all of the SDK interfaces are properly resolved and the types are correct. Additionally, this will allow you to declare your own custom data if your integration requires it.

Checkpoint: custom-types.d.ts exists at the project root.

Step 2 - Add navigation

The Stream Chat SDK does not handle navigation, but Expo Router makes it easy to set up the channel list, channel, and thread screens we need for the application.

Install the following packages to get started with Expo Router, as mentioned in their documentation.

bash
1
npx expo install expo-router react-native-screens expo-linking expo-constants expo-status-bar -- --force

After this step, follow the Expo Router installation guide to make sure the entry point is set up correctly for your app. Reminder that you already added the scheme in Step 0. No Babel setup is needed.

In particular, make sure your package.json includes the Expo Router entry point:

package.json (json)
1
2
3
{ "main": "expo-router/entry" }

react-native-screens ships native code, so rebuild the development build with npx expo run ios or npx expo run android rather than only restarting the bundler.

We'll set up a simple stack to hold the necessary screens for navigation in our app, and start with a basic HomeScreen, which we will replace later with chat related screens.

You can copy-paste the following code into the app/_layout.tsx file:

app/_layout.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import "react-native-gesture-handler"; import { Stack } from "expo-router"; import { StyleSheet } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { SafeAreaProvider } from "react-native-safe-area-context"; export default function RootLayout() { return ( <SafeAreaProvider> <GestureHandlerRootView style={styles.container}> <Stack /> </GestureHandlerRootView> </SafeAreaProvider> ); } const styles = StyleSheet.create({ container: { flex: 1, }, });

Then create a basic HomeScreen in app/index.tsx:

app/index.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { StatusBar } from "expo-status-bar"; import { StyleSheet, Text, View } from "react-native"; export default function HomeScreen() { return ( <View style={styles.centered}> <Text>Home Screen</Text> <StatusBar style="auto" /> </View> ); } const styles = StyleSheet.create({ centered: { alignItems: "center", flex: 1, justifyContent: "center", }, });

Checkpoint: the app shows the text Home Screen centered on the page, under the default Expo Router header.

Step 3 - Credentials

The next step 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. Pick one, then finish with the "Store the credentials" step.

Option 1 - Your own Stream app, via the Stream CLI

The getstream CLI provisions all of it in one flow. Run these from the MyStreamChatApp directory you created in Step 0.

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. Expo is a first-class target for the CLI:

Terminal (bash)
1
getstream env --target expo

This writes EXPO_PUBLIC_STREAM_API_KEY into .env and adds that file to .gitignore. The API secret is never written to a client target. Expo loads EXPO_PUBLIC_* variables into the bundle automatically, so you can read the key with process.env.EXPO_PUBLIC_STREAM_API_KEY instead of pasting it into your source.

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. 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"}]}}'

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? Use these values in the "Store the credentials" step:

js
1
2
3
4
export const chatApiKey = "REPLACE_WITH_API_KEY"; export const chatUserId = "REPLACE_WITH_USER_ID"; export const chatUserName = "REPLACE_WITH_USER_NAME"; export const chatUserToken = "REPLACE_WITH_USER_TOKEN";

Swap in your own credentials from Option 1 before building anything real. Tutorial credentials are shared and short lived.

Store the credentials

Whichever option you picked, put the four values in a small config file:

chatConfig.ts (js)
1
2
3
4
export const chatApiKey = "<your API key>"; export const chatUserId = "<your user id>"; export const chatUserName = "<your user name>"; export const chatUserToken = "<your user token>";

On Option 1, read the API key from the .env file the CLI wrote instead of hardcoding it:

chatConfig.ts (ts)
1
export const chatApiKey = process.env.EXPO_PUBLIC_STREAM_API_KEY!;

For production apps, generate user tokens on your backend and return them to the client instead of hardcoding them in the app.

Checkpoint: chatConfig.ts holds four non-placeholder values, and the API key and token belong to the same Stream app.

Step 4 - Connect the user

Before rendering any chat UI, create a StreamChat client and connect the current user. The easiest way to do that in an Expo app is with the useCreateChatClient hook.

Create a small wrapper component that creates the client before rendering the app content:

components/ChatWrapper.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
import React, { PropsWithChildren } from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useCreateChatClient } from "stream-chat-expo"; import { chatApiKey, chatUserId, chatUserName, chatUserToken, } from "../chatConfig"; const user = { id: chatUserId, name: chatUserName, }; export const ChatWrapper = ({ children }: PropsWithChildren) => { const chatClient = useCreateChatClient({ apiKey: chatApiKey, userData: user, tokenOrProvider: chatUserToken, }); if (!chatClient) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return <>{children}</>; };

Note: Make sure you 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 stream-chat-expo to access it.

Checkpoint: components/ChatWrapper.tsx exists and the app still builds. Nothing changes on screen yet - ChatWrapper isn't mounted until Step 5, which is where you'll see the connection happen.

Step 5 - Add the providers

Three providers go around the stack: your own app context for the selected channel and thread, then Stream's OverlayProvider and Chat.

5.1 - Create the app context

Ideally, a context should store the current Channel and Thread selected by the user while moving through the app.

Create contexts/AppContext.tsx:

contexts/AppContext.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
import React, { PropsWithChildren, useState } from "react"; import type { Channel, LocalMessage } from "stream-chat"; type AppContextValue = { channel: Channel | null; setChannel: (channel: Channel | null) => void; thread: LocalMessage | null; setThread: (thread: LocalMessage | null) => void; }; export const AppContext = React.createContext<AppContextValue>({ channel: null, setChannel: () => {}, thread: null, setThread: () => {}, }); export const AppProvider = ({ children }: PropsWithChildren) => { const [channel, setChannel] = useState<Channel | null>(null); const [thread, setThread] = useState<LocalMessage | null>(null); return ( <AppContext.Provider value={{ channel, setChannel, thread, setThread }}> {children} </AppContext.Provider> ); }; export const useAppContext = () => React.useContext(AppContext);

To use the context, update app/_layout.tsx so it wraps the stack with both ChatWrapper and AppProvider:

app/_layout.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
import "react-native-gesture-handler"; import { Stack } from "expo-router"; import { StyleSheet } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { ChatWrapper } from "../components/ChatWrapper"; import { AppProvider } from "../contexts/AppContext"; export default function RootLayout() { return ( <SafeAreaProvider> <GestureHandlerRootView style={styles.container}> <ChatWrapper> <AppProvider> <Stack /> </AppProvider> </ChatWrapper> </GestureHandlerRootView> </SafeAreaProvider> ); } const styles = StyleSheet.create({ container: { flex: 1, }, });

5.2 - Add the OverlayProvider

The OverlayProvider is the highest level of the Stream Chat components and must be used near the root of your application, below SafeAreaProvider.

The OverlayProvider allows users to open the full-screen image viewer and message context menu as overlays on top of the rest of the application. You can go through the available props here.

Update ChatWrapper so it wraps the app content in OverlayProvider:

components/ChatWrapper.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
import React, { PropsWithChildren } from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { OverlayProvider, useCreateChatClient } from "stream-chat-expo"; import { chatApiKey, chatUserId, chatUserName, chatUserToken, } from "../chatConfig"; const user = { id: chatUserId, name: chatUserName, }; export const ChatWrapper = ({ children }: PropsWithChildren) => { const chatClient = useCreateChatClient({ apiKey: chatApiKey, userData: user, tokenOrProvider: chatUserToken, }); if (!chatClient) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return <OverlayProvider>{children}</OverlayProvider>; };

If you see some errors at this point, please refer to our troubleshooting guide.

5.3 - Add the Chat component

The Chat component provides the chat client, connection state, translations, and theme to the rest of the SDK.

Update ChatWrapper again so it wraps the app content with Chat inside OverlayProvider:

components/ChatWrapper.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
import React, { PropsWithChildren } from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Chat, OverlayProvider, useCreateChatClient } from "stream-chat-expo"; import { chatApiKey, chatUserId, chatUserName, chatUserToken, } from "../chatConfig"; const user = { id: chatUserId, name: chatUserName, }; export const ChatWrapper = ({ children }: PropsWithChildren) => { const chatClient = useCreateChatClient({ apiKey: chatApiKey, userData: user, tokenOrProvider: chatUserToken, }); if (!chatClient) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return ( <OverlayProvider> <Chat client={chatClient}>{children}</Chat> </OverlayProvider> ); };

Checkpoint: the app briefly shows Loading chat ... and then renders the Home Screen once the websocket connects. app/_layout.tsx wraps the stack in ChatWrapper and AppProvider, and ChatWrapper returns OverlayProvider -> Chat.

Step 6 - Add the channel list

The ChannelList component queries channels for the connected user and renders them in a FlatList.

6.1 - Add the channel list screen

Before configuring this component, let's set up a screen for the channel list by replacing HomeScreen in app/index.tsx with ChannelListScreen.

app/index.tsx (tsx)
1
2
3
4
5
6
7
8
9
import { Stack } from "expo-router"; export default function ChannelListScreen() { return ( <> <Stack.Screen options={{ title: "Channels" }} /> </> ); }

6.2 - Display the channel list

Now we can render the ChannelList component within ChannelListScreen.

The ChannelList can be used with no props and will return all channels to which the set user has access. In practical applications, you will probably want to show only the channels that the current user is a member of.

For such filtering purposes, you can provide a filters prop to ChannelList, which will filter the channels.

Additionally, the ChannelList component takes sort props to sort the channels and options props to provide additional query options. Please check out Querying Channels in our documentation for more information and various use cases of filters, sort, and options.

app/index.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
import { Stack } from "expo-router"; import { ChannelList } from "stream-chat-expo"; import type { ChannelSort } from "stream-chat"; import { chatUserId } from "../chatConfig"; const filters = { members: { $in: [chatUserId], }, type: "messaging", }; const sort: ChannelSort = { last_message_at: -1, }; const options = { limit: 20, presence: true, state: true, watch: true, }; export default function ChannelListScreen() { return ( <> <Stack.Screen options={{ title: "Channels" }} /> <ChannelList filters={filters} options={options} sort={sort} /> </> ); }

If you don't see any channels, drop the members filter from the filters object.

Checkpoint: the "Channels" screen lists the channels your user belongs to.

Channel list on iOS iOS

Channel list on Android Android

Step 7 - Channel screen

7.1 - Navigate to the channel screen

You can add the press handler for the list item within the ChannelList component using the onSelect prop. This is where you can add the logic for navigating to the channel screen, where we will render the message list and composer.

Let's implement the basic ChannelScreen component and logic for navigating from ChannelList to ChannelScreen.

Create a new route file at app/channel/[cid].tsx:

app/channel/[cid].tsx (tsx)
1
2
3
4
5
6
7
8
9
import { Stack } from "expo-router"; export default function ChannelScreen() { return ( <> <Stack.Screen options={{ title: "Channel" }} /> </> ); }

Then update ChannelListScreen so it stores the selected channel and navigates to the route:

app/index.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
import { Stack, useRouter } from "expo-router"; import { ChannelList } from "stream-chat-expo"; import type { ChannelSort } from "stream-chat"; import { chatUserId } from "../chatConfig"; import { useAppContext } from "../contexts/AppContext"; const filters = { members: { $in: [chatUserId], }, type: "messaging", }; const sort: ChannelSort = { last_message_at: -1, }; const options = { limit: 20, presence: true, state: true, watch: true, }; export default function ChannelListScreen() { const router = useRouter(); const { setChannel } = useAppContext(); return ( <> <Stack.Screen options={{ title: "Channels" }} /> <ChannelList filters={filters} options={options} sort={sort} onSelect={(channel) => { setChannel(channel); router.push({ pathname: "/channel/[cid]", params: { cid: channel.cid }, }); }} /> </> ); }

7.2 - Display the channel screen

The channel screen will comprise three main components:

  • MessageList component used to render the list of messages sent in a channel.
  • MessageComposer component used to render the input box needed to send messages, images, files, and commands to a channel.
  • Channel component that holds all data related to a channel. It also acts as a bridge between the MessageList and MessageComposer components.

The Channel component takes the channel as a prop. The MessageList and MessageComposer components don't need any props to be set, and we'll use the defaults set for these components.

app/channel/[cid].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
import { useRef } from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Stack } from "expo-router"; import { useHeaderHeight } from "expo-router/react-navigation"; import { Channel, MessageComposer, MessageList } from "stream-chat-expo"; import { useAppContext } from "../../contexts/AppContext"; export default function ChannelScreen() { const { channel } = useAppContext(); const headerHeight = useHeaderHeight(); const headerHeightRef = useRef(headerHeight); if (!channel) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return ( <> <Stack.Screen options={{ title: "Channel" }} /> <Channel channel={channel} keyboardVerticalOffset={headerHeightRef.current} topInset={headerHeightRef.current} > <MessageList /> <MessageComposer /> </Channel> </> ); }

Checkpoint: tapping a channel opens it. You can send messages, long press a message to open the message menu and add a reaction, and use the built-in composer UI.

Channel screen on iOS iOS

Channel screen on Android Android

The default composer is text-only. Image and file attachments need the optional media packages - see the installation guide.

Step 8 - Thread screen

The Threads feature is similar to Slack's, which allows you to start a conversation about a particular message in a message list.

8.1 - Create the thread screen

Let's first set up a separate screen for the thread within our navigation stack.

Create a new route file at app/channel/[cid]/thread/[messageId].tsx:

app/channel/[cid]/thread/[messageId].tsx (tsx)
1
2
3
4
5
6
7
8
9
import { Stack } from "expo-router"; export default function ThreadScreen() { return ( <> <Stack.Screen options={{ title: "Thread" }} /> </> ); }

8.2 - Navigate to the thread screen

As explained in the previous step, when a user long presses a message, it opens an overlay where the user can add a reaction and see many actions for the message.

MessageList accepts an onThreadSelect prop, which gets called when a user selects the "Thread Reply" action on the message overlay.

Update ChannelScreen so it stores the selected thread and navigates to ThreadScreen:

app/channel/[cid].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
import { useRef } from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Stack, useRouter } from "expo-router"; import { useHeaderHeight } from "expo-router/react-navigation"; import { Channel, MessageComposer, MessageList } from "stream-chat-expo"; import { useAppContext } from "../../contexts/AppContext"; export default function ChannelScreen() { const router = useRouter(); const { channel, thread, setThread } = useAppContext(); const headerHeight = useHeaderHeight(); const headerHeightRef = useRef(headerHeight); if (!channel) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return ( <> <Stack.Screen options={{ title: "Channel" }} /> <Channel channel={channel} keyboardVerticalOffset={headerHeightRef.current} topInset={headerHeightRef.current} thread={thread} > <MessageList onThreadSelect={(message) => { setThread(message); if (!message) return; router.push({ pathname: "/channel/[cid]/thread/[messageId]", params: { cid: channel.cid, messageId: message.id, }, }); }} /> <MessageComposer /> </Channel> </> ); }

8.3 - Display the thread screen

The Thread component must be rendered inside Channel, with the current thread set on the thread prop and threadList enabled.

This way, the Channel component is aware that it is being rendered within a thread screen and can avoid concurrency issues.

app/channel/[cid]/thread/[messageId].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
import { useRef } from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Stack } from "expo-router"; import { useHeaderHeight } from "expo-router/react-navigation"; import { Channel, Thread } from "stream-chat-expo"; import { useAppContext } from "../../../../contexts/AppContext"; export default function ThreadScreen() { const { channel, thread, setThread } = useAppContext(); const headerHeight = useHeaderHeight(); const headerHeightRef = useRef(headerHeight); if (!channel || !thread) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return ( <> <Stack.Screen options={{ title: "Thread" }} /> <Channel channel={channel} keyboardVerticalOffset={headerHeightRef.current} topInset={headerHeightRef.current} thread={thread} threadList > <Thread onThreadDismount={() => setThread(null)} /> </Channel> </> ); }

Thread renders its own message list and composer automatically, so you do not need to place MessageList and MessageComposer inside it yourself.

Checkpoint: long press a message, choose "Thread Reply", and the Thread screen opens with the parent message, its own message list, and its own composer. Sending a reply keeps you in the thread.

Thread screen on iOS iOS

Thread screen on Android Android

Step 9 - Theming

So far you've used the SDK's default look. Theming in the Expo SDK is a plain object of React Native styles that is deep-merged with the defaults, so you only declare the keys you want to change. The DeepPartial<Theme> type keeps those keys type-checked.

Add a theme object to ChatWrapper and pass it to OverlayProvider through its value prop:

components/ChatWrapper.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
import type { DeepPartial, Theme } from "stream-chat-expo"; // ...rest of the code const chatTheme: DeepPartial<Theme> = { semantics: { chatBgOutgoing: "#81c784", // darker green - your own message bubbles }, messageList: { container: { backgroundColor: "#c8e6c9", // light green - message list background }, }, }; export const ChatWrapper = ({ children }: PropsWithChildren) => { // ...existing chatClient logic return ( <OverlayProvider value={{ style: chatTheme }}> <Chat client={chatClient}>{children}</Chat> </OverlayProvider> ); };

semantics is the SDK's color token layer. Name every token you want recolored - overriding an upstream token such as accentPrimary does not cascade to the leaves.

Checkpoint: open a channel - the message list sits on a light green background and your own messages are a darker green. Messages from other people keep the SDK's default bubble color, because chatBgOutgoing only recolors your side; its mirror token is chatBgIncoming.

Themed channel on iOS iOS

Themed channel on Android Android

Read more about theming, including dark mode, in our theming documentation.

Step 10 - Customization

Theming changes tokens. To replace an actual view, use WithComponents and pass an overrides object - everything you don't name keeps its default.

tsx
1
2
3
4
5
6
7
8
import { WithComponents } from "stream-chat-expo"; <WithComponents overrides={{ MessageItemView: MyCustomMessage }}> <Channel channel={channel}> <MessageList /> <MessageComposer /> </Channel> </WithComponents>;

WithComponents supports nesting - inner overrides merge over outer ones, so the closest provider wins. This lets you set app-wide defaults near the root and narrow overrides deeper in the tree (e.g. different components inside a thread screen).

10.1 - Customize the channel list

The ChannelList is essentially a FlatList of channels.

To customize the channel list item, override the ChannelPreview component using WithComponents. The default is ChannelPreviewView.

Objective: Highlight unread channels while keeping the default preview UI

Let's start by creating a custom list item component, which returns the default UI component ChannelPreviewView.

app/index.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
import { Stack, useRouter } from "expo-router"; import { ChannelList, ChannelPreviewView, WithComponents, } from "stream-chat-expo"; import type { ChannelPreviewViewProps } from "stream-chat-expo"; import type { ChannelSort } from "stream-chat"; import { chatUserId } from "../chatConfig"; import { useAppContext } from "../contexts/AppContext"; const filters = { members: { $in: [chatUserId], }, type: "messaging", }; const sort: ChannelSort = { last_message_at: -1, }; const options = { limit: 20, presence: true, state: true, watch: true, }; const CustomListItem = (props: ChannelPreviewViewProps) => { return <ChannelPreviewView {...props} />; }; export default function ChannelListScreen() { const router = useRouter(); const { setChannel } = useAppContext(); return ( <> <Stack.Screen options={{ title: "Channels" }} /> <WithComponents overrides={{ ChannelPreview: CustomListItem }}> <ChannelList filters={filters} options={options} sort={sort} onSelect={(channel) => { setChannel(channel); router.push({ pathname: "/channel/[cid]", params: { cid: channel.cid }, }); }} /> </WithComponents> </> ); }

The unread count on channels can be accessed via the unread prop. We will use this count to conditionally add a light green background for unread channels, and leave read channels transparent.

app/index.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
import { StyleSheet, View } from "react-native"; // ...rest of the code const CustomListItem = (props: ChannelPreviewViewProps) => { const { unread } = props; const containerStyle = unread ? styles.unreadContainer : styles.previewContainer; return ( <View style={containerStyle}> <ChannelPreviewView {...props} /> </View> ); }; const styles = StyleSheet.create({ container: { flex: 1, }, previewContainer: { backgroundColor: '#598BAF', // blue }, unreadContainer: { backgroundColor: '#c8e6c9', // green }, });

You won't see any background color change yet, since the ChannelPreviewView has a white background by default.

To make the wrapped view background visible, add a channelPreview key to the chatTheme you created in Step 9:

components/ChatWrapper.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const chatTheme: DeepPartial<Theme> = { semantics: { chatBgOutgoing: "#81c784", // darker green - your own message bubbles }, channelPreview: { container: { backgroundColor: "transparent", }, }, messageList: { container: { backgroundColor: "#c8e6c9", // light green - message list background }, }, };

Similarly, along with customizing the entire list item component, you can also override individual components within the list item, for example, ChannelPreviewStatus, ChannelPreviewAvatar, ChannelPreviewMessage, and ChannelPreviewUnreadCount, by adding them to the overrides object. You can use the visual guide to find out which components you can customize.

Unread channel highlighted on iOS iOS

Unread channel highlighted on Android Android

10.2 - Customize the message list

All components within the MessageList and MessageComposer can be customized using WithComponents.

Objective: Replace the Default Message UI with a Custom Component

The most common use case of customizing the MessageList is to have a custom UI for the message. You can do so by overriding MessageItemView via WithComponents as shown below.

app/channel/[cid].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
import { WithComponents } from "stream-chat-expo"; // ...rest of the code const CustomMessage = () => { return null; }; export default function ChannelScreen() { // ...rest of the code const headerHeightRef = useRef(headerHeight); return ( <> <Stack.Screen options={{ title: "Channel" }} /> <WithComponents overrides={{ MessageItemView: CustomMessage }}> <Channel channel={channel} keyboardVerticalOffset={headerHeightRef.current} topInset={headerHeightRef.current} thread={thread} > <MessageList onThreadSelect={(message) => { setThread(message); if (!message) return; router.push({ pathname: "/channel/[cid]/thread/[messageId]", params: { cid: channel.cid, messageId: message.id, }, }); }} /> <MessageComposer /> </Channel> </WithComponents> </> ); }

Now that we have configured the component, let's render the message on the UI. You can access the message object from the MessageContext. The MessageContext also gives you access to a boolean isMyMessage which you can use to style the message UI conditionally.

You can also access plenty of other useful properties and callbacks from this context, such as setQuotedMessage, handleReaction, and onLongPress. Please check the MessageContext documentation for the full list.

app/channel/[cid].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
import { useCallback } from "react"; import { Pressable, StyleSheet, Text } from "react-native"; import type { GestureResponderEvent } from "react-native"; import { useMessageContext } from "stream-chat-expo"; // ...rest of the code const CustomMessage = () => { const { contextMenuAnchorRef, isMyMessage, message, onLongPress } = useMessageContext(); const handleLongPress = useCallback( (event: GestureResponderEvent) => onLongPress({ event }), [onLongPress], ); return ( <Pressable onLongPress={handleLongPress} ref={contextMenuAnchorRef} style={isMyMessage ? styles.myMessage : styles.message} > <Text>{message.text}</Text> </Pressable> ); }; const styles = StyleSheet.create({ container: { flex: 1, }, previewContainer: { backgroundColor: '#598BAF', // blue }, unreadContainer: { backgroundColor: '#c8e6c9', // green }, message: { alignSelf: "flex-start", backgroundColor: "#ededed", borderRadius: 10, margin: 10, padding: 10, width: "70%", }, myMessage: { alignSelf: "flex-end", backgroundColor: "#ADD8E6", borderRadius: 10, margin: 10, padding: 10, width: "70%", }, });

This is a really simplified version of a custom message UI that displays only text. You can obviously add functionalities such as onPress, onLongPress handlers, and message actions according to your needs.

Generally, you wouldn't need to customize the entire message UI, but only the required parts such as MessageStatus, MessageAuthor, and MessageTimestamp. For this purpose, you can check the component customization guide to decide which key to pass in the overrides object. You can access MessageContext at every message-level component.

Checkpoint: unread channels in the list have a light green background behind the default preview, and messages render as your own bubbles - gray and left-aligned for others, blue and right-aligned for your own.

Custom message UI on iOS iOS

Custom message UI on Android Android

Verify the whole build

Step 10 replaced the message UI with a component that renders message.text and nothing else, so reactions, link previews and attachments do not show up on screen. Hand the SDK's default MessageItemView back to WithComponents before running the checks below:

app/channel/[cid].tsx (tsx)
1
2
3
4
5
6
import { MessageItemView, WithComponents } from "stream-chat-expo"; // ...rest of the code <WithComponents overrides={{ MessageItemView }}> {/* ...rest of the screen, unchanged */} </WithComponents>;

Your editor will flag CustomMessage as unused while the override is swapped out - that is expected.

iOS
Android
bash
1
npx expo run ios

Then confirm the full loop on the running app: the channel list loads, tap a channel, send a message, long press it for reactions, open a thread and reply, and paste a YouTube link to watch it unfurl. Run it on both platforms if you support both - the composer and keyboard handling are the parts most likely to differ.

YouTube link unfurled in the message list on iOS iOS

YouTube link unfurled in the message list on Android Android

Image and file uploads, voice recordings, richer video playback and offline storage are opt-in - each needs an extra package. See the installation guide to add them, then re-run this verification with attachments included.

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

We have also demonstrated the power of the SDK by building open-source clones of popular chat applications such as WhatsApp, Slack, and iMessage. The source code for all of them is in the react-native-samples repository.

Troubleshooting

  • stream project is not initialized - CLI onboarding not run. Run getstream init in the project directory first.
  • The app crashes or shows a native module error in Expo Go - stream-chat-expo ships native code and does not run in Expo Go. Build a development build with npx expo run ios or npx expo run android.
  • Stuck on "Loading chat ..." - the client never connected. Check the Metro logs; an auth error usually means the API key and token belong to different apps, and a websocket error usually means the device or emulator has no working internet connection.
  • 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.
  • process.env.EXPO_PUBLIC_STREAM_API_KEY is undefined - Expo inlines EXPO_PUBLIC_* variables at bundle time. Restart the dev server after getstream env --target expo writes .env.
  • Channel list is empty, no errors - the user has no channels. Seed one with getstream api GetOrCreateChannel (Step 3), or create one in the dashboard with Chat Explorer. If you're using the built-in credentials, remove the members filter from the filters object.
  • A route 404s or the app opens a blank screen - the Expo Router entry point is missing. Set "main": "expo-router/entry" in package.json and confirm the file lives under app/.
  • Peer dependency conflicts on install - pass -- --force to npx expo install, as in Step 0.
  • [Worklets] Mismatch between JavaScript code version and Worklets Babel plugin version - a stale Metro transform cache, not a missing plugin. Metro's cache lives in your system temp directory and is shared across projects, and the worklets plugin version is not part of its cache key, so a fresh project can pick up entries transformed by an older version of the plugin. Restart with npx expo start --clear. Check with npm ls react-native-worklets that the project really does resolve a single copy.
  • Metro fails on some other worklets or Reanimated error - check that the Babel plugin is being applied. Expo SDK 54+ adds "react-native-worklets/plugin" automatically when react-native-worklets is installed, so most projects need no babel.config.js at all. If you do have one, make sure it still extends babel-preset-expo, then restart with npx expo start --clear.
  • Gestures, swipes, or the message menu don't respond - import "react-native-gesture-handler"; must be the first line of app/_layout.tsx, and the app root must be wrapped in GestureHandlerRootView.
  • Errors about the old architecture - the SDK is New Architecture only. Follow the New Architecture guide.
  • Stale code after an edit - clear the bundler cache with npx expo start --clear, and rebuild the development build after any native dependency change.
  • Crash mentioning safe area or gesture handler - provider order is wrong. SafeAreaProvider is outermost, then GestureHandlerRootView, then the Stream providers.
  • Thread screen opens empty - thread was never set. Confirm onThreadSelect calls setThread(message) before navigating.
  • Theme has no effect - the theme goes on OverlayProvider as value={{ style: chatTheme }}. A bare value={chatTheme} is ignored. Keep the theme object defined outside the component so it isn't recreated on every render.
  • One theme key does nothing while the others work - an unknown top-level key is merged and silently discarded, so a typo costs you no error. Colors live under semantics, and each leaf token has to be named explicitly.
  • A custom component never renders - WithComponents only applies to its descendants. It has to wrap the component that renders the overridden view.
  • The keyboard covers the composer - pass useHeaderHeight() to both keyboardVerticalOffset and topInset on Channel.
  • TypeScript errors on custom data - custom-types.d.ts is missing, or the file is outside include in tsconfig.json.

More platform-specific fixes live in the troubleshooting guide.

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 Expo messaging app with our SDK's component library. We started with the polished defaults, then customized them with a theme object and scoped component overrides - all with minimal code changes.

Both the chat SDK for React Native and the API have plenty of features available to support more advanced use-cases such as push notifications, content moderation, rich messages, and more.

For more information read the React Native 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-native for the React Native pack. (Alternative: npx skills add GetStream/agent-skills -s stream.)
  • Provisioning: getstream init (auth + create/select org & app) -> getstream env --target expo (writes EXPO_PUBLIC_STREAM_API_KEY to .env) -> getstream token <user-id> (mint a token)
  • Data & config from the CLI: getstream api <Endpoint> --request '{...}' for users, channels, and messages
  • React Native integration skill: invoke /stream-react-native in your agent for Expo and React Native CLI setup patterns; /stream-docs searches live SDK docs with citations
  • Docs index for LLMs: https://getstream.io/chat/docs/sdk/react-native/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.