Instant messaging has become an integral part of web applications in recent years. The real-time exchange of information helps to cement the users' trust, whether they are customers, merchants, or other stakeholders. This technology has become ubiquitous across many industries, including virtual events, healthcare, and education.
However, the complexity surrounding proper in-house implementation was an obstacle until the introduction of real-time communication APIs like Stream.
In this tutorial, we will use Stream to integrate instant messaging into a MERN-based marketplace app, highlighting the importance of this feature in modern web applications. You will also see the simplicity Stream's Chat API provides to the developer experience.
What We Will Build
In this article, we will integrate instant messaging into an e-commerce application. You will clone a starter app where customers can sign up and create listings. Other customers can view such listings and click on each for more details. Using Stream, you will add a feature for logged-in users to start a chat with the merchant and get instant feedback if the merchant is online. You will also add an inbox section that allows users to see (and respond) to messages from prospective buyers.
Prerequisites
To follow along, you will need:
- A Stream account, sign up for a free account.
- Create an app on your Stream Dashboard
- Node installed on your system
- MongoDB installed locally or the cloud version
Getting Started
To get started, clone the starter repository prepared for this tutorial.
12git clone https://github.com/yemiwebby/stream-marketplace.git cd stream-marketplace
The application has the following structure. For the sake of brevity, only the top-level files and folders are shown.
stream-marketplace
└───client
└───server
| package.json
│
The server
folder contains the code related to the backend. The backend is built with Express. The client
folder contains the code related to the front end, built with React using Vite.
Finally, package.json
contains helper scripts to set up and run both the backend and frontend concurrently.
Install the top-level dependencies using the following command.
1yarn install
Next, set up the frontend and backend dependencies using the following command.
1yarn setup
Adding Stream to the Backend
While the client side initiates conversations, you need backend access for two key areas - token generation and client syncing. To start a conversation on the client side, you require a token. The backend creates this token and passes the authentication response to the client.
Starting conversations on the client side depends on both users being present on Stream storage. A simple way of ensuring this happens is by syncing the users in the database with your Stream storage. You can do this when the app starts or a new user registers. For demonstration purposes, you will do both.
You will need your Stream API key and API secret to do these things. You can find them on your app dashboard.
Switch to the server
folder to add Stream features to the backend.
1cd server
Next, add Stream using the following command.
1yarn add stream-chat
Next, in the helper
folder, create a new file named stream.js
. This file will contain code to help with the stream functionality required by the backend. Add the following code to the newly created file.
123456789101112131415161718192021import {StreamChat} from "stream-chat"; const streamClient = () => { const apiKey = process.env.STREAM_API_KEY; const apiSecret = process.env.STREAM_API_SECRET; return StreamChat.getInstance(apiKey, apiSecret); }; export const getStreamToken = (user) => streamClient().createToken(user._id.toString()); export const syncUser = async ({_id}) => { await streamClient().upsertUser({ id: _id.toString() }); }; export const syncUsers = (users) => { users.forEach(syncUser); };
The getStreamToken
function will generate a token on successful user authentication. To integrate this into the current authentication process, open controller/user.js
and update the getSuccessResponse
function to match the following code.
1234567891011import { getStreamToken } from "../helper/stream"; const getSuccessResponse = async (user) => ({ user: { id: user._id, email: user.email, fullName: user.fullName, }, bearerToken: await getJWTForUser(user), streamToken: getStreamToken(user), });
Next, sync the users in the database with Stream on startup. To do this, update the start
function in app.js
to match the following.
123456789101112131415161718192021222324252627import { syncUsers } from "./helper/stream.js"; import user from "./model/user.js"; const start = async () => { const { DB_USERNAME: username, DB_PASSWORD: password, DB_HOST: host, DB_PORT: dbPort, APP_PORT: port, } = process.env; try { mongoose.set("strictQuery", false); await mongoose.connect( `mongodb://${username}:${password}@${host}:${dbPort}` ); syncUsers(await User.find({})); app.listen(port || 3000, () => console.log(`Server started on port ${port}`) ); } catch (error) { console.error(error); process.exit(1); } };
You can also sync users during the registration process. To do this, update the register
function in controller/user.js
to match the following.
123456789101112131415161718export const register = async (req, res) => { const { name, email, password } = req.body; const user = new User({ name, email, password: hashPassword(password), }); try { const savedUser = await user.register(); await syncUser(savedUser); const response = await getSuccessResponse(user); response.message = "User registered successfully"; res.status(201).send(response); } catch (error) { res.status(400).send({ error: error.message }); } };
Remember to add an import statement for the syncUser
function.
1import {getStreamToken, syncUser} from "../helper/stream.js";
The last thing to do on the backend is to set the local environment variables. To do this, make a copy of the .env
file provided.
1cp .env .env.local
Open the newly created .env.local
file and update the values as they pertain to your development environment.
DB_USERNAME=
DB_PASSWORD=
DB_HOST=
DB_PORT=
JWT_TTL=3600000
JWT_SECRET_KEY=This1sN4tSoSecret___YOushouldchangeIt&&*
APP_PORT=8080
STREAM_API_KEY=
STREAM_API_SECRET=
You can test the backend by running the following command.
yarn dev
By default, the backend will be served at port 8080. The backend has five endpoints, namely:
Action | Endpoint | Method | Requires Authentication |
---|---|---|---|
Create new user | /register | POST | NO |
Authenticate existing user | /login | POST | NO |
Get all products | /product | GET | NO |
Get product | /product/:id | GET | NO |
Create new product | /product | POST | YES |
For endpoints requiring authentication, the incoming request must have an Authorization
header containing a bearer token.
Add Stream to the frontend
Next, switch to the client
folder. Add Stream to the front end using the following command.
1yarn add stream-chat stream-chat-react
Next, create local environment variables by copying the .env
file in the starter repository.
1cp .env .env.local
Next, update the values in .env.local
with your Stream chat key.
12VITE_API_URL = 'http://localhost:8080' VITE_STREAM_CHAT_KEY=
The next thing to do is update the authentication context to keep track of the Stream token returned upon successful registration or login. This project's authentication credentials are stored in Context. Update the code in src/components/context/AuthContext.jsx
to match the following.
1234567891011121314151617181920212223242526272829303132333435363738394041424344import { createContext, useContext, useEffect, useState } from "react"; const AuthContext = createContext({}); const AuthContextProvider = ({ children }) => { const [bearerToken, setBearerToken] = useState(null); const [user, setUser] = useState(null); const [streamToken, setStreamToken] = useState(null); const saveAuthCredentials = ({ bearerToken, user, streamToken }) => { setBearerToken(bearerToken); setUser(user); setStreamToken(streamToken); }; useEffect(() => { const timer = setTimeout(() => { if (bearerToken !== null) { setBearerToken(null); setUser(null); setStreamToken(null); } }, 3500000); return () => clearTimeout(timer); }, [bearerToken]); return ( <AuthContext.Provider value={{ bearerToken, saveAuthCredentials, user, streamToken }} > {children} </AuthContext.Provider> ); }; export const useAuthContext = () => { const context = useContext(AuthContext); if (context === undefined) { throw new Error("useAuthContext must be used within a AuthContextProvider"); } return context; }; export default AuthContextProvider;
The application is wrapped with the exported AuthContextProvider
. This gives child components the ability to update the saved user details and tokens (using the saveAuthCredentials
function) and retrieve the saved details as may be required by each component.
Next, update the components responsible for registration and login so that the Stream token is also saved to Context. Open src/components/authentication/LoginForm.jsx
and update the onSubmit
function to match the following.
1234567const onSubmit = async (values) => { const {user, bearerToken, streamToken} = await login(values); if (user && bearerToken && streamToken) { saveAuthCredentials({user, bearerToken, streamToken}); onClose(); } };
This function is responsible for calling the backend when the user clicks the ****Submit**** button on the login form. If the user provided valid credentials, then the returned response will contain the authentication credentials which can be saved using the saveAuthCredentials
function.
In the same manner, update the onSubmit
function in src/components/authentication/RegistrationForm.jsx
to match the following.
1234567const onSubmit = async (values) => { const { user, bearerToken, streamToken } = await register(values); if (user && bearerToken && streamToken) { saveAuthCredentials({ user, bearerToken, streamToken }); navigate("/"); } };
Now that you can retrieve a Stream token from the backend, it’s time to add the chat functionality to your application. The first thing to do is add the StreamChat client instance to your application. In the src
folder, create a new folder named hooks
. In the src/hooks
folder, create a new file named useStreamClient.js
and add the following code.
123456789101112131415161718192021222324252627282930313233import {useEffect, useState} from "react"; import {StreamChat} from "stream-chat"; export const useClient = ({user, streamToken}) => { const [chatClient, setChatClient] = useState(null); const apiKey = import.meta.env.VITE_STREAM_CHAT_KEY; useEffect(() => { if (user && streamToken) { const client = new StreamChat(apiKey); // prevents application from setting stale client (user changed, for example) let didUserConnectInterrupt = false; const connectionPromise = client .connectUser(user, streamToken) .then(() => { if (!didUserConnectInterrupt) setChatClient(client); }); return () => { didUserConnectInterrupt = true; setChatClient(null); // wait for connection to finish before initiating closing sequence connectionPromise .then(() => client.disconnectUser()) .then(() => { console.log("connection closed"); }); }; } }, [apiKey, user, streamToken]); return chatClient; };
This hook creates and initializes a StreamChat instance in a “StrictMode” compliant way.
Next, add the chat feature to the ViewProduct
component. When a user is logged in, the user should be able to send a message to the product owner from this component. Additionally, if the logged-in user is the owner of the product, instead of opening a chat, the button displayed will redirect the user to the inbox (which we will build later). Update the code in src/components/product/ViewProduct.jsx
to match the following.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107import {useEffect, useState} from "react"; import {useNavigate, useParams} from "react-router-dom"; import {getProduct} from "../../helper/API.js"; import {Button, Card, CardBody, CardFooter, Center, Heading, Image, Stack, Text,} from "@chakra-ui/react"; import {Channel, ChannelHeader, Chat, MessageInput, MessageList, Thread, Window,} from "stream-chat-react"; import {useAuthContext} from "../context/AuthContext.jsx"; import {useClient} from "../../hooks/useStreamClient.js"; import "stream-chat-react/dist/css/v2/index.css"; const ViewProduct = () => { const {_id} = useParams(); const [product, setProduct] = useState(null); const [showChat, setShowChat] = useState(false); const [channel, setChannel] = useState(null); const [belongsToUser, setBelongsToUser] = useState(false); const [canShowChat, setCanShowChat] = useState(false); const {user, streamToken} = useAuthContext(); const chatClient = useClient({user, streamToken}); const navigate = useNavigate(); useEffect(() => { const load = async () => { const product = await getProduct({_id}); setProduct(product); }; load(); }, []); useEffect(() => { if (user) { setBelongsToUser(product?.owner === user.id); setCanShowChat(channel && !belongsToUser); } else { setCanShowChat(false); } }, [user, product, channel]); useEffect(() => { if (chatClient) { const channel = chatClient.channel("messaging", `${user.id}_${product._id}`, { name: user.name, members: [user.id, product.owner], }); setChannel(channel); } }, [chatClient]); const onMessageButtonClick = () => { if (belongsToUser) { navigate("/inbox"); } else { setShowChat(true); } }; return ( product && ( <Stack spacing={8} direction="row"> <Card align="center"> <CardBody> <Center> <Image src={product.productUrl} alt={product.name} borderRadius="lg" fallbackSrc="https://via.placeholder.com/500" /> </Center> <Stack mt="6" spacing="3"> <Heading size="md">{product.name}</Heading> <Text color="teal.600"> ₦{product.price} ({product.quantity} items left) </Text> <Text>{product.description}</Text> </Stack> </CardBody> <CardFooter> {chatClient && ( <Button variant="solid" colorScheme="teal" onClick={onMessageButtonClick} > {belongsToUser ? "View Inbox" : "Message Owner"} </Button> )} </CardFooter> </Card> {showChat && canShowChat && ( <Chat client={chatClient} theme="str-chat__theme-light"> <Channel channel={channel}> <Window> <ChannelHeader/> <MessageList/> <MessageInput/> </Window> <Thread/> </Channel> </Chat> )} </Stack> ) ); }; export default ViewProduct;
Next, create a new component for the user’s inbox. In the src/components
folder, create a new folder named chat
. In this folder, create a new file named Inbox.jsx
and add the following code to it.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455import { Channel, ChannelHeader, ChannelList, Chat, LoadingIndicator, MessageInput, MessageList, Thread, Window, } from "stream-chat-react"; import {Stack,} from "@chakra-ui/react"; import {useClient} from "../../hooks/useStreamClient"; import "stream-chat-react/dist/css/v2/index.css"; import {useAuthContext} from "../context/AuthContext.jsx"; import {useNavigate} from "react-router-dom"; import {useEffect} from "react"; const Inbox = () => { const {user, streamToken} = useAuthContext(); const navigate = useNavigate(); useEffect(() => { if (!user) { navigate("/"); } }, [user]); const filters = {type: "messaging", members: {$in: [user?.id]}}; const sort = {last_message_at: -1}; const chatClient = useClient({user, streamToken}); if (!chatClient) { return <LoadingIndicator/>; } return ( <Stack spacing={8} direction="row"> <Chat client={chatClient} theme="str-chat__theme-light"> <ChannelList filters={filters} sort={sort}/> <Channel> <Window> <ChannelHeader/> <MessageList/> <MessageInput/> </Window> <Thread/> </Channel> </Chat> </Stack> ); }; export default Inbox;
With the chat functionality in place, add some styling for the chat boxes. In the src
folder, create a new file named layout.css
and add the following to it.
1234567891011.str-chat__channel-list { width: 30%; } .str-chat__channel { width: 100%; } .str-chat__thread { width: 45%; }
Import this CSS file in src/App.jsx
as shown below.
1import './layout.css';
Finally, add a new route definition for the Inbox
component. Update src/routes.jsx
to match the following.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546import App from "./App"; import Products from "./components/product/ViewProducts"; import RegistrationForm from "./components/authentication/RegistrationForm"; import LoginForm from "./components/authentication/LoginForm"; import CreateProductForm from "./components/product/CreateProductForm"; import ViewProduct from "./components/product/ViewProduct"; import Inbox from "./components/chat/Inbox"; const routes = [ { path: "/", element: <App />, children: [ { index: true, element: <Products />, }, { path: "products", element: <Products />, }, { path: "register", element: <RegistrationForm />, }, { path: "login", element: <LoginForm />, }, { path: "product/create", element: <CreateProductForm />, }, { path: "product/:_id", element: <ViewProduct />, }, { path: "inbox", element: <Inbox />, }, ], }, ]; export default routes;
You can run your application using the yarn dev
command. Remember to be at the root of your project directory when running this command.
Conclusion
In this article, you’ve seen how easy it is to add real-time messaging to an application using the Stream chat service. We generated a token for the user (server-side) during the registration or login process.
On the client side, we instantiated a client and used the Stream Chat SDK to handle the rest. While we made minimal changes to the appearance of the Chat boxes, Stream allows you to customize your UI components, which allows the chat to blend in better with your application.
The final version of the application is available on the completed branch of this repository on GitHub; feel free to consult it if you run into any issues. Happy coding!