DEV Community

Cover image for Discord Clone Using Next.js and Tailwind - Part 1: Project setup
Stefan Blos
Stefan Blos

Posted on • Originally published at getstream.io

Discord Clone Using Next.js and Tailwind - Part 1: Project setup

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

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

Getting the API key from the dashboard.

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.

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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]
  );
Enter fullscreen mode Exit fullscreen mode

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]
  );
Enter fullscreen mode Exit fullscreen mode

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:

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]
  );
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

Lastly, we’ll create a convenience hook to access our DiscordContext like this:

export const useDiscordContext = () => useContext(DiscordContext);
Enter fullscreen mode Exit fullscreen mode

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)