The internet is a great place to connect with people. Nothing compares to real-world encounters, but there are situations where these are not possible. In these cases, online experiences can bring people together, facilitate the exchange of ideas, build communities, and create lasting friendships.
Discord is a great example of an enabling platform. After getting started in the gaming industry, it has expanded to other areas as well. Its core value is connecting people and allowing them to hang out, chat, talk, have video conferences, share their screens, and more.
At Stream, our real-time communication SDKs offer much of the same functionality, and we also align with the mission of bringing people together. That is why, in this series of posts, we want to explore how to replicate Discord using Stream’s chat, voice, and video SDKs.
We’ll use a modern web tech stack with Next.js and TailwindCSS. Based on that, we’ll build a system with Stream Chat that allows us to create servers and channels inside of them - just like in Discord.
In addition, we’ll add voice chat to the application using the Stream Video & Audio SDK. In the end, we’ll have a fully functional clone of Discord while doing our best to closely mirror their beautiful yet playful design.
This is a larger project, so it is split into multiple parts. This is the first part where we will set up the project, Stream Chat, and build up the basic data structures we need. The following parts will focus on building the UI and then jumping into video functionality.
The final code for the project can be found in this GitHub repository.
Create a New Project
We will set up our project with Next.js and Tailwind. Luckily, this is straightforward with the installation guide from Next.js that is at our disposal.
We start with the following command:
npx create-next-app@latest
We will be guided through the setup process. Here are the questions and the parameters that we will use for the project:
- What is your project named?
discord-clone
- Would you like to use TypeScript?
Yes
- Would you like to use ESLint?
Yes
- Would you like to use Tailwind CSS?
Yes
- Would you like to use src/ directory?
No
- Would you like to use App Router?
Yes
- Would you like to customize the default import alias (@/*)?
No
With that, the project is configured for us, and we can open up our editor (in our case VSCode).
Next (no pun intended), we will clean up the project and set up Stream Chat.
Set Up Stream Chat
We can start up the project and take a look at the template that Next.js provided for us. We start the development environment with the following command:
// Using yarn
yarn dev
// Using npm
npm run dev
We can navigate to localhost:3000 in our browser to see it in action.
First, we add the Stream Chat dependency with the following command:
// Using yarn
yarn add stream-chat stream-chat-react
// Using npm
npm install stream-chat stream-chat-react
Next, to initialize the Stream SDK we create a helper hook. Create a new folder called hooks
and add a file called useClient.ts
.
This hook eliminates side effects and properly initializes the SDK in the Next.js context. Here’s the code:
import { useEffect, useState } from 'react';
import { StreamChat, TokenOrProvider, User } from 'stream-chat';
export type UseClientOptions = {
apiKey: string;
user: User;
tokenOrProvider: TokenOrProvider;
};
export const useClient = ({
apiKey,
user,
tokenOrProvider,
}: UseClientOptions): StreamChat | undefined => {
const [chatClient, setChatClient] = useState<StreamChat>();
useEffect(() => {
const client = new StreamChat(apiKey);
// prevents application from setting stale client (user changed, for example)
let didUserConnectInterrupt = false;
const connectionPromise = client
.connectUser(user, tokenOrProvider)
.then(() => {
if (!didUserConnectInterrupt) {
setChatClient(client);
}
});
return () => {
didUserConnectInterrupt = true;
setChatClient(undefined);
// wait for connection to finish before initiating closing sequence
connectionPromise
.then(() => client.disconnectUser())
.then(() => {
console.log('connection closed');
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- should re-run only if user.id changes
}, [apiKey, user.id, tokenOrProvider]);
return chatClient;
};
Here, we allow input of the apiKey
, a User
object, and a Token
(or a provider that creates a token for us). The user is connected to the client, and upon unmounting the component, the user will be disconnected again.
With this hook in place, we can now go to the page.tsx
file and add the UI to the project:
'use client';
const user: User = {
id: '7cd445eb-9af2-4505-80a9-aa8543c3343f',
name: 'Harry Potter',
image: `https://getstream.io/random_png/?id=${userId}&name=${userName}`,
};
const apiKey = '7cu55d72xtjs';
const userToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiN2NkNDQ1ZWItOWFmMi00NTA1LTgwYTktYWE4NTQzYzMzNDNmIn0.TtrCA5VoRB2KofI3O6lYjYZd2pHdQT408u7ryeWO4Qg';
export default function Home() {
const chatClient = useClient({
apiKey,
user,
tokenOrProvider: userToken,
});
if (!chatClient) {
return <LoadingIndicator />;
}
return (
<Chat client={chatClient} theme='str-chat__theme-light'>
<ChannelList />
<Channel>
<Window>
<ChannelHeader />
<MessageList />
<MessageInput />
</Window>
<Thread />
</Channel>
</Chat>
);
}
We’re doing a few things, let’s quickly review them:
- We need to configure this component to be client-side rendered using the
use client
tag at the beginning of the file - We create a user (of type
User
) with anid
, aname
, and animage
. We take theapiKey
from the Stream Dashboard and initialize auserToken
- To create a production token setup, a server is required (we offer SDKs for many different languages). However, during development we can use a handy tool Stream provides: the JWT Generator. With the application secret and a user ID it can create a token for us. (🚨 Remember to only use these during development, though)
- These previously created variables are used in the
useClient
hook to set up the SDK. - For now, we’ll render out the standard Stream UI components like
Chat
andChannelList
to have something to show
This code is enough to set up the Stream SDK and render a fully functional chat experience to the screen. We showed an example of a previously created user in our project (by using our API key). To add this to a project yourself, we will go through the process using the Stream Dashboard.
Create a User in the Stream Dashboard
The code we just went through assumes that we already created a user in the dashboard. We want to quickly go through the process of doing that.
Head to the Stream Dashboard and create a new project. We offer a free tier so we can get started easily. We’ll be greeted with an overview screen that directly contains the API key we need (marked in the blue box in the screenshot below).
Here, we can also see the secret shown next to the API key, which we can copy or reveal (we need this for the token generation after having created our user.
Next, on the navigation bar on the left, we go to Chat Messaging → Explorer. We click on Users and then hit the button Create new user (see screenshot below).
In the upcoming dialog we:
- create a name for the user, which is what will be displayed in the application
- either enter an ID or use an auto-generated one (we will need this ID to generate a token in a second)
- for the User Application Role we select
User
(this is the standard for - well - users, read more on our permission and role system here)
After creating the user we want to generate a user token. This basic JWT authenticates users in the Stream backend. The solution for production applications is that one of Stream’s backend SDKs creates a new token whenever a user needs to authenticate. To speed up the development process, we’ll use a handy tool.
We go to the JWT Generator and paste the secret of our application (remember that it can be found under Chat Messaging → Overview) and the ID of the user we just created, and we get a token.
We can replace the data we showed you in our page.tsx
file with the one we just created. Specifically, these are the user data, the API key, and the token. Once that is done, the app will work with your private application and use the data from that.
Let’s think about the data structures we need next.
Setup Discord Data
The way Discord is set up is that we have servers we can join. On each server, we can have multiple channels grouped into certain categories. In addition, we can have direct messages with each user independent of servers and channels.
Stream Chat doesn’t come built-in with servers, but we have a lot of freedom due to being able to add custom fields to a channel in the extraData
object. Whenever we create a channel, we’ll add a server
key to the extraData
object and set it to one of the servers that we have. In the list of channels, we then filter for only channels within the given server.
Also, we’ll add a category
field to the extraData
so that we can group the channels inside of a server easily.
We can get a server list by filtering channels with their extra data and getting all distinct server names and images from that.
Creating a DiscordContext
We’ll use a Context
to handle servers and channels in a single place. This gives us convenient access, and we have central points to manage our state.
We keep a state object that gives functions to do the following things:
- create a server
- create a channel
- change the current server
Aside from that, we’ll keep a server
in our state. For that, we create a distinct type. Create a file called DiscordServer.ts
:
export type DiscordServer = {
name: string;
image: string | undefined;
};
Now, we can create a file called DiscordContext.tsx
and start to fill it up. First, let’s define a type for our state:
type DiscordState = {
server?: DiscordServer;
changeServer: (server: DiscordServer) => void;
createServer: (client: StreamChat, name: string, imageUrl: string) => void;
createChannel: (
client: StreamChat,
name: string,
image: string,
category: string,
userIds: string[]
) => void;
};
The functions take different parameters and we’ll go over them once we implement the functionality for them. For now, let’s focus on setting up our context first.
We need to initialize our state with something, so let’s add an empty convenience object for that and initialize the DiscordContext
object with that:
const initialValue: DiscordState = {
server: undefined,
changeServer: () => {},
createServer: () => {},
createChannel: () => {},
};
const DiscordContext = createContext<DiscordState>(initialValue);
Now, we can create the context object by creating the skeleton of a provider first. In the next step, we will create the necessary functions, but let’s do the setup first:
export const DiscordContextProvider: any = ({
children,
}: {
children: React.ReactNode;
}) => {
const [myState, setMyState] = useState<DiscordState>(initialValue);
const store: DiscordState = {
server: myState.server,
changeServer: () => {},
createServer: () => {},
createChannel: () => {},
};
return (
<DiscordContext.Provider value={store}>{children}</DiscordContext.Provider>
);
};
Changing a server is the most basic task. It requires us to update the state (using setMyState
) we keep in our context. We’ll create these functions inside the provider object.
One thing to note is that we’ll wrap each of them in a useCallback
hook. This ensures that the memory address of the functions will be kept constant and will not trigger re-draws of the UI. It’s a useful thing to do, more information can be found here.
Here is the changeServer
function:
const changeServer = useCallback(
(server: DiscordServer) => {
setMyState({ ...myState, server });
},
[myState]
);
Next up is the createServer
function. Due to our setup, we will need to create a channel with the name
and image
in the extraData
. This makes sense, as servers without channels wouldn’t make sense anyway. We’ll default to creating a new category called Text Channels
for each new server with a channel name of Welcome
.
When we create the channel, the server list will be re-drawn, detect that new server, and add it to the UI. Also, after creating the new server, we’ll directly switch to that (using the previously created changeServer
function).
We need the client
to create a new channel in the Stream backend andname
and imageUrl
to set these parameters. Here’s the code:
const createServer = useCallback(
async (client: StreamChat, name: string, imageUrl: string) => {
if (client.userID) {
const channel = client.channel('messaging', uuid(), {
name: 'Welcome',
members: [client.userID, 'test-user'],
data: {
image: imageUrl,
server: name,
category: 'Text Channels',
},
});
try {
const response = await channel.create();
console.log('[createServer] Response: ', response);
changeServer({ name, image: imageUrl });
} catch (err) {
console.log(err);
}
}
},
[changeServer]
);
Lastly, we want to be able to create new channels on a server. For that, we need the following parameters:
-
client
: theStreamChat
object to create channels with -
name
: the channel needs a name of course -
image
: there should be an URL attached to have an image for the channel -
category
: each channel is attached to a category -
userIds
: an array of users that we want to add to the channel
With that, we can create a new channel, just like we did in createServer
:
const createChannel = useCallback(
async (
client: StreamChat,
name: string,
image: string,
category: string,
userIds: string[]
) => {
const channel = client.channel('messaging', {
name: name,
members: userIds,
data: {
image: image,
server: myState.server?.name,
category: category,
},
});
try {
const response = await channel.create();
console.log('[createChannel] Response: ', response);
} catch (err) {
console.log(err);
}
},
[myState.server?.name]
);
This adds the last piece of functionality that we needed. To tie things up, we need to replace the dummy store
object that we created previously with this piece of code:
const store: DiscordState = {
server: myState.server,
changeServer: changeServer,
createServer: createServer,
createChannel: createChannel,
};
Lastly, we’ll create a convenience hook to access our DiscordContext
like this:
export const useDiscordContext = () => useContext(DiscordContext);
Now, we have easy access to all the functionality that we need.
Summary
This marks the end of the first part of this series. We managed to set up a brand new project using Next.js and TailwindCSS. To complete our modern setup we have added Stream Chat and initialized the SDK properly.
Then we modeled our data structure that requires zero backend work from our side. By leveraging the extraData
object that the channels come with by default, we conveniently create servers and can filter by them and categories.
Last, we prepared a DiscordContext
object so that we can have access to all the necessary logic we need for switching and creating servers and adding new channels on them using different categories.
This is a good place to take a break. In the next parts of this series, we will build up the UI and customize it to look like the Discord app we know. Stay tuned and follow us on our socials (Twitter, LinkedIn) to get notified when these will be released.
In the meantime, feel free to check the Github repo to see the full code (and give it a ⭐️ while you’re at it). Thanks for following this tutorial, and see you for part two where we will cover UI for servers and channels.
Top comments (0)