This content originally appeared on DEV Community and was authored by Oluwabusayo Jacobs
Ever wondered how popular messaging apps like Signal manage features such as real-time chat, voice calls, and video chat? In this tutorial, we’ll build our own Signal-inspired messaging app using React Native, with Clerk handling authentication and Stream powering real-time chat and calls.
In this first part, we’ll focus on setting up the project and laying the foundation. Specifically, we’ll:
Set up a new Expo project
Integrate user authentication using Clerk
Install and configure the Stream React Native Chat SDK
Build out the core layout of the app
In the second part, we’ll implement a functional chat screen and add audio and video calling to our app using Stream’s React Native Video and Audio SDK.
If you’re interested in seeing how real-time chat and video can be implemented in a React Native app, this guide will walk you through everything step by step.
You can download the app to see it in action or check out the complete source code on GitHub.
Let’s get started!
Prerequisites
To get the most out of this tutorial, you should have a basic understanding of the following:
React Fundamentals: You should know how to utilize components, hooks, and manage state in React. You should also be familiar with React Native’s core components.
Node.js & npm: Make sure you have Node.js and npm installed on your machine, as they’re required to run the project.
TypeScript & TailwindCSS Basics: We’ll be using both throughout the tutorial. A general understanding of TypeScript syntax and Tailwind’s utility classes will help you follow along more smoothly.
Project Setup
Let’s start by setting up the project with Expo and Nativewind.
Expo provides a set of tools to help build native apps faster. It will serve as the framework for our React Native app.
Nativewind, on the other hand, will allow us to style our app using Tailwind CSS.
Creating an Expo Project
Run the following command to create your Expo project:
npx create-expo-app@latest signal-clone
This creates a new Expo project named signal-clone
with a boilerplate setup.
Your project structure should look like the image below:
Let’s clean things up a bit and remove everything we won’t need:
Delete the
(tabs)
folder and all files in theapp
directory.Inside the
hooks
folder, delete all files.In the
components
directory, delete everything exceptHapticTab.tsx
.You can also remove the entire
ui
folder if it exists.
Installing Nativewind
Next, set up Nativewind for your project by following the steps below:
-
Install Nativewind and dependencies:
npm install nativewind react-native-reanimated@~3.17.4 react-native-safe-area-context@5.4.0 clsx npm install -D tailwindcss@^3.4.17 prettier-plugin-tailwindcss@^0.5.11
-
Set up Tailwind CSS:
-
Run the following to create a
tailwind.config.js
file:
npx tailwindcss init
-
Edit your
tailwind.config.js
file with the following code:
/** @type {import('tailwindcss').Config} */ module.exports = { content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,ts,tsx}'], presets: [require('nativewind/preset')], theme: { extend: { colors: { primary: '#2c6bed', }, }, }, plugins: [], };
-
Create a
global.css
file in the root directory and add the following code:
@tailwind base; @tailwind components; @tailwind utilities;
-
-
Add the Babel preset:
Create ababel.config.js
file and add the following snippet:
module.exports = function (api) { api.cache(true); return { presets: [ ["babel-preset-expo", { jsxImportSource: "nativewind" }], "nativewind/babel", ], }; };
-
Create your Metro configuration:
Create a
metro.config.js
file in the root of your project with the following code:
const { getDefaultConfig } = require('expo/metro-config'); const { withNativeWind } = require('nativewind/metro'); const config = getDefaultConfig(__dirname); module.exports = withNativeWind(config, { input: './global.css', inlineRem: 16, });
-
Add Nativewind type definitions:
Create a
nativewind-env.d.ts
file in your project directory and add the following directive:
/// <reference types="nativewind/types" />
Testing the Setup
Let’s test our setup by creating our first screen and running the development server to see the results.
Create a _layout.tsx
file in the app
folder with the following code:
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import '../global.css';
const RootLayout = () => {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
</Stack>
<StatusBar style="auto" />
</GestureHandlerRootView>
);
};
export default RootLayout;
This creates our root layout with basic gesture support and a stack navigation.
Next, create an index.tsx
file in the app
directory and add the following code:
import { Text, View } from 'react-native';
const WelcomeScreen = () => {
return (
<View className="flex-1 items-center justify-center bg-white">
<Text className="text-lg font-semibold text-primary">
Welcome to Signal!
</Text>
</View>
);
};
export default WelcomeScreen;
Lastly, run the development server with the command below:
npx expo start
Once you run the command above, the server will start, and you’ll see a QR code in the terminal.
Next, install the Expo Go app on your iOS or Android device. This app lets you preview and test your project directly on your phone. You can also set up Expo Go on an Android or iOS emulator.
Once you install the app, scan the QR code to open it on your device. For Android, select the Expo Go > Scan QR code option. For iOS, use the default camera app.
If you follow the above steps, you should be able to launch the welcome screen successfully!
Download assets
To finish up your project setup, you’ll need to add the assets from the archive below:
After downloading the archive, unzip it and replace the default assets in the signal-clone/assets/images
directory.
Your assets/images
directory should look like the image below:
Building the Welcome Screen
So far, our welcome screen only returns a simple welcome message. Let’s improve it by adding a few key components:
Screen component
The screen component will serve as the container for our screens.
Create a Screen.tsx
file in the components
directory and add the following snippet:
import clsx from 'clsx';
import { ActivityIndicator, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
interface ScreenProps {
children: React.ReactNode;
className?: string;
viewClassName?: string;
loadingOverlay?: boolean;
}
const Screen = ({
children,
className,
viewClassName,
loadingOverlay = false,
}: ScreenProps) => {
return (
<>
<SafeAreaView className={clsx('flex-1 android:pt-3', className)}>
<View className={clsx('flex-1', viewClassName)}>{children}</View>
</SafeAreaView>
{loadingOverlay && (
<View className="absolute inset-0 bg-black/40 items-center justify-center z-50">
<ActivityIndicator color="white" />
</View>
)}
</>
);
};
export default Screen;
The Screen
component wraps our content in a SafeAreaView
to keep it within safe screen boundaries. It also accepts optional class names for styling and an optional loadingOverlay
prop to show a loading screen.
AppImage component
The AppImage
will be a simple image component that can accept Tailwind styles. This component will display a splash image on the welcome screen.
Create an AppImage.tsx
file in the components
directory with the following code:
import { Image, ImageProps } from 'expo-image';
import { cssInterop } from 'nativewind';
cssInterop(Image, {
className: {
target: 'style',
},
});
const AppImage = (props: ImageProps) => {
return <Image {...props} />;
};
export default AppImage;
Here, we wrap and return Expo’s Image
component with Nativewind’s cssInterop
so it can accept Tailwind className
styles.
Button component
The button will enable users to perform actions within our UI. We’ll also use it in our welcome screen to display a call-to-action that takes the user to the next step.
Create a Button.tsx
file in the components
folder and add the following code:
import clsx from 'clsx';
import { Text, TouchableOpacity } from 'react-native';
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
onPress?: () => void;
children: React.ReactNode;
className?: string;
variant?: 'plain' | 'default' | 'text';
}
const Button = ({
onPress,
children,
className,
variant = 'default',
...otherProps
}: ButtonProps) => {
if (variant === 'plain')
return (
<TouchableOpacity
className={clsx('w-fit h-fit', className)}
onPress={onPress}
{...otherProps}
>
{children}
</TouchableOpacity>
);
return (
<TouchableOpacity
className={clsx(
variant === 'default' &&
'bg-blue-600 rounded-[13px] justify-center items-center px-4 py-4 w-full',
variant === 'text' && 'bg-transparent justify-center items-center',
otherProps.disabled && 'opacity-50',
variant !== 'text' && className
)}
onPress={onPress}
{...otherProps}
>
<Text
className={clsx(
variant === 'default' && 'text-[17px] font-medium text-white',
variant === 'text' && 'text-sm text-black',
variant === 'text' && className
)}
>
{children}
</Text>
</TouchableOpacity>
);
};
export default Button;
In the code above, the Button
component accepts three variants (default
, plain
, and text
) for different styling options, and uses TouchableOpacity
to handle user interactions with visual feedback.
Building the Welcome Screen
With our components in place, let’s build out our complete welcome screen.
Head to the index.tsx
file and update it with the following code:
import { useRouter } from 'expo-router';
import { Text, View } from 'react-native';
import AppImage from '@/components/AppImage';
import Button from '@/components/Button';
import Screen from '@/components/Screen';
const WelcomeScreen = () => {
const router = useRouter();
return (
<Screen
className="bg-white"
viewClassName="px-10 pb-10 w-full items-center justify-end gap-16"
>
<AppImage
source={require('@/assets/images/onboarding_splash_Normal.png')}
className="w-[85%] h-[55%]"
contentFit="cover"
/>
<View className="flex items-center gap-4">
<View className="flex w-full items-center">
<Text className="text-center text-[28.5px] font-semibold">
Take privacy with you.
</Text>
<Text className="w-[210px] text-center text-[28.5px] font-semibold">
Be yourself in every message.
</Text>
</View>
<Text className="text-base text-gray-500">Terms & Privacy Policy</Text>
</View>
<Button onPress={() => router.navigate('/sign-up')}>Continue</Button>
</Screen>
);
};
export default WelcomeScreen;
This new WelcomeScreen
displays a splash image, a few short texts, and a call-to-action button redirecting the user to a new screen.
Adding User Authentication with Clerk
If the user clicks the “Continue” button on our welcome screen right now, they’d be taken to a sign-up screen that doesn’t exist yet. Before working on this screen, we must implement user authentication within our app. This is where Clerk comes in.
What is Clerk?
Clerk is a user management platform. It provides tools that enable developers to easily implement authentication systems and user profiles without building them from scratch.
We’ll use Clerk in our Signal clone to handle authentication, user profiles, and sessions, so we can focus on building the actual chat experience.
Setting Up a Clerk Account
To get started with Clerk, first create an account on their website. Go to Clerk’s sign-up page and sign up using your email or a social login option.
Creating a Clerk Project
Once you sign in, you’ll need to create a project for your app. You can do this by following the steps below:
Go to the dashboard and click “Create application“.
Name your application “Signal clone”.
Under “Sign in options,” ensure Email is selected.
Click “Create application” to complete the setup.
Once the project is created, you’ll be redirected to the app overview page. Here, you will find your Publishable Key. Save this key, as it will be helpful later.
Next, we need to ensure that users provide a first and last name, and a username, during the sign-up process. Follow the steps below to enable this feature:
Go to your dashboard’s “Configure” tab.
Find the “Username” option and enable it, then disable the “Allow user name for sign-in” option.
Locate the “First and last name” option and toggle it on.
Click “Continue” to save the changes.
Installing Clerk in Your Project
Next, let’s install Clerk into your Expo project:
-
Install Clerk’s Expo SDK by running the following command:
npm install @clerk/clerk-expo
-
Create a
.env.local
file in the root of your project and add your Clerk Publishable Key:
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key
Replace
your_clerk_publishable_key
with the key from your Clerk project dashboard. -
To get the user and authentication data throughout our app, we must wrap our main layout with the Clerk’s
<ClerkProvider />
component.Navigate to the
app/_layout.tsx
file and update it with the following code:
import { ClerkProvider } from '@clerk/clerk-expo'; ... const PUBLISHABLE_KEY = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY; const RootLayout = () => { return ( <GestureHandlerRootView style={{ flex: 1 }}> <ClerkProvider publishableKey={PUBLISHABLE_KEY}> <Stack> <Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="(auth)" options={{ headerShown: false }} /> </Stack> <StatusBar style="auto" /> </ClerkProvider> </GestureHandlerRootView> ); }; export default RootLayout;
We also add an
(auth)
stack to the layout, which we’ll work on later. -
Clerk stores the active user’s session token in memory by default. In Expo apps, the recommended way to store sensitive data, such as tokens, is by using
expo-secure-store
, which encrypts the data before storing it. To use a token cache:-
Run the following command to install the libraries:
npm install expo-secure-store
-
Update the root layout to use the secure token cache:
... import { tokenCache } from '@clerk/clerk-expo/token-cache'; ... const RootLayout = () => { return ( ... <ClerkProvider tokenCache={tokenCache} publishableKey={PUBLISHABLE_KEY}> ... </ClerkProvider> ... ); }; export default RootLayout;
-
-
Let’s automatically redirect users to a
/chats
route if they’re already signed in.Open
app/index.tsx
and update it like this:
import { useUser } from '@clerk/clerk-expo'; import { Redirect, useRouter } from 'expo-router'; ... const WelcomeScreen = () => { const { isLoaded, isSignedIn } = useUser(); const router = useRouter(); if (!isLoaded) return null; if (isSignedIn) { return <Redirect href="/chats" />; } return ( ... ); }; export default WelcomeScreen;
-
Create an
(auth)
folder in theapp
directory. This folder represents the route group for our sign-up and sign-in screens. In the(auth)
group, create a_layout.tsx
file with the following code:
import { useAuth } from '@clerk/clerk-expo'; import { Redirect, Stack } from 'expo-router'; const AuthLayout = () => { const { isSignedIn } = useAuth(); if (isSignedIn) { return <Redirect href={'/chats'} />; } return ( <Stack screenOptions={{ headerShown: false }}> <Stack.Screen name="sign-in" /> <Stack.Screen name="sign-up" /> </Stack> ); }; export default AuthLayout;
Creating the Sign-Up and Sign-In Screens
With Clerk installed, let’s create our sign-up and sign-in screens.
These screens will use three new modules to handle the user form logic:
TextField
: This component will serve as the primary text input for our application.useUserForm
: This custom hook will manage and update the state for our user forms.utils
: This module will provide utility functions throughout our app.
Let’s start with the TextField
component. Create a TextField.tsx
file in the components
directory and add the following code:
import clsx from 'clsx';
import { DimensionValue, Text, TextInput, View } from 'react-native';
interface TextFieldProps extends React.ComponentProps<typeof TextInput> {
width?: DimensionValue;
label?: string;
}
const TextField = ({
label,
width = '100%',
className,
...otherProps
}: TextFieldProps) => {
return (
<View
style={{ width }}
className="relative px-4 flex-row bg-white items-center justify-between rounded-xl py-3 android:py-0 border border-white"
>
{label && (
<View>
<Text className="w-[108px] font-medium">{label}</Text>
</View>
)}
<TextInput
className={clsx(
'flex-1 placeholder:text-gray-400 text-black',
className
)}
{...otherProps}
/>
</View>
);
};
export default TextField;
Next, let’s create our custom hook. In the hooks
folder, create a useUserForm.tsx
file with the following code:
import { useState } from 'react';
interface InitialValues {
firstName?: string;
lastName?: string;
username?: string;
usernameNumber?: string;
numberError?: string;
emailAddress?: string;
password?: string;
}
const defaultValues: InitialValues = {
firstName: '',
lastName: '',
username: '',
usernameNumber: '',
numberError: '',
emailAddress: '',
password: '',
};
const useUserForm = (initialValues = defaultValues) => {
const [firstName, setFirstName] = useState(initialValues.firstName || '');
const [lastName, setLastName] = useState(initialValues.lastName || '');
const [username, setUsername] = useState(initialValues.username || '');
const [usernameNumber, setUsernameNumber] = useState(
initialValues.usernameNumber || ''
);
const [numberError, setNumberError] = useState(
initialValues.numberError || ''
);
const [emailAddress, setEmailAddress] = useState(
initialValues.emailAddress || ''
);
const [password, setPassword] = useState(initialValues.password || '');
const onChangeUsername = (text: string) => {
setUsername(text);
if (!usernameNumber) {
const randomNumber = String(Math.floor(Math.random() * 99) + 1).padStart(
2,
'0'
);
setUsernameNumber(randomNumber);
setNumberError('');
}
};
const onChangeNumber = (number: string) => {
if (number === '00') return;
setUsernameNumber(number);
const isNumber = /^\d+$/.test(number);
const isValid = isNumber && number.length === 2;
if (!isValid) {
setNumberError('Invalid username, enter a minimum of 2 digits');
} else {
setNumberError('');
}
};
const onChangeFirstName = (text: string) => {
setFirstName(text);
};
const onChangeLastName = (text: string) => {
setLastName(text);
};
const onChangeEmailAddress = (text: string) => {
setEmailAddress(text);
};
const onChangePassword = (text: string) => {
setPassword(text);
};
return {
firstName,
lastName,
username,
usernameNumber,
numberError,
emailAddress,
password,
onChangeNumber,
onChangeUsername,
onChangeFirstName,
onChangeLastName,
onChangeEmailAddress,
onChangePassword,
};
};
export default useUserForm;
The useUserForm
hook accepts the initialValues
prop and uses it to initialize the following states:
firstName
andlastName
: These states make up the full name of the user. They are updated via theonChangeFirstName
andonChangeLastName
handlers respectively.emailAddress
andpassword
: These states represent the auth credentials for the user. They are updated through theonChangeEmail
andonChangePassword
functions.-
username
,usernameNumber
, andnumberError
: These states help derive the full username of the user. In Signal, usernames are paired with a two-digit number as a privacy safeguard. This number is represented by theusernameNumber
. Theusername
andusernameNumber
states also come with the following change handlers:-
onChangeUsername
: Updates theusername
and generates a randomusernameNumber
if it’s not already set. -
onChangeNumber
: Updates theusernameNumber
and runs a simple validation. It also sets thenumberError
to display a helpful message if the validation fails.
-
The userUserForm
hook then returns all current states and the change handlers for each field.
Next, let’s create our utility module. Create a lib
folder, and then add a utils.ts
file with the following code:
export const getError = (err: any) => {
const errors = err.errors as { longMessage: string }[];
const errorMessage = errors
.map((error, index) => `${index + 1}. ${error.longMessage}`)
.join('\n');
alert(errorMessage);
};
This function accepts an error object from a Clerk API request and displays the error messages using an alert.
With our modules set up, let’s create our screens.
Starting with the sign-up screen, create a sign-up.tsx
file in the (auth)
folder and add the following code:
import { useSignUp } from '@clerk/clerk-expo';
import clsx from 'clsx';
import { Link, useRouter } from 'expo-router';
import { useState } from 'react';
import { Text, TextInput, View } from 'react-native';
import Button from '@/components/Button';
import Screen from '@/components/Screen';
import TextField from '@/components/TextField';
import useUserForm from '@/hooks/useUserForm';
import { getError } from '@/lib/utils';
const SignUpScreen = () => {
const { isLoaded, signUp, setActive } = useSignUp();
const router = useRouter();
const {
firstName,
lastName,
username,
usernameNumber,
numberError,
emailAddress,
password,
onChangeFirstName,
onChangeLastName,
onChangeUsername,
onChangeNumber,
onChangeEmailAddress,
onChangePassword,
} = useUserForm();
const [pendingVerification, setPendingVerification] = useState(false);
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const onSignUpPress = async () => {
if (!isLoaded || numberError) return;
setLoading(true);
try {
const finalUsername = `${username}_${usernameNumber}`;
await signUp.create({
firstName,
lastName,
username: finalUsername,
emailAddress: emailAddress.toLowerCase(),
password,
});
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' });
setPendingVerification(true);
} catch (err) {
getError(err);
} finally {
setLoading(false);
}
};
const onVerifyPress = async () => {
if (!isLoaded) return;
setLoading(true);
try {
const signUpAttempt = await signUp.attemptEmailAddressVerification({
code,
});
if (signUpAttempt.status === 'complete') {
await setActive({ session: signUpAttempt.createdSessionId });
router.replace('/chats');
} else {
console.error(JSON.stringify(signUpAttempt, null, 2));
}
} catch (err) {
getError(err);
} finally {
setLoading(false);
}
};
if (pendingVerification) {
return (
<Screen viewClassName="pt-10 px-4 gap-4" loadingOverlay={loading}>
<View className="gap-3">
<Text className="text-center text-3xl font-semibold">
Verify email address
</Text>
<Text className="text-center text-base text-gray-500">
Enter the code we sent to {emailAddress.toLowerCase()}
</Text>
<Button
variant="text"
className="text-base text-blue-600"
onPress={() => setPendingVerification(false)}
>
Wrong email?
</Button>
</View>
<TextField
value={code}
placeholder="Enter your verification code"
keyboardType="numeric"
onChangeText={(code) => setCode(code)}
/>
<Button onPress={onVerifyPress}>Verify</Button>
</Screen>
);
}
return (
<Screen viewClassName="pt-10 px-4 gap-4" loadingOverlay={loading}>
<View className="gap-3">
<Text className="text-center text-3xl font-semibold">Sign up</Text>
<Text className="text-center text-base text-gray-500">
Create an account to get started
</Text>
</View>
<View className="gap-3">
<TextField
value={firstName}
placeholder="First name"
onChangeText={onChangeFirstName}
/>
<TextField
value={lastName}
placeholder="Last name"
onChangeText={onChangeLastName}
/>
<View className="relative">
<TextField
autoCapitalize="none"
value={username}
placeholder="Username"
onChangeText={onChangeUsername}
className="pr-12"
/>
<View className="absolute right-3 top-3 flex-row gap-2">
<View className="w-0.5 h-5 bg-gray-300" />
<TextInput
keyboardType="number-pad"
maxLength={2}
value={usernameNumber}
onChangeText={onChangeNumber}
className="w-5 h-5 android:w-8 android:h-12 android:bottom-3.5"
/>
</View>
<Text
className={clsx(
'pl-2 pt-2 text-xs',
numberError ? 'text-red-500' : 'text-gray-500'
)}
>
{numberError ||
'Usernames are always paired with a set of numbers.'}
</Text>
</View>
<TextField
autoCapitalize="none"
value={emailAddress}
placeholder="Email address"
onChangeText={onChangeEmailAddress}
/>
<TextField
value={password}
placeholder="Password"
secureTextEntry={true}
onChangeText={onChangePassword}
/>
</View>
<Button onPress={onSignUpPress}>Continue</Button>
<View className="flex-row gap-[3px]">
<Text>Already have an account?</Text>
<Link href="/sign-in">
<Text className="text-blue-600">Sign in</Text>
</Link>
</View>
</Screen>
);
};
export default SignUpScreen;
The sign-up screen renders two forms:
-
Initial Form: This is the form provided to the user to fill out their details. In this form:
- We use the
TextField
component to render the form fields for the user registration. - We also render a
Text
element below the username field that displays either a helper message or thenumberError
when theusernameNumber
is invalid. - The form state is managed using the
useUserForm
hook. - When the user clicks “Continue”, the
onSignupPress
function is called. This function creates afinalUsername
by combining theusername
andusernameNumber
with an underscore. Then, using Clerk’ssignUp
object from theuseSignUp
hook, it registers the user withsignUp.create()
and sends a verification email usingsignUp.prepareEmailAddressVerification()
.
- We use the
Verification Form: After the sign-up is successful, the user is redirected to the verification form and prompted to enter the email verification code. Once the user clicks “Verify”, the
onVerifyPress
function is called. This function uses thesignUp.attemptEmailAddressVerification()
method to verify the user’s email based on the code provided. If successful, it runs thesetActive()
function (from theuseSignUp
hook) to create an active session and then redirects the user to/chats
.
Next, create a sign-in.tsx
file in the (auth)
folder, then add the following code:
import { useSignIn } from '@clerk/clerk-expo';
import { Link, useRouter } from 'expo-router';
import { useState } from 'react';
import { Text, View } from 'react-native';
import Button from '@/components/Button';
import Screen from '@/components/Screen';
import TextField from '@/components/TextField';
import useUserForm from '@/hooks/useUserForm';
import { getError } from '@/lib/utils';
const SignInScreen = () => {
const { signIn, setActive, isLoaded } = useSignIn();
const router = useRouter();
const { emailAddress, password, onChangeEmailAddress, onChangePassword } =
useUserForm();
const [loading, setLoading] = useState(false);
const onSignInPress = async () => {
if (!isLoaded) return;
setLoading(true);
try {
const signInAttempt = await signIn.create({
identifier: emailAddress,
password,
});
if (signInAttempt.status === 'complete') {
await setActive({ session: signInAttempt.createdSessionId });
router.replace('/chats');
} else {
alert('State incomplete');
}
} catch (err) {
getError(err);
} finally {
setLoading(false);
}
};
return (
<Screen viewClassName="pt-10 px-4 gap-4" loadingOverlay={loading}>
<View className="gap-3">
<Text className="text-center text-3xl font-semibold">Sign in</Text>
<Text className="text-center text-base text-gray-500">
Enter your email address and password to sign in.
</Text>
</View>
<TextField
autoCapitalize="none"
value={emailAddress}
placeholder="Enter email"
onChangeText={onChangeEmailAddress}
/>
<TextField
value={password}
placeholder="Enter password"
secureTextEntry={true}
onChangeText={onChangePassword}
/>
<Button onPress={onSignInPress}>Continue</Button>
<View className="flex-row gap-[3px]">
<Text>Don't have an account?</Text>
<Link href="/sign-up">
<Text className="text-blue-600">Sign up</Text>
</Link>
</View>
</Screen>
);
};
export default SignInScreen;
In this code, we:
Render two
TextField
components for the user’s email address and password.Use the
useUserForm
hook to manage the form field states.-
Run the
onSignInPress
function when the user clicks “Continue”. This function utilizes theuseSignIn
hook to:- Attempt a sign-in using the
signIn.create()
method. - If the sign-in process was successful, use the
setActive()
function to set the created session as active and then redirect the user.
- Attempt a sign-in using the
Now that we have Clerk and our auth screens set up, let’s move on to setting up Stream Chat.
Setting Up Stream Chat
What is Stream?
Stream is a platform that provides APIs and SDKs to help build real-time messaging, video calling, and activity feeds. We’ll use Stream’s React Native SDK for Video and React Native Chat SDK to implement video calling and chat messaging features in our Signal clone.
Creating your Stream Account
The first step to using Stream is to create a Stream account:
Sign Up: Go to Stream’s sign-up page and create a new account using your email or a social login.
Complete Your Profile:
* Once you sign up, you'll be prompted to provide additional information, such as your role and industry.
* Select the **"Chat Messaging"** and **"Video and Audio"** options, as our app needs these features.

* Click **"Complete Signup"**.
After completing the steps above, you will be redirected to your Stream dashboard.
Creating a New Stream Project
Next, follow the steps below to create a new app for your project:
Click “Create App” in the top right corner of your Stream dashboard.
Configure Your App:
* **App Name**: Enter a name like "**signal-clone**" or any other name available.
* **Region**: Select the closest region to you to ensure you get the best performance.
* **Environment**: Set it to "**Development**".
* Click "**Create App**" to complete the process.
Installing Stream Chat
To begin using Stream Chat in your Expo project, follow these steps:
-
Run the following command to install the necessary packages for Stream Chat:
npx expo install stream-chat stream-chat-expo @react-native-community/netinfo expo-image-manipulator react-native-gesture-handler react-native-reanimated react-native-svg expo-video expo-av expo-sharing expo-haptics expo-clipboard expo-document-picker expo-image-picker expo-media-library @op-engineering/op-sqlite
-
Add your Stream API keys to your
.env.local
file:
EXPO_PUBLIC_STREAM_API_KEY=your_stream_api_key STREAM_API_SECRET=your_stream_api_secret
Replace
your_stream_api_key
andyour_stream_api_secret
with the keys from the “App Access Keys” section in your Stream dashboard. -
Configure the app permissions in your project’s
app.json
file:
{ "expo": { ... "plugins": [ ... [ "expo-av", { "microphonePermission": "$(PRODUCT_NAME) would like to use your microphone for voice recording." } ], [ "expo-video", { "supportsBackgroundPlayback": true, "supportsPictureInPicture": true } ], [ "expo-image-picker", { "cameraPermission": "$(PRODUCT_NAME) would like to use your camera to share image in a message.", "photosPermission": "$(PRODUCT_NAME) would like to use your device gallery to attach image in a message." } ], [ "expo-media-library", { "photosPermission": "$(PRODUCT_NAME) would like access to your photo gallery to share image in a message.", "savePhotosPermission": "$(PRODUCT_NAME) would like to save photos to your photo gallery after downloading from a message." } ] ], ... } }
This code adds the necessary permissions to the
Info.plist
on iOS devices. -
Add the
react-native-reanimated
plugin to yourbabel.config.js
file in your application folder:
module.exports = function (api) { api.cache(true); return { presets: [ ['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel', ], plugins: ['react-native-reanimated/plugin'], }; };
With Stream Chat installed, let’s create the home layout for our Signal clone.
Building the Home Layout and Initializing Stream
The home layout will contain all screens a signed-in user can view. These screens will also need access to Stream, so we’ll initialize the Stream client within the layout file.
We need to set up a few things before we start building the layout.
First, let’s add a new (home)
stack to the root layout. Open your app/_layout.tsx
file and update it like so:
...
const RootLayout = () => {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ClerkProvider tokenCache={tokenCache} publishableKey={PUBLISHABLE_KEY}>
<Stack>
...
<Stack.Screen name="(home)" options={{ headerShown: false }} />
</Stack>
<StatusBar style="auto" />
</ClerkProvider>
</GestureHandlerRootView>
);
};
export default RootLayout;
Next, let’s work on a few components. While the Stream client is connecting, we’ll need to show the user a loading screen.
Create a Spinner.tsx
file in the components
directory with the following snippet:
import { ActivityIndicator } from 'react-native';
interface SpinnerProps {
color?: string;
}
const Spinner = ({ color = '#2c6bed' }: SpinnerProps) => {
return <ActivityIndicator color={color} />;
};
export default Spinner;
Next, create a ScreenLoading.tsx
file in the same folder with the following code:
import Screen from './Screen';
import Spinner from './Spinner';
const ScreenLoading = () => {
return (
<Screen className="bg-white" viewClassName="items-center justify-center">
<Spinner />
</Screen>
);
};
export default ScreenLoading;
With our components ready, we can begin building our layout.
Create a (home)
folder in the app
directory, and then add a _layout.tsx
file with the following code:
import { useUser } from '@clerk/clerk-expo';
import { Stack, useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import { StreamChat } from 'stream-chat';
import { Chat, OverlayProvider } from 'stream-chat-expo';
import ScreenLoading from '@/components/ScreenLoading';
const tokenProvider = async (userId: string) => {
const response = await fetch('/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ userId }),
});
const data = await response.json();
return data.token;
};
const API_KEY = process.env.EXPO_PUBLIC_STREAM_API_KEY as string;
const HomeLayout = () => {
const router = useRouter();
const { user, isSignedIn } = useUser();
const [loading, setLoading] = useState(true);
const [chatClient, setChatClient] = useState<StreamChat>();
useEffect(() => {
if (!isSignedIn) {
router.replace('/sign-in');
}
const customProvider = async () => {
const token = await tokenProvider(user!.id);
return token;
};
const setUpStream = async () => {
try {
const chatClient = StreamChat.getInstance(API_KEY);
const clerkUser = user!;
const chatUser = {
id: clerkUser.id,
name: clerkUser.fullName!,
image: clerkUser.hasImage ? clerkUser.imageUrl : undefined,
username: clerkUser.username!,
};
if (!chatClient.user) {
await chatClient.connectUser(chatUser, customProvider);
}
setChatClient(chatClient);
} catch (error) {
console.error('Error setting up Stream:', error);
} finally {
setLoading(false);
}
};
if (user) setUpStream();
return () => {
if (!isSignedIn) {
chatClient?.disconnectUser();
}
};
}, [user, chatClient, isSignedIn, router]);
if (loading) return <ScreenLoading />;
return (
<OverlayProvider>
<Chat client={chatClient!}>
<Stack>
<Stack.Screen
name="(tabs)"
options={{
headerShown: false,
}}
/>
</Stack>
</Chat>
</OverlayProvider>
);
};
export default HomeLayout;
There’s a lot going on here, so let’s break it down:
We create a
tokenProvider
function that fetches a user token from a/token
endpoint. This token is required to authenticate and log in the user to Stream.We use the
isSignedIn
state from Clerk’suseUser
hook to check if a user is signed in. If not, we redirect them to the sign-in screen.We define a
setupStream
function that creates a Stream client instance, connects the user to Stream, and then stores the client instance in thechatClient
state.We ensure the user is disconnected from the client when the component is unmounted.
While the client is connecting, we render the
ScreenLoading
component.-
Once connected, we wrap our app in Stream’s
<OverlayProvider>
and<Chat>
components:- The
OverlayProvider
allows users to interact with modals, message actions, and other UI elements provided by Stream. - The
Chat
component takes in thechatClient
, which provides the Stream context to all other components.
- The
Finally, we return a
Stack
navigator with a single(tabs)
screen, which we’ll work on later.
Next, let’s create the route for the /token
endpoint.
First, open your app.json
file and update it with the following:
{
"expo": {
...
"web": {
...
"output": "server",
...
},
...
}
}
This setting tells Expo to output a server bundle, which is required for API routes to work.
In Expo, API Routes can be defined by creating files in the app
directory with the +api.ts
extension.
For our /token
route, create a token+api.ts
file in the same directory with the following code:
import { StreamChat } from 'stream-chat';
const API_KEY = process.env.EXPO_PUBLIC_STREAM_API_KEY!;
const SECRET = process.env.STREAM_API_SECRET!;
export async function POST(request: Request) {
const client = StreamChat.getInstance(API_KEY, SECRET);
const body = await request.json();
const userId = body?.userId;
if (!userId) {
return Response.error();
}
const token = client.createToken(userId);
const response = {
userId: userId,
token: token,
};
return Response.json(response);
}
Here we generate a user token based on the provided userId
and return it in a response.
This web server setup is meant for development only. Stream’s React Native Video & Audio SDK does not currently support Expo’s web platform, so using this code in a production or release build will cause it to crash. For production environments, create your own backend server with an endpoint that generates a user token, and direct your fetch requests to that endpoint instead.
With our home layout set up, let’s build the tab screens.
Building the Tab Screens
The Signal app features three main tabs:
Chats: where conversations happen
Calls: for viewing recent calls
Stories: for short status updates
We’ll replicate this tab layout in our Signal clone. However, this tutorial will only cover the Chats screen in detail to keep things focused, while the Calls and Stories tabs will have placeholder UIs.
Building the Tabs layout
To get started, create a (tabs)
folder in your (home)
directory. Inside the folder, create a _layout.tsx
file and add the following code:
import { Ionicons } from '@expo/vector-icons';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Tabs } from 'expo-router';
import { HapticTab } from '@/components/HapticTab';
const TabsLayout = () => {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: 'black',
tabBarButton: HapticTab,
tabBarStyle: {
backgroundColor: 'white',
borderTopColor: 'white',
},
headerTransparent: true,
headerTitleAlign: 'center',
}}
>
<Tabs.Screen
name="chats"
options={{
title: 'Chats',
tabBarIcon: ({ color }) => (
<Ionicons name="chatbubble-sharp" size={24} color={color} />
),
}}
/>
<Tabs.Screen
name="calls"
options={{
title: 'Calls',
tabBarIcon: ({ color }) => (
<FontAwesome name="phone" size={28} color={color} />
),
}}
/>
<Tabs.Screen
name="stories"
options={{
title: 'Stories',
tabBarIcon: ({ color }) => (
<MaterialIcons name="web-stories" size={28} color={color} />
),
}}
/>
</Tabs>
);
};
export default TabsLayout;
This sets up our tab navigation and assigns icons to each tab.
Creating the Chats Screen
To understand how we’ll be building the chats screen, we first need to explain how channels work in Stream. Channels in Stream are objects that contain:
Messages exchanged between users.
A list of people watching the channel.
An optional list of members.
We’ll use these channels to represent group chats and DMs in our Signal clone.
Let’s start by creating the modules we’ll need for our screen.
Create an Avatar.tsx
file in the components
directory and add the following code:
import { Ionicons } from '@expo/vector-icons';
import { Text, TextStyle, View } from 'react-native';
import AppImage from './AppImage';
interface AvatarProps {
name: string;
imageUrl?: string;
size?: number;
fontSize?: TextStyle['fontSize'];
fontWeight?: TextStyle['fontWeight'];
placeholderType?: 'text' | 'icon';
}
const Avatar = ({
imageUrl,
size = 40,
name,
fontSize = 20,
fontWeight = '500',
placeholderType = 'text',
}: AvatarProps) => {
if (imageUrl)
return (
<View
className="relative flex shrink-0 overflow-hidden rounded-full"
style={{ width: size, height: size }}
>
<AppImage
source={{ uri: imageUrl }}
className="w-full h-full"
alt="name"
contentFit="cover"
/>
</View>
);
return (
<View
style={{
width: size,
height: size,
backgroundColor: '#d8e8f0',
}}
className="shrink-0 rounded-full aspect-square flex flex-row items-center justify-center overflow-hidden"
>
{placeholderType === 'text' && (
<Text
style={{
fontSize,
fontWeight,
}}
className="leading-[2] text-[#086da0] uppercase"
>
{name ? name[0] : ''}
</Text>
)}
{placeholderType === 'icon' && (
<Ionicons name="people-outline" size={fontSize} color="#086da0" />
)}
</View>
);
};
export default Avatar;
This component displays a user’s avatar. If the user has an image, it renders it. If not, it shows either the first letter of their name or an icon.
Next, create an AppMenu.tsx
file in the components
folder and add the following snippet:
import { useClerk, useUser } from '@clerk/clerk-expo';
import { Feather } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useRef, useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { getError } from '../lib/utils';
import Avatar from './Avatar';
import Button from './Button';
const AppMenu = () => {
const { signOut } = useClerk();
const router = useRouter();
const { user } = useUser();
const [menuVisible, setMenuVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
const avatarRef = useRef<View>(null);
const toggleMenu = () => {
if (menuVisible) {
setMenuVisible(false);
return;
}
if (avatarRef.current) {
avatarRef.current.measure((_x, _y, _width, height, pageX, pageY) => {
setMenuPosition({ top: pageY + height + 8, left: pageX - 8 });
setMenuVisible(true);
});
}
};
const goToProfile = () => {
setMenuVisible(false);
router.push('/profile');
};
const handleSignOut = async () => {
setMenuVisible(false);
try {
await signOut();
router.replace('/');
} catch (err) {
getError(err);
}
};
return (
<>
<Button variant="plain" ref={avatarRef} onPress={toggleMenu}>
<Avatar
imageUrl={user?.imageUrl}
size={28}
fontSize={12}
name={user?.fullName!}
/>
</Button>
<Modal
transparent
visible={menuVisible}
animationType="fade"
onRequestClose={() => setMenuVisible(false)}
>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => setMenuVisible(false)}
>
<View
style={{ top: menuPosition.top, left: menuPosition.left }}
className="absolute bg-white rounded-lg shadow-md shadow-gray-100 min-w-[250px]"
>
<Button
variant="plain"
className="flex-row items-center justify-between py-2 px-3 border-b border-gray-200"
onPress={goToProfile}
>
<Text>Profile</Text>
<Feather name="user" size={20} color="black" />
</Button>
<Button
variant="plain"
className="flex-row items-center justify-between py-2 px-3"
onPress={handleSignOut}
>
<Text className="text-red-600">Sign Out</Text>
<Feather name="log-out" size={20} color="red" />
</Button>
</View>
</Pressable>
</Modal>
</>
);
};
export default AppMenu;
Here’s what the AppMenu
component does:
Displays the user’s avatar using the
Avatar
component, positioned using aref
to calculate where the menu should appear on the screen.Toggles a custom dropdown menu (built with
Modal
) that appears below the avatar.-
Inside the menu, there are two options:
- Profile: Navigates the user to the profile screen.
- Sign Out: Logs the user out using Clerk and redirects to the home screen.
Next, navigate to the lib
folder and update the utils.ts
file with the following code:
import type { Channel } from 'stream-chat';
export const getError = (err: any) => {
...
};
export const checkIfDMChannel = (channel: Channel) => {
return !!channel?.id?.startsWith('!members');
};
Here we define a checkIfDMChannel
function to determine if a channel is a direct message (DM) between two users. We do this by checking if the channel ID starts with the substring !members
. Channels with this ID pattern are referred to as ‘distinct channels,’ and only one of them can exist between their specified members. This makes them the perfect way to implement DMs in our app.
Next, create a PreviewAvatar.tsx
file in the components
directory with the following code:
import {
ChannelAvatarProps,
useChannelPreviewDisplayAvatar,
} from 'stream-chat-expo';
import { checkIfDMChannel } from '@/lib/utils';
import Avatar from './Avatar';
export interface PreviewAvatarProps extends ChannelAvatarProps {
size?: number;
fontSize?: number;
}
const PreviewAvatar = ({
channel,
size = 44,
fontSize = 20,
}: PreviewAvatarProps) => {
const isDMChannel = checkIfDMChannel(channel);
const displayAvatar = useChannelPreviewDisplayAvatar(channel);
const placeholderType = isDMChannel ? 'text' : 'icon';
return (
<Avatar
size={size}
name={displayAvatar.name!}
fontSize={fontSize}
imageUrl={isDMChannel ? displayAvatar.image : undefined}
placeholderType={placeholderType}
/>
);
};
export default PreviewAvatar;
The PreviewAvatar
component is used to display the appropriate avatar for a channel.
Here’s how it works:
It detects whether the channel is a direct message (DM) using the
checkIfDMChannel
function we created.If it’s a DM, it displays the other user’s avatar.
If it’s a group or team channel, it defaults to showing an icon instead.
It uses the
useChannelPreviewDisplayAvatar
hook fromstream-chat-expo
to get the display name and avatar.The actual rendering is done by the
Avatar
component.
With our modules set up, let’s move on to the chats screen.
In the (tabs)
folder, create a chats.tsx
file and add the following code:
import Feather from '@expo/vector-icons/Feather';
import { Link, useRouter } from 'expo-router';
import { View } from 'react-native';
import { Channel } from 'stream-chat';
import { ChannelList, useChatContext } from 'stream-chat-expo';
import AppMenu from '@/components/AppMenu';
import Button from '@/components/Button';
import PreviewAvatar from '@/components/PreviewAvatar';
import Screen from '@/components/Screen';
import ScreenLoading from '@/components/ScreenLoading';
const ChatsScreen = () => {
const { client } = useChatContext();
const router = useRouter();
const goToChannel = (channel: Channel) => {
router.navigate({
pathname: '/chat/[id]',
params: { id: channel.id! },
});
};
return (
<Screen className="bg-white" viewClassName="px-4">
<View className="flex flex-row items-center justify-between w-full h-10">
<AppMenu />
<View className="flex flex-row items-center gap-4">
<Button variant="plain">
<Feather name="camera" size={20} />
</Button>
<Link href="/new-message" asChild>
<Button variant="plain" className="pl-4 py-1">
<Feather name="edit" size={18} />
</Button>
</Link>
</View>
</View>
<ChannelList
filters={{
type: 'messaging',
members: { $in: [client.userID!] },
}}
sort={{ last_message_at: -1 }}
onSelect={goToChannel}
LoadingIndicator={ScreenLoading}
PreviewAvatar={PreviewAvatar}
/>
</Screen>
);
};
export default ChatsScreen;
Here we render a top nav that contains the AppMenu
and an edit button that links to /new-message
where users can start a new chat. We also list all the user’s channels using Stream’s ChannelList
component. This component is given the following props:
filters
: We set this to only show messaging channels that include the logged-in user.sort
: We use this prop to sort the channels by the latest message.onSelect
: When a channel is tapped, we run thegoToChannel
function. This function dynamically routes the user to a chat screen for that channel using its ID.LoadingIndicator
: We pass our customScreenLoading
component to show when theChannelList
is loading.PreviewAvatar
: We pass our customPreviewAvatar
component.
Our app currently doesn’t have any existing channels, so the ChannelList
returns an empty state indicator.
Creating the Calls and Stories Screens
Next, let’s create the calls and stories screen with placeholder UIs.
Create a calls.tsx
file in the (tabs)
folder with the following code:
import { Feather } from '@expo/vector-icons';
import { Text, View } from 'react-native';
import AppMenu from '@/components/AppMenu';
import Button from '@/components/Button';
import Screen from '@/components/Screen';
const CallsScreen = () => {
return (
<Screen className="bg-white" viewClassName="px-4 items-start">
<View className="flex flex-row items-center justify-between w-full h-10">
<AppMenu />
<View className="flex flex-row items-center gap-8">
<Button variant="plain">
<Feather name="phone" size={20} color="black" />
</Button>
</View>
</View>
<Button
variant="plain"
className="flex flex-row items-center justify-center gap-4 mt-4"
>
<Feather name="link" size={20} color="black" />
<Text className="font-semibold">Create a Call Link</Text>
</Button>
<View className="flex-1 w-full flex-col items-center justify-center mt-8">
<Text className="font-semibold">No recent calls</Text>
<Text className="text-sm text-gray-600">
Get started by calling a friend
</Text>
</View>
</Screen>
);
};
export default CallsScreen;
Next, create a stories.tsx
file in the same folder and add the following code:
import { useUser } from '@clerk/clerk-expo';
import { Feather } from '@expo/vector-icons';
import { Text, View } from 'react-native';
import AppMenu from '@/components/AppMenu';
import Avatar from '@/components/Avatar';
import Button from '@/components/Button';
import Screen from '@/components/Screen';
const StoriesScreen = () => {
const { user } = useUser();
return (
<Screen className="bg-white" viewClassName="px-4 pt-1">
<View className="flex flex-row items-center justify-between w-full h-8">
<AppMenu />
<View className="flex flex-row items-center gap-8">
<Button variant="plain">
<Feather name="camera" size={20} color="black" />
</Button>
</View>
</View>
<Button variant="plain" className="flex-row items-center gap-3 mt-4">
<View className="relative w-10 h-10">
<Avatar
imageUrl={user?.imageUrl}
size={40}
fontSize={16}
name={user?.fullName!}
/>
<View className="absolute -bottom-1 -right-1 w-[22px] h-[22px] rounded-full border-[3px] border-white bg-blue-600 flex items-center justify-center">
<Feather name="plus" size={14} color="white" />
</View>
</View>
<View>
<Text className="font-semibold">My Stories</Text>
<Text className="text-xs text-gray-500">Tap to add</Text>
</View>
</Button>
</Screen>
);
};
export default StoriesScreen;
And with that, we’ve successfully built our tab screens!
Conclusion
At this point, we’ve successfully set up our core navigation, integrated secure user authentication with Clerk, and prepared Stream’s React Native Chat SDK to handle chat functionality in our app.
In part two, we’ll build upon this foundation by creating a fully interactive chat experience, complete with real-time messaging, audio and video calls, and screens for managing user profiles and initiating conversations.
Stay tuned!
This content originally appeared on DEV Community and was authored by Oluwabusayo Jacobs