Build Instant Messaging in a MERN-Based E-commerce App

10 min read
Oluyemi Olususi
Oluyemi Olususi
Published February 20, 2024

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:

Getting Started

To get started, clone the starter repository prepared for this tutorial.

bash
1
2
git 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.

bash
1
yarn install

Next, set up the frontend and backend dependencies using the following command.

bash
1
yarn 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.

Stream Dashboard

Switch to the server folder to add Stream features to the backend.

bash
1
cd server

Next, add Stream using the following command.

bash
1
yarn 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.

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {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.

jsx
1
2
3
4
5
6
7
8
9
10
11
import { 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.

jsx
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 { 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.

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export 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.

jsx
1
import {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.

bash
1
cp .env .env.local
Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

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:

ActionEndpointMethodRequires Authentication
Create new user/registerPOSTNO
Authenticate existing user/loginPOSTNO
Get all products/productGETNO
Get product/product/:idGETNO
Create new product/productPOSTYES

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.

bash
1
yarn add stream-chat stream-chat-react

Next, create local environment variables by copying the .env file in the starter repository.

bash
1
cp .env .env.local

Next, update the values in .env.local with your Stream chat key.

bash
1
2
VITE_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.

jsx
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
import { 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.

jsx
1
2
3
4
5
6
7
const 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.

jsx
1
2
3
4
5
6
7
const 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.

jsx
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 {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.

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import {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.

jsx
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
import { 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.

css
1
2
3
4
5
6
7
8
9
10
11
.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.

jsx
1
import './layout.css';

Finally, add a new route definition for the Inbox component. Update src/routes.jsx to match the following.

jsx
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 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!

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->