Discord Clone Using Next.js and Tailwind - Part 1: Project setup
Tags: webdev, nextjs, typescript, tailwindcss
Published: 2024-01-26 10:55:20

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](https://discord.com/) 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](https://nextjs.org/) and [TailwindCSS](https://tailwindcss.com/). Based on that, we’ll build a system with [Stream Chat](https://getstream.io/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](https://getstream.io/video/sdk/react/). 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](https://github.com/GetStream/discord-clone-nextjs).

## Create a New Project

We will set up our project with Next.js and Tailwind. Luckily, this is straightforward with the [installation guide](https://nextjs.org/docs/getting-started/installation) from Next.js that is at our disposal.

We start with the following command:

```jsx
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](https://code.visualstudio.com)).

Next (no pun intended), we will clean up the project and set up Stream [Chat](https://getstream.io/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:

```jsx
// Using yarn
yarn dev

// Using npm
npm run dev
```

We can navigate to [localhost:3000](http://localhost:3000) in our browser to see it in action.

First, we add the [Stream Chat](https://getstream.io/chat) dependency with the following command:

```tsx
// 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:

```tsx
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:

```tsx
'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 an `id`, a `name`, and an `image`. We take the `apiKey` from the Stream Dashboard and initialize a `userToken`
- To create a production token setup, a server is required ([we offer SDKs](https://getstream.io/chat/sdk/#backend-clients) for many different languages). However, during development we can use a handy tool Stream provides: the [JWT Generator](https://getstream.io/chat/docs/react/token_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` and `ChannelList` 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](https://dashboard.getstream.io/).

## 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](https://dashboard.getstream.io/) and create a new project. We offer a [free tier](https://getstream.io/pricing/#chat) 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).


![Getting the API key from the dashboard.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p6utix4o54ocjdikgo7z.png)

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



![Create new user in the dashboard.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zlh75g8245stlhj5goo9.png)

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](https://getstream.io/chat/docs/other-rest/user_permissions/))

After creating the user we want to generate a user token. This basic [JWT](https://jwt.io/introduction)  authenticates users in the Stream backend. The solution for production applications is that one of Stream’s [backend SDKs](https://getstream.io/chat/sdk/#backend-clients) 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](https://getstream.io/chat/docs/react/token_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`:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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](https://react.dev/reference/react/useCallback).

Here is the `changeServer` function:

```tsx
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 and`name` and `imageUrl` to set these parameters. Here’s the code:

```tsx
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`: the `StreamChat` 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`:

```tsx
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:

```tsx
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:

```tsx
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](https://nextjs.org/) and [TailwindCSS](https://tailwindcss.com/). 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](https://twitter.com/getstream_io), [LinkedIn](https://www.linkedin.com/company/getstream/)) to get notified when these will be released.

In the meantime, feel free to check the [Github repo](https://github.com/GetStream/discord-clone-nextjs) 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.