Building a Slack Clone with Next.js and TailwindCSS - Part One

Collaboration is essential for success, and creating a tool that helps teams work better can be fun and rewarding. With more people working from home, building an app that helps everyone stay connected through messaging, video, and group chats can make a big difference.

In this three-part series, we will build a Slack clone—an app that helps teams stay in touch with instant messaging, video calls, and channels. We’ll build this application using React (Next.js), TailwindCSS, Prisma, and Stream SDKs.

In this first part, we'll set up the basics by setting up the project and building the first user interface, including the channel page.

In part two, we'll add real-time messaging and channels using Stream React Chat SDK. Finally, in part three, we'll add video calls (like Slack Huddles) using Stream React Video and Audio SDK and add the final touches.

By the end of this series, you'll have built a robust collaboration app that mirrors the essential features of Slack.

Here's a glimpse of what the final product will look like:

You can check out the live demo and access the complete source code on GitHub.

Let's get started!

Prerequisites

Before starting the project, make sure you have the following:

  • Basic Understanding of React: You should be comfortable building components, managing state, and understanding how components work.

  • Node.js and npm: Ensure Node.js and npm (Node Package Manager) are installed on your computer. This is important for running and building our project.

  • Familiarity with TypeScript, Next.js, and TailwindCSS Basics: We'll use these tools a lot, so knowing the basics will help you follow along easily.

Project Setup

Let's start by setting up our project. We'll begin by cloning a starter template that contains the initial setup code and folder structure to help us get started quickly:

# Clone the repository
git clone https://github.com/TropicolX/slack-clone.git

# Navigate into the project directory
cd slack-clone

# Check out the starter branch
git checkout starter

# Install the dependencies
npm install

The project structure should look like the following:

This project is organized to keep the code neat and easy to manage as it grows:

  • Components Directory: This folder has all the reusable parts of the user interface, like icons, buttons, and other base components.

  • Hooks Directory: The hooks folder has custom React hooks like useClickOutside, which we will use to handle specific user interactions.

  • Lib Directory: This folder contains utility functions like utils.ts that simplify common tasks across the app.

Setting Up the Database

To build an app similar to Slack, we need to be able to store information about workspaces, channels, members, and invitations in a database.

We'll be using Prisma to help us interact with this database easily.

What is Prisma?

Prisma is an open-source ORM (Object-Relational Mapping) tool that lets us define our database structure and run queries efficiently. With Prisma, you can write database operations more intuitively without needing to handle SQL directly, which makes things simpler and reduces errors.

Installing Prisma

Let’s start by installing Prisma and its dependencies:

npm install prisma --save-dev
npm install @prisma/client sqlite3

The @prisma/client library helps us interact with the database and sqlite3 is the database we will use for this project.

After installing, let’s initialize Prisma with the following command:

npx prisma init

This command sets up the default Prisma structure and creates a new .env file where we will configure our database connection.

Setting Up the Database Schema

Now, let's define our database schema. Open the prisma/schema.prisma file and add the following:

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Workspace {
  id           String       @id @default(cuid())
  name         String
  image        String?
  ownerId      String 
  channels     Channel[]    
  memberships  Membership[]
  invitations Invitation[]
}

model Channel {
  id           String       @id @default(cuid())
  name         String
  description  String?
  workspaceId  String
  workspace    Workspace    @relation(fields: [workspaceId], references: [id])
}

model Membership {
  id           String       @id @default(cuid())
  userId       String
  email        String
  workspaceId  String
  workspace    Workspace @relation(fields: [workspaceId], references: [id])
  role         String?      @default("member")
  joinedAt     DateTime?    @default(now())
  @@unique([userId, workspaceId])
}

model Invitation {
  id            Int        @id @default(autoincrement())
  email         String
  token         String     @unique
  workspaceId   String
  workspace     Workspace  @relation(fields: [workspaceId], references: [id])
  invitedById   String
  acceptedById  String?
  createdAt     DateTime   @default(now())
  acceptedAt    DateTime?
}

This schema defines the primary relationships in our Slack clone. Here's what each model does:

  • Workspace: Represents a workspace where people can collaborate. It contains information like the workspace name, image, and the list of channels, memberships, and invitations linked to it.

  • Channel: Represents a channel within a workspace. Channels are where users can have specific discussions, and they belong to a particular workspace.

  • Membership: Keeps track of which users are part of which workspace. It includes details like the user ID, email, role (e.g., member), and when they joined the workspace.

  • Invitation: Manages invitations to join a workspace. It tracks the invitee's email, a unique token for the invitation, who invited them, and whether or not the invitation has been accepted.

Each model has its own details and connections, making it easy to get related data as we build features.

Next, let’s set up our database connection. Navigate to your .env file and add the following:

DATABASE_URL=file:./dev.db

This sets up SQLite as our database for local development. You could switch to another database in production, but SQLite is great for quick prototyping and development.

Running Prisma Migrations

To create the database tables based on our schema, run the following command:

npx prisma migrate dev --name init

This command sets up the tables for the models we defined in the database. It also helps us keep track of changes in our database setup during development.

After running the migration, generate the Prisma client by running the following command:

npx prisma generate

This command creates the Prisma client, which lets us work with the database safely and reliably throughout our code.

Setting Up Prisma Client in Code

To use the Prisma client in our project, create a new prisma.ts file in the lib directory with the following code:

import { PrismaClient } from '@prisma/client';

let prisma: PrismaClient;

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient();
} else {
  // @ts-expect-error global.prisma is used by @prisma/client
  if (!global.prisma) {
    // @ts-expect-error global.prisma is used by @prisma/client
    global.prisma = new PrismaClient();
  }
  // @ts-expect-error global.prisma is used by @prisma/client
  prisma = global.prisma;
}
export default prisma;

This script makes sure we only create one Prisma client instance. We use a global instance during development to avoid problems with too many database connections. This is especially useful because frequent restarts or hot reloading can otherwise lead to connection issues.

User Authentication with Clerk

What is Clerk?

Clerk is a platform that helps manage users by providing tools for authentication and user profiles. It includes ready-made UI components, APIs, and a dashboard for admins, making adding authentication features to your app much more straightforward. Instead of building an entire authentication system yourself, Clerk saves time and effort by offering these features out of the box.

In this project, we’ll use Clerk to handle user authentication.

Setting Up a Clerk Account

Clerk sign-up page

First, you'll need to create a free account with Clerk. Go to the Clerk sign-up page and sign up using your email or a social login option.

Creating a Clerk Project

After signing in, you can create a new project in Clerk for your app:

  1. Go to the dashboard and click "Create application".

  2. Name your application “Slack clone”.

  3. Under “Sign in options,” choose Email, Username, and Google.

  4. Click the "Create application" to complete the setup.

Clerk dashboard steps

After creating the project, you'll see the application overview page, which contains your Publishable Key and Secret Key—keep these handy as you'll need them later.

Next, we’ll make the first and last names required fields during sign-up:

  1. Navigate to your dashboard's "Configure" tab.

  2. Under "User & Authentication", select "Email, Phone, Username".

  3. Find the "Name" option in the "Personal Information" section and toggle it on..

  4. Click the gear icon next to "Name" and set it as required.

  5. Click “Continue” to save your changes.

Installing Clerk in Your Project

Next, let's add Clerk to your Next.js project:

  1. Install the Clerk package by running the command below:

     npm install @clerk/nextjs
    
  2. Create an .env.local file and add the following environment variables:

     NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key
     CLERK_SECRET_KEY=your_clerk_secret_key
    

    Replace your_clerk_publishable_key and your_clerk_secret_key with the keys from your project's overview page.

  3. To use Clerk's authentication throughout your app, you need to wrap your application with ClerkProvider. Update your app/layout.tsx file like this:

     import type { Metadata } from 'next';
     import { ClerkProvider } from '@clerk/nextjs';
    
     ...
    
     export default function RootLayout({
       children,
     }: Readonly<{
       children: React.ReactNode;
     }>) {
       return (
         <ClerkProvider>
           <html lang="en">
             <body className="text-white bg-purple antialiased">{children}</body>
           </html>
         </ClerkProvider>
       );
     }
    

Creating Sign-Up and Sign-In Pages

Now, we need to set up sign-up and sign-in pages using Clerk's <SignUp /> and <SignIn /> components. These components come with built-in UI and handle all the authentication logic.

Here's how to add the pages:

  1. Set Authentication URLs: Clerk's <SignUp /> and <SignIn /> components need to know where they're mounted in your app. Add these routes to your .env.local file:

     NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
     NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
    
  2. Create the Sign-Up Page: Create a sign-up page at app/sign-up/[[...sign-up]]/page.tsx, and add the following code:

     import { SignUp } from '@clerk/nextjs';
    
     export default function Page() {
       return (
         <div className="sm:w-svw sm:h-svh bg-purple w-full h-full flex items-center justify-center">
           <SignUp />
         </div>
       );
     }
    
  3. Create the Sign-In Page: Create a page.tsx file in the app/sign-in/[[...sign-in]] directory and add the code below:

     import { SignIn } from '@clerk/nextjs';
    
     export default function Page() {
       return (
         <div className="w-svw h-svh bg-purple flex items-center justify-center">
           <SignIn />
         </div>
       );
     }
    
  4. Add Your Clerk Middleware: Clerk comes with a clerkMiddleware() helper that integrates authentication into our Next.js project. We can use this middleware to protect some routes while keeping others public.

    In our case, we want only the sign-up and sign-in routes accessible to everyone while protecting other routes. To do this, create a middleware.ts file in the src directory with the following code:

     import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
    
     const isPublicRoute = createRouteMatcher([
       '/sign-in(.*)',
       '/sign-up(.*)',
     ]);
    
     export default clerkMiddleware(async (auth, request) => {
       if (!isPublicRoute(request)) {
         await auth.protect();
       }
     });
    
     export const config = {
       matcher: [
         // Skip Next.js internals and all static files, unless found in search params
         '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
         // Always run for API routes
         '/(api|trpc)(.*)',
       ],
     };
    

With these steps completed, Clerk should be integrated into your app, and your sign-in and sign-up pages should be fully functional.

Building The Workspace Dashboard

Now that we have set up our database and authentication, it's time to start building the workspace dashboard. This dashboard will be the main area where users can use different parts of the app, view their workspaces, and see invitations to other workspaces.

To get started, we need to create components that will make up the main parts of our user interface.

Creating the Navigation Bar

The navigation bar is integral to websites because it helps users navigate the sites and access different features.

However, in this setup, the navigation links don't lead anywhere. The primary helpful item here is the 'Create a new workspace' button within it.

Create a Navbar.tsx file in the components folder and add the following code:

import { ReactNode } from 'react';
import Button from './Button';

type NavbarProps = {
  action: () => void;
};

const Navbar = ({ action }: NavbarProps) => {
  return (
    <header>
      <nav className="bg-purple h-20">
        <div className="flex justify-between h-full px-[4vw] mx-auto">
          <div className="flex items-center w-[125px] justify-start">
            <div className="flex items-center gap-1.5">
              <div className="w-[26px] h-[26px]">
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
                  <path
                    d="M27.255 80.719c0 7.33-5.978 13.317-13.309 13.317C6.616 94.036.63 88.049.63 80.719s5.987-13.317 13.317-13.317h13.309zm6.709 0c0-7.33 5.987-13.317 13.317-13.317s13.317 5.986 13.317 13.317v33.335c0 7.33-5.986 13.317-13.317 13.317-7.33 0-13.317-5.987-13.317-13.317zm0 0"
                    fill="#de1c59"
                  />
                  <path
                    d="M47.281 27.255c-7.33 0-13.317-5.978-13.317-13.309C33.964 6.616 39.951.63 47.281.63s13.317 5.987 13.317 13.317v13.309zm0 6.709c7.33 0 13.317 5.987 13.317 13.317s-5.986 13.317-13.317 13.317H13.946C6.616 60.598.63 54.612.63 47.281c0-7.33 5.987-13.317 13.317-13.317zm0 0"
                    fill="#35c5f0"
                  />
                  <path
                    d="M100.745 47.281c0-7.33 5.978-13.317 13.309-13.317 7.33 0 13.317 5.987 13.317 13.317s-5.987 13.317-13.317 13.317h-13.309zm-6.709 0c0 7.33-5.987 13.317-13.317 13.317s-13.317-5.986-13.317-13.317V13.946C67.402 6.616 73.388.63 80.719.63c7.33 0 13.317 5.987 13.317 13.317zm0 0"
                    fill="#2eb57d"
                  />
                  <path
                    d="M80.719 100.745c7.33 0 13.317 5.978 13.317 13.309 0 7.33-5.987 13.317-13.317 13.317s-13.317-5.987-13.317-13.317v-13.309zm0-6.709c-7.33 0-13.317-5.987-13.317-13.317s5.986-13.317 13.317-13.317h33.335c7.33 0 13.317 5.986 13.317 13.317 0 7.33-5.987 13.317-13.317 13.317zm0 0"
                    fill="#ebb02e"
                  />
                </svg>
              </div>
              <span className="text-[29px] font-outfit font-bold">slack</span>
            </div>
          </div>
          <div className="hidden sm:flex items-center text-sm flex-1">
            <ul className="flex flex-1 leading-[1.555] -tracking-[.0012em]">
              <NavLink dropdown>Features</NavLink>
              <NavLink dropdown>Solutions</NavLink>
              <NavLink>Enterprise</NavLink>
              <NavLink dropdown>Resources</NavLink>
              <NavLink>Pricing</NavLink>
            </ul>
            <button className="hidden lg:flex mt-1 mr-6">
              <svg
                width="20"
                height="20"
                fill="white"
                xmlns="http://www.w3.org/2000/svg"
              >
                <path
                  d="m18.78 17.72c.1467.1467.22.3233.22.53 0 .2133-.0733.39-.22.53-.16.1467-.3367.22-.53.22-.2067 0-.3833-.0733-.53-.22l-4.47-4.47c-.6667.54-1.4067.9567-2.22 1.25-.8067.2933-1.65.44-2.53.44-1.36 0-2.61333-.3367-3.76-1.01s-2.05667-1.5833-2.73-2.73-1.01-2.4-1.01-3.76.33667-2.61333 1.01-3.76 1.58333-2.05667 2.73-2.73 2.4-1.01 3.76-1.01 2.6133.33667 3.76 1.01 2.0567 1.58333 2.73 2.73 1.01 2.4 1.01 3.76c0 .88-.1467 1.7267-.44 2.54-.2933.8067-.71 1.5433-1.25 2.21zm-10.28-3.22c1.08667 0 2.0867-.27 3-.81.92-.54 1.65-1.2667 2.19-2.18.54-.92.81-1.92333.81-3.01s-.27-2.08667-.81-3c-.54-.92-1.27-1.65-2.19-2.19-.9133-.54-1.91333-.81-3-.81s-2.09.27-3.01.81c-.91333.54-1.64 1.27-2.18 2.19-.54.91333-.81 1.91333-.81 3s.27 2.09.81 3.01c.54.9133 1.26667 1.64 2.18 2.18.92.54 1.92333.81 3.01.81z"
                  stroke="#fff"
                  strokeWidth=".5"
                ></path>
              </svg>
            </button>
            <form action={action}>
              <Button
                type="submit"
                variant="secondary"
                className="hidden lg:flex ml-2 py-0 w-[240px] h-[45px]"
              >
                <span>Create a new workspace</span>
              </Button>
            </form>
          </div>
        </div>
      </nav>
    </header>
  );
};

type NavLinkProps = {
  dropdown?: boolean;
  children: ReactNode;
};

const NavLink = ({ dropdown = false, children }: NavLinkProps) => {
  return (
    <li className="p-[.25rem_.88rem]">
      <button className="text-[15.5px] font-semibold flex items-center gap-1">
        <span>{children}</span>
        {dropdown && (
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="8"
            height="5"
            viewBox="0 0 8 5"
            fill="none"
          >
            <path
              d="M7 1L4 4L1 1"
              stroke="white"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        )}
      </button>
    </li>
  );
};

export default Navbar;

Here, we render the Navbar component, which includes the logo, placeholder links, and a button to create a new workspace. The Navbar also takes an action prop, which is triggered when the 'Create a new workspace' button is clicked.

This function allows us to define what happens when users want to create a new workspace.

Creating a Workspace List Component

To display all the workspaces a user has, we need a WorkspaceList component. This component will show the workspace details and allow users to launch a workspace or accept an invitation.

Create a new file named /components/WorkspaceList.tsx and add the following code:

import { Workspace } from '@prisma/client';

import Button from './Button';

interface WorkspaceListProps {
  action: (formData: FormData) => void;
  actionText: string;
  buttonVariant?: 'primary' | 'secondary';
  title: string;
  workspaces: (Omit<Workspace, 'ownerId'> & {
    memberCount: number;
    token?: string;
    firstChannelId?: string;
  })[];
}

const placeholderImage =
  'https://a.slack-edge.com/80588/img/avatars-teams/ava_0014-88.png';

const WorkspaceList = ({
  action,
  actionText,
  buttonVariant = 'primary',
  title,
  workspaces,
}: WorkspaceListProps) => {
  return (
    <div className="rounded-[9px] mb-12 border-[#fff3] border-4">
      <div className="flex items-center bg-[#ecdeec] text-black p-4 text-lg rounded-t-[5px] min-h-[calc(50px+2rem)]">
        {title}
      </div>
      <div className="flex flex-col rounded-b-[5px] bg-[#fff] [&>:not(:first-child)]:border [&>:not(:first-child)]:border-t-[#ebeaeb]">
        {workspaces.map((workspace) => (
          <form action={action} key={workspace.id} className="p-4">
            <input
              type="hidden"
              name="channelId"
              value={workspace?.firstChannelId}
            />
            <input type="hidden" name="token" value={workspace?.token} />
            <input type="hidden" name="workspaceId" value={workspace.id} />
            <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-0">
              <div className="flex items-center">
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={workspace.image || placeholderImage}
                  alt="workspace-image"
                  className="rounded-[5px] mr-4 h-[75px] w-[75px] object-cover"
                />
                <div className="flex flex-col my-auto text-black">
                  <span className="text-lg font-bold mb-2">
                    {workspace.name}
                  </span>
                  <div className="flex h-5">
                    <span className="text-[#696969] text-[14.5px]">
                      {workspace.memberCount} member
                      {workspace.memberCount !== 1 && 's'}
                    </span>
                  </div>
                </div>
              </div>
              <div className="sm:ml-auto w-full sm:w-auto flex sm:block">
                <Button
                  type="submit"
                  variant={buttonVariant}
                  className="grow shrink-0"
                >
                  <span>{actionText}</span>
                </Button>
              </div>
            </div>
          </form>
        ))}
      </div>
    </div>
  );
};

export default WorkspaceList;
  • The component takes several props:

    • action: A function defining what happens when the action button clicks.

    • actionText: The text displayed on the button for each workspace.

    • buttonVariant: Specifies the style of the button, either 'primary' or 'secondary'.

    • title: The title of the workspace list.

    • workspaces: An array of workspace objects with additional details.

  • For each workspace, we show:

    • Name: The name of the workspace.

    • Image: The workspace image or a placeholder if no image is provided.

    • Number of Members: The count of members in the workspace.

  • Each workspace item is also a form that has hidden input fields. These fields store essential data like channelId, token, and workspaceId. When a user clicks the submit button, these inputs send the needed information to act for that workspace.

Putting It All Together

Now that we have created the essential components for the workspace dashboard, it's time to bring them together and make the main dashboard page. We will use the Navbar and WorkspaceList components to build a user-friendly interface for our workspace app.

Update your app/page.tsx file to bring all the components together and create the dashboard:

import Image from 'next/image';
import { redirect } from 'next/navigation';
import { currentUser } from '@clerk/nextjs/server';
import { SignOutButton } from '@clerk/nextjs';

import Button from '@/components/Button';
import Navbar from '@/components/Navbar';
import prisma from '@/lib/prisma';
import WorkspaceList from '@/components/WorkspaceList';

export default async function Home() {
  const user = await currentUser();
  const userEmail = user?.primaryEmailAddress?.emailAddress;

  const memberships = await prisma.membership.findMany({
    where: {
      userId: user!.id,
    },
    include: {
      workspace: {
        include: {
          _count: {
            select: { memberships: true },
          },
          memberships: {
            take: 5,
          },
          channels: {
            take: 1,
            select: {
              id: true,
            },
          },
        },
      },
    },
  });

  const workspaces = memberships.map((membership) => {
    const { workspace } = membership;
    return {
      id: workspace.id,
      name: workspace.name,
      image: workspace.image,
      memberCount: workspace._count.memberships,
      firstChannelId: workspace.channels[0].id,
    };
  });

  const invitations = await prisma.invitation.findMany({
    where: {
      email: userEmail,
      acceptedAt: null,
    },
    include: {
      workspace: {
        include: {
          _count: {
            select: { memberships: true },
          },
          memberships: {
            take: 5,
          },
        },
      },
    },
  });

  const processedInvitations = invitations.map((invitation) => {
    const { workspace } = invitation;
    return {
      id: workspace.id,
      name: workspace.name,
      image: workspace.image,
      memberCount: workspace._count.memberships,
      token: invitation.token,
    };
  });

  async function acceptInvitation(formData: FormData) {
    'use server';
    const token = String(formData.get('token'));
    const invitation = await prisma.invitation.findUnique({
      where: { token },
    });

    await prisma.membership.create({
      data: {
        userId: user!.id,
        email: userEmail!,
        workspace: {
          connect: { id: invitation!.workspaceId },
        },
        role: 'user',
      },
    });

    await prisma.invitation.update({
      where: { token },
      data: {
        acceptedAt: new Date(),
        acceptedById: user!.id,
      },
    });

    const workspace = await prisma.workspace.findUnique({
      where: { id: invitation!.workspaceId },
      select: {
        id: true,
        channels: {
          take: 1,
          select: {
            id: true,
          },
        },
      },
    });

    redirect(`/client/${workspace!.id}/${workspace!.channels[0].id}`);
  }

  async function launchChat(formData: FormData) {
    'use server';
    const workspaceId = formData.get('workspaceId');
    const channelId = formData.get('channelId');
    redirect(`/client/${workspaceId}/${channelId}`);
  }

  async function goToGetStartedPage() {
    'use server';
    redirect('/get-started');
  }

  return (
    <div className="font-lato min-h-screen text-white">
      <Navbar action={goToGetStartedPage} />
      <section className="mt-9 max-w-[62.875rem] mx-auto px-[4vw]">
        {/* Workspaces */}
        <div className="flex items-center gap-1 mb-6">
          <Image
            src="https://a.slack-edge.com/6c404/marketing/img/homepage/bold-existing-users/waving-hand.gif"
            width={52}
            height={56}
            alt="waving-hand"
            unoptimized
          />
          <h1 className="text-[40px] sm:text-[55.5px] leading-[1.12] font-outfit font-semibold">
            Welcome back
          </h1>
        </div>
        <div className="mb-12">
          {workspaces.length > 0 ? (
            <WorkspaceList
              title={`Workspaces for ${userEmail}`}
              workspaces={workspaces}
              action={launchChat}
              actionText="Launch Slack"
            />
          ) : (
            <p className="text-lg font-bold pt-4">
              You are not a member of any workspaces yet.
            </p>
          )}
        </div>
        {/* Create new workspace */}
        <div className="rounded-[9px] mb-12 border-[#fff3] border-4">
          <div className="flex flex-col sm:grid items-center bg-[#fff] p-4 grid-rows-[1fr] grid-cols-[200px_1fr_auto] rounded-[5px]">
            <Image
              src="https://a.slack-edge.com/613463e/marketing/img/homepage/bold-existing-users/create-new-workspace-module/woman-with-laptop-color-background.png"
              width={200}
              height={121}
              className="rounded-[5px] m-[-1rem_-1rem_-47px]"
              alt="woman-with-laptop"
            />
            <p className="mt-[50px] text-center sm:text-start mb-3 sm:my-0 pr-4 tracking-[.02em] text-[17.8px] text-black">
              <strong>
                {workspaces.length > 0
                  ? 'Want to use Slack with a different team?'
                  : 'Want to get started with Slack?'}
              </strong>
            </p>
            <form action={goToGetStartedPage}>
              <Button type="submit" variant="secondary">
                Create a new workspace
              </Button>
            </form>
          </div>
        </div>
        {/* Invitations */}
        <div className="mb-12">
          {processedInvitations.length > 0 && (
            <WorkspaceList
              title={`Invitations for ${userEmail}`}
              workspaces={processedInvitations}
              action={acceptInvitation}
              actionText="Accept invite"
              buttonVariant="secondary"
            />
          )}
        </div>
        <SignOutButton redirectUrl="/sign-in">
          <div className="flex flex-col sm:flex-row items-center justify-center mb-12">
            <p className="mr-2 text-lg leading-[1.555] tracking-[-.0012em]">
              Not seeing your workspace?
            </p>
            <button className="text-lg leading-[1.555] tracking-[.012em] text-[#36c5f0] ml-2 flex items-center gap-[9px]">
              <span>Try using a different email</span>
              <svg
                xmlns="http://www.w3.org/2000/svg"
                className="w-[19px] h-[13px]"
                fill="none"
              >
                <path
                  d="M1 6a.5.5 0 0 0 0 1V6zM12.854.646a.5.5 0 0 0-.708.708l.708-.708zM18 6.5l.354.354a.5.5 0 0 0 0-.708L18 6.5zm-5.854 5.146a.5.5 0 0 0 .708.708l-.708-.708zM1 7h16.5V6H1v1zm16.646-.854l-5.5 5.5.708.708 5.5-5.5-.708-.708zm-5.5-4.792l2.75 2.75.708-.708-2.75-2.75-.708.708zm2.75 2.75l2.75 2.75.708-.708-2.75-2.75-.708.708z"
                  fill="#36c5f0"
                />
              </svg>
            </button>
          </div>
        </SignOutButton>
      </section>
    </div>
  );
}

Here's what each part of the code does:

  • User Information: The function starts by retrieving the current user's information using Clerk.

  • Workspace Data: It queries the database to get all the workspaces the user belongs to and any pending invitations.

  • Functions for Actions: There are three main functions defined here:

    • acceptInvitation(): Accepts an invitation and redirects the user to the appropriate workspace.

    • launchChat(): Launches the selected workspace's chat by redirecting to the correct URL.

    • goToGetStartedPage(): Redirects to the "Get Started" page to create a new workspace.

  • Finally, we return the Navbar, WorkspaceList, and Clerk’s SignOutButton button to present a welcoming interface.

Creating a Workspace

Building the Create Workspace API

To allow users to create a new workspace, we need to build an API that will handle the creation process and a user interface where they can provide the necessary details.

Create a /api/workspaces/create directory, then add a route.ts file with the following code:

import { NextResponse } from 'next/server';
import { auth, currentUser } from '@clerk/nextjs/server';

import prisma from '@/lib/prisma';
import {
  generateChannelId,
  generateToken,
  generateWorkspaceId,
  isEmail,
} from '@/lib/utils';

export async function POST(request: Request) {
  const { userId } = await auth();

  if (!userId) {
    return NextResponse.json(
      { error: 'Authentication required' },
      { status: 401 }
    );
  }

  try {
    const user = await currentUser();
    const userEmail = user?.primaryEmailAddress?.emailAddress;

    const body = await request.json();
    const { workspaceName, channelName, emails, imageUrl } = body;

    // Validate input
    if (
      !workspaceName ||
      !channelName ||
      !Array.isArray(emails) ||
      emails.length === 0
    ) {
      return NextResponse.json(
        { error: 'Invalid input data' },
        { status: 400 }
      );
    }

    // Validate emails
    for (const email of emails) {
      if (!isEmail(email)) {
        return NextResponse.json(
          { error: `Invalid email address: ${email}` },
          { status: 400 }
        );
      }
    }

    // Create workspace
    const workspace = await prisma.workspace.create({
      data: {
        id: generateWorkspaceId(),
        name: workspaceName,
        image: imageUrl || null,
        ownerId: userId,
      },
    });

    // Create initial channel
    const channel = await prisma.channel.create({
      data: {
        id: generateChannelId(),
        name: channelName,
        workspaceId: workspace.id,
      },
    });

    // Add authenticated user as admin
    await prisma.membership.create({
      data: {
        userId: userId,
        email: userEmail!,
        workspace: {
          connect: { id: workspace.id },
        },
        role: 'admin',
      },
    });

    // Invite provided emails
    const invitations = [];
    const skippedEmails = [];
    const errors = [];

    for (const email of emails) {
      try {
        // Check if an invitation already exists
        const existingInvitation = await prisma.invitation.findFirst({
          where: {
            email,
            workspaceId: workspace.id,
            acceptedAt: null,
          },
        });

        // check if the user is already a member
        const existingMembership = await prisma.membership.findFirst({
          where: {
            email,
            workspaceId: workspace.id,
          },
        });

        if (existingInvitation) {
          skippedEmails.push(email);
          continue;
        }

        if (existingMembership) {
          skippedEmails.push(email);
          continue;
        }

        if (email === userEmail) {
          skippedEmails.push(email);
          continue;
        }

        // Generate token
        const token = generateToken();

        // Create invitation
        const invitation = await prisma.invitation.create({
          data: {
            email,
            token,
            workspaceId: workspace.id,
            invitedById: userId,
          },
        });

        invitations.push(invitation);
      } catch (error) {
        console.error(`Error inviting ${email}:`, error);
        errors.push({ email, error });
      }
    }

    // Return response
    const response = {
      message: 'Workspace created successfully',
      workspace: {
        id: workspace.id,
        name: workspace.name,
      },
      channel: {
        id: channel.id,
        name: channelName,
      },
      invitationsSent: invitations.length,
      invitationsSkipped: skippedEmails.length,
      errors,
    };

    if (errors.length > 0) {
      return NextResponse.json(response, { status: 207 });
    } else {
      return NextResponse.json(response, { status: 200 });
    }
  } catch (error) {
    console.error('Error creating workspace:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  } finally {
    await prisma.$disconnect();
  }
}

Here’s what’s going on in this API:

  • Authentication: It first checks if the user is logged in. Only logged-in users can create a workspace.

  • Input Validation: It ensures the provided information, like the workspace name, channel name, and email list, is correct.

  • Creating a Workspace and Channel: The API then creates a new workspace in the database and sets up the first channel for the workspace.

  • Adding Admin: We add the user who creates the workspace as an admin of that workspace.

  • Sending Invitations: It sends invitations to the provided email addresses while skipping any that are already invited, already members, or are not valid.

Finally, the API returns a response with details about the new workspace, channel, and how many invitations were successfully sent or skipped.

Building the Workspace Setup Page

Next, let's create a page where users can fill out the information needed to set up a new workspace. This page will be the user interface for interacting with our API.

Create a get-started directory inside /app, then create a page.tsx file in it and add the following code:

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

import { isUrl } from '@/lib/utils';
import ArrowDropdown from '@/components/icons/ArrowDropdown';
import Avatar from '@/components/Avatar';
import Button from '@/components/Button';
import Hash from '@/components/icons/Hash';
import Home from '@/components/icons/Home';
import MoreHoriz from '@/components/icons/MoreHoriz';
import RailButton from '@/components/RailButton';
import SidebarButton from '@/components/SidebarButton';
import Tags from '@/components/Tags';
import TextField from '@/components/TextField';

const pattern = `(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|png|svg))`;

const GetStarted = () => {
  const router = useRouter();
  const [workspaceName, setWorkspaceName] = useState('');
  const [channelName, setChannelName] = useState('');
  const [emails, setEmails] = useState<string[]>([]);
  const [imageUrl, setImageUrl] = useState('');
  const [loading, setLoading] = useState(false);

  const allFieldsValid = Boolean(
    workspaceName &&
      channelName &&
      (!imageUrl || (isUrl(imageUrl) && RegExp(pattern).test(imageUrl))) &&
      emails.length > 0
  );

  const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    if (allFieldsValid) {
      e.stopPropagation();

      try {
        setLoading(true);
        const response = await fetch('/api/workspaces/create', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            workspaceName: workspaceName.trim(),
            channelName: channelName.trim(),
            emails,
            imageUrl,
          }),
        });

        const result = await response.json();

        if (response.ok) {
          alert('Workspace created successfully!');
          const { workspace, channel } = result;
          router.push(`/client/${workspace.id}/${channel.id}`);
        } else {
          alert(`Error: ${result.error}`);
        }
      } catch (error) {
        console.error('Error creating workspace:', error);
        alert('An unexpected error occurred.');
      } finally {
        setLoading(false);
      }
    }
  };

  return (
    <div className="client font-lato w-screen h-screen flex flex-col">
      <div className="absolute w-full h-full bg-theme-gradient" />
      <div className="relative w-full h-10 flex items-center justify-between pr-1"></div>
      <div className="w-screen h-[calc(100svh-40px)] grid grid-cols-[70px_auto]">
        <div className="hidden relative w-[4.375rem] sm:flex flex-col items-center overflow-hidden gap-3 pt-2 z-[1000] bg-transparent">
          <div className="w-9 h-9 mb-[5px]">
            <Avatar
              width={36}
              borderRadius={8}
              fontSize={20}
              fontWeight={600}
              data={{ name: workspaceName, image: imageUrl }}
            />
          </div>
          <div className="relative flex flex-col items-center w-[3.25rem]">
            <div className="relative">
              <RailButton
                title="Home"
                icon={<Home color="var(--primary)" filled />}
                active
              />
              <div className="absolute w-full h-full top-0 left-0" />
            </div>
            <div className="relative opacity-30">
              <RailButton
                title="More"
                icon={<MoreHoriz color="var(--primary)" />}
              />
              <div className="absolute w-full h-full top-0 left-0" />
            </div>
          </div>
        </div>
        <div className="relative w-svw h-full sm:h-auto sm:w-auto flex mr-1 mb-1 rounded-md overflow-hidden border border-solid border-[#797c814d]">
          <div className="hidden w-[275px] relative px-2 sm:flex flex-col flex-shrink-0 gap-3 min-w-0 min-h-0 max-h-[calc(100svh-44px)] bg-[#10121499] border-r-[1px] border-solid border-r-[#797c814d]">
            <div className="pl-1 w-full h-[49px] flex items-center justify-between">
              <div className="max-w-[calc(100%-80px)]">
                <div className="w-fit max-w-full rounded-md py-[3px] px-2 flex items-center text-white hover:bg-hover-gray">
                  <span className="truncate text-[18px] font-[900] leading-[1.33334]">
                    {workspaceName}
                  </span>
                </div>
              </div>
            </div>
            {channelName && (
              <div className="w-full flex flex-col">
                <div className="h-7 -ml-1.5 flex items-center px-4 text-[15px] leading-7">
                  <button className="hover:bg-hover-gray rounded-md">
                    <ArrowDropdown color="var(--icon-gray)" />
                  </button>
                  <button className="flex px-[5px] max-w-full rounded-md text-sidebar-gray font-medium hover:bg-hover-gray">
                    Channels
                  </button>
                </div>
                <SidebarButton icon={Hash} title={channelName} />
              </div>
            )}
            <div className="absolute w-full h-full top-0 left-0" />
          </div>
          <div className="bg-[#1a1d21] grow p-16 flex flex-col">
            <div className="max-w-[705px] flex flex-col gap-8">
              <h2 className="max-w-[632px] font-sans font-bold mb-2 text-[45px] leading-[46px] text-white">
                Create a new workspace
              </h2>
              <form onSubmit={onSubmit} action={() => {}} className="contents">
                <TextField
                  label="Workspace name"
                  name="workspaceName"
                  value={workspaceName}
                  onChange={(e) => setWorkspaceName(e.target.value)}
                  placeholder="Enter a name for your workspace"
                  required
                />
                <TextField
                  label={
                    <span>
                      Workspace image{' '}
                      <span className="text-[#9a9b9e] ml-0.5">(optional)</span>
                    </span>
                  }
                  name="workspaceImage"
                  type="url"
                  value={imageUrl}
                  onChange={(e) => setImageUrl(e.target.value)}
                  placeholder="Paste an image URL"
                  pattern={`(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|png|svg))`}
                  title='Image URL must start with "http://" or "https://" and end with ".png", ".jpg", ".jpeg", ".gif", or ".svg"'
                />
                <TextField
                  label="Channel name"
                  name="channelName"
                  value={channelName}
                  onChange={(e) =>
                    setChannelName(
                      e.target.value.toLowerCase().replace(/\s/g, '-')
                    )
                  }
                  placeholder="Enter a name for your first channel"
                  maxLength={80}
                  required
                />
                <Button
                  type="submit"
                  disabled={emails.length === 0}
                  className="w-fit order-5 capitalize py-2 hover:bg-[#592a5a] hover:border-[#592a5a]"
                  loading={loading}
                >
                  Submit
                </Button>
              </form>
              <Tags
                values={emails}
                setValues={setEmails}
                label="Invite members"
                placeholder="Enter email addresses"
              />
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

export default GetStarted;

In the code above:

  • The page allows users to provide details for creating a new workspace, including:

    • Workspace Name: The name of the workspace.

    • Channel Name: The name of the first channel to be created within the workspace.

    • Emails of Members to Invite: Users can enter a list of email addresses to invite members to the workspace.

    • Workspace Image (Optional): Users can optionally provide an image URL to represent the workspace.

  • The form uses the useState hook to store the values the user enters and uses allFieldsValid to ensure all required fields are filled in correctly.

  • When the form is submitted, it makes a POST request to the /api/workspaces/create route, passing along the workspace details. If the request is successful, we redirect the user to the new workspace.

  • The interface provides visual feedback while the request is processed, such as showing a loading state or error messages if something goes wrong.

Creating Your First Workspace

Now that you have finished building the API and setup page, it's time to create your first workspace to ensure everything works as expected. Follow these steps:

  1. Go to the Setup Page: Navigate to your app's /get-started page.

  2. Fill in the Necessary Details: Enter the workspace name, channel name, and email addresses of the members you want to invite.

  3. Add an Image (Optional): If you wish, add an image URL to make the workspace look more personal.

  4. Submit the Form: Click the "Submit" button to create the workspace.

  5. Verify the Creation: Ensure the workspace is created successfully and all invited members receive invitations.

  6. Check the Dashboard: Verify that the new workspace is listed correctly on your dashboard and that the initial channel is visible.

By following these steps, you can confirm that the workspace creation flow functions correctly.

Setting Up Stream In Your Application

What is Stream?

Stream is a platform that allows developers to add rich chat and video features to their applications. Instead of dealing with the complexity of creating chat and video from the ground up, Stream provides APIs and SDKs to help you add them quickly and easily.

In this project, we'll use Stream's React SDK for Video and React Chat SDK to build the chat and video calling features in our Slack clone.

Creating your Stream Account

To start using Stream, you'll need to create an account:

  1. Sign Up: Go to the Stream sign-up page and create an account using your email or a social login.

  2. Complete Your Profile:

    • After signing up, you'll be asked for additional information, such as your role and industry.

    • Select the "Chat Messaging" and "Video and Audio" options since we need these tools for our app.

      Strem sign up options

    • Click "Complete Signup" to continue.

You will now be redirected to your Stream dashboard.

Creating a New Stream Project

After creating your Stream account, the next step is to set up an app for your project:

  1. Create a New App: In your Stream dashboard, click the "Create App" button.

  2. Configure Your App:

    • App Name: Enter a name like "Slack Clone" or any other name you choose.

    • Region: Pick the region nearest to you for the best performance.

    • Environment: Keep it set to "Development".

    • Click the "Create App" to finish.

  3. Get Your API Keys: After creating the app, navigate to the "App Access Keys" section. You’ll need these keys to connect Stream to your project.

Configuring User Permissions

To allow users to send messages, read channels, and perform other actions, you need to set up the necessary permissions in the Stream dashboard:

  1. Navigate to the "Roles & Permissions" tab under "Chat messaging."

  2. Select the "user" role and choose the "messaging" scope.

  3. Click the “Edit” button and select the following permissions:

    • Create Message

    • Read Channel

    • Read Channel Members

    • Create Reaction

    • Upload Attachments

    • Create Attachments

  4. Save and confirm the changes.

Installing Stream SDKs

To start using Stream in our Next.js project, we need to install a few SDKs:

  1. Install the SDKs: Run the following command to install the necessary packages:

     npm install @stream-io/node-sdk @stream-io/video-react-sdk stream-chat-react stream-chat
    
  2. Set Up Environment Variables: Add your Stream API keys to your .env.local file:

     NEXT_PUBLIC_STREAM_API_KEY=your_stream_api_key
     STREAM_API_SECRET=your_stream_api_secret
    

    Replace your_stream_api_key and your_stream_api_secret with the keys from your Stream dashboard.

  3. Import the Stylesheets: Stream SDKs have ready-made stylesheets for their chat and video components. Import these styles into your app/layout.tsx file:

     // app/layout.tsx
     ... 
     import '@stream-io/video-react-sdk/dist/css/styles.css';
     import 'stream-chat-react/dist/css/v2/index.css';
     import './globals.css';
     ...
    

Syncing Clerk with Your Stream App

To make sure user data is consistent between Clerk and Stream, you need to set up a webhook that syncs user information:

  1. Set Up ngrok: Since webhooks require a publicly accessible URL, we'll use ngrok to expose our local server. Follow the steps below to set up an ngrok tunnel for your app:

  2. Create a Webhook Endpoint in Clerk:

    • Navigate to Webhooks: In your Clerk dashboard, navigate to the “Configure” tab and select "Webhooks.”

    • Add a New Endpoint:

      • Click "Add Endpoint" and enter your ngrok URL, followed by /api/webhooks (e.g., https://your-subdomain.ngrok.io/api/webhooks).

      • Under “Subscribe to events”, select user.created and user.updated.

      • Click "Create".

    • Get the Signing Secret: Copy the signing secret provided and add it to your .env.local file:

        WEBHOOK_SECRET=your_clerk_webhook_signing_secret
      

      Replace your_clerk_webhook_signing_secret with the signing secret from the webhooks page.

  3. Install Svix: We need Svix to verify and handle incoming webhooks. Run the following command to install the package:

     npm install svix
    
  4. Create the Webhook Endpoint in Your App: Next, we need to create a route to receive the webhook's payload. Create a /app/api/webhooks directory and add a route.ts file with the following code:

     import { Webhook } from 'svix';
     import { headers } from 'next/headers';
     import { WebhookEvent } from '@clerk/nextjs/server';
     import { StreamClient } from '@stream-io/node-sdk';
    
     const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!;
     const SECRET = process.env.STREAM_API_SECRET!;
     const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
    
     export async function POST(req: Request) {
       const client = new StreamClient(API_KEY, SECRET);
    
       if (!WEBHOOK_SECRET) {
         throw new Error(
           'Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local'
         );
       }
    
       // Get the headers
       const headerPayload = headers();
       const svix_id = headerPayload.get('svix-id');
       const svix_timestamp = headerPayload.get('svix-timestamp');
       const svix_signature = headerPayload.get('svix-signature');
    
       // If there are no headers, error out
       if (!svix_id || !svix_timestamp || !svix_signature) {
         return new Response('Error occured -- no svix headers', {
           status: 400,
         });
       }
    
       // Get the body
       const payload = await req.json();
       const body = JSON.stringify(payload);
    
       // Create a new Svix instance with your secret.
       const wh = new Webhook(WEBHOOK_SECRET);
    
       let evt: WebhookEvent;
    
       // Verify the payload with the headers
       try {
         evt = wh.verify(body, {
           'svix-id': svix_id,
           'svix-timestamp': svix_timestamp,
           'svix-signature': svix_signature,
         }) as WebhookEvent;
       } catch (err) {
         console.error('Error verifying webhook:', err);
         return new Response('Error occured', {
           status: 400,
         });
       }
    
       const eventType = evt.type;
    
       switch (eventType) {
         case 'user.created':
         case 'user.updated':
           const newUser = evt.data;
           await client.upsertUsers([
             {
               id: newUser.id,
               role: 'user',
               name: `${newUser.first_name} ${newUser.last_name}`,
               custom: {
                 username: newUser.username,
                 email: newUser.email_addresses[0].email_address,
               },
               image: newUser.has_image ? newUser.image_url : undefined,
             },
           ]);
           break;
         default:
           break;
       }
    
       return new Response('Webhook processed', { status: 200 });
     }
    

    In the webhook handler:

    • We use Svix's Webhook class to verify incoming requests. If the request is valid, we sync the user data with Stream using the upsertUsers method for user.created and user.updated events.

    • For user.created and user.updated events, we sync the user data with Stream using upsertUsers.

  5. Make the Webhook Endpoint Public: Finally, we need to add the webhook endpoint to the public routes in the middleware configuration to ensure Clerk can access it from “outside”. Navigate to your middleware.ts file, and add the following:

     import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
    
     const isPublicRoute = createRouteMatcher([
       ...
       '/api/webhooks(.*)',
     ]);
     ...
    

After completing these steps, Stream will be successfully set up in your application.

Building the Workspace Hub

The workspace hub is the central part of our Slack clone, where users can chat, make video calls, and manage their workspaces. It combines all the essential features—like how Slack organizes communication channels and tools—making it easy for users to communicate and work together.

Creating the Layout

We need a layout that will be the foundation for all the activities in the workspace. This layout will bring together parts like the sidebar, chat area, and huddle.

Create a client folder in the app directory, and add a layout.tsx file with the following code:

'use client';
import { createContext, ReactNode, useEffect, useState } from 'react';
import {
  Channel,
  Invitation,
  Membership,
  Workspace as PrismaWorkspace,
} from '@prisma/client';
import { UserButton, useUser } from '@clerk/nextjs';
import { StreamChat } from 'stream-chat';
import { Chat } from 'stream-chat-react';
import {
  Call,
  StreamVideo,
  StreamVideoClient,
} from '@stream-io/video-react-sdk';

import ArrowBack from '@/components/icons/ArrowBack';
import ArrowForward from '@/components/icons/ArrowForward';
import Avatar from '@/components/Avatar';
import Bookmark from '@/components/icons/Bookmark';
import Clock from '@/components/icons/Clock';
import IconButton from '@/components/IconButton';
import Help from '@/components/icons/Help';
import Home from '@/components/icons/Home';
import Plus from '@/components/icons/Plus';
import Messages from '@/components/icons/Messages';
import MoreHoriz from '@/components/icons/MoreHoriz';
import Notifications from '@/components/icons/Notifications';
import RailButton from '@/components/RailButton';
import SearchBar from '@/components/SearchBar';
import WorkspaceLayout from '@/components/WorkspaceLayout';
import WorkspaceSwitcher from '@/components/WorkspaceSwitcher';

interface LayoutProps {
  children?: ReactNode;
  params: Promise<{ workspaceId: string }>;
}

export type Workspace = PrismaWorkspace & {
  channels: Channel[];
  memberships: Membership[];
  invitations: Invitation[];
};

export const AppContext = createContext<{
  workspace: Workspace;
  setWorkspace: (workspace: Workspace) => void;
  otherWorkspaces: Workspace[];
  setOtherWorkspaces: (workspaces: Workspace[]) => void;
  channel: Channel;
  setChannel: (channel: Channel) => void;
  loading: boolean;
  setLoading: (loading: boolean) => void;
  chatClient: StreamChat;
  setChatClient: (chatClient: StreamChat) => void;
  videoClient: StreamVideoClient;
  setVideoClient: (videoClient: StreamVideoClient) => void;
  channelCall: Call | undefined;
  setChannelCall: (call: Call) => void;
}>({
  workspace: {} as Workspace,
  setWorkspace: () => {},
  otherWorkspaces: [],
  setOtherWorkspaces: () => {},
  channel: {} as Channel,
  setChannel: () => {},
  loading: false,
  setLoading: () => {},
  chatClient: {} as StreamChat,
  setChatClient: () => {},
  videoClient: {} as StreamVideoClient,
  setVideoClient: () => {},
  channelCall: undefined,
  setChannelCall: () => {},
});

const tokenProvider = async (userId: string) => {
  const response = await fetch('/api/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ userId: userId }),
  });
  const data = await response.json();
  return data.token;
};

const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY as string;

const Layout = ({ children }: LayoutProps) => {
  const { user } = useUser();
  const [loading, setLoading] = useState(true);
  const [workspace, setWorkspace] = useState<Workspace>();
  const [channel, setChannel] = useState<Channel>();
  const [otherWorkspaces, setOtherWorkspaces] = useState<Workspace[]>([]);
  const [chatClient, setChatClient] = useState<StreamChat>();
  const [videoClient, setVideoClient] = useState<StreamVideoClient>();
  const [channelCall, setChannelCall] = useState<Call>();

  useEffect(() => {
    const customProvider = async () => {
      const token = await tokenProvider(user!.id);
      return token;
    };

    const setUpChatAndVideo = async () => {
      const chatClient = StreamChat.getInstance(API_KEY);
      const clerkUser = user!;
      const chatUser = {
        id: clerkUser.id,
        name: clerkUser.fullName!,
        image: clerkUser.imageUrl,
        custom: {
          username: user?.username,
        },
      };

      if (!chatClient.user) {
        await chatClient.connectUser(chatUser, customProvider);
      }

      setChatClient(chatClient);
      const videoClient = StreamVideoClient.getOrCreateInstance({
        apiKey: API_KEY,
        user: chatUser,
        tokenProvider: customProvider,
      });
      setVideoClient(videoClient);
    };

    if (user) setUpChatAndVideo();
  }, [user, videoClient, chatClient]);

  if (!chatClient || !videoClient || !user)
    return (
      <div className="client font-lato w-screen h-screen flex flex-col">
        <div className="absolute w-full h-full bg-theme-gradient" />
      </div>
    );

  return (
    <AppContext.Provider
      value={{
        workspace: workspace!,
        setWorkspace,
        otherWorkspaces,
        setOtherWorkspaces,
        channel: channel!,
        setChannel,
        loading,
        setLoading,
        chatClient,
        setChatClient,
        videoClient,
        setVideoClient,
        channelCall,
        setChannelCall,
      }}
    >
      <Chat client={chatClient}>
        <StreamVideo client={videoClient}>
          <div className="client font-lato w-screen h-screen flex flex-col">
            <div className="absolute w-full h-full bg-theme-gradient" />
            {/* Toolbar */}
            <div className="relative w-full h-10 flex items-center justify-between pr-1">
              <div className="w-[4.375rem] h-10 mr-auto flex-none" />
              {!loading && (
                <div className="flex flex-auto items-center">
                  <div className="relative hidden sm:flex flex-none basis-[24%]">
                    <div className="flex justify-start basis-full" />
                    <div className="flex justify-end basis-full mr-3">
                      <div className="flex gap-1 items-center">
                        <IconButton
                          icon={<ArrowBack color="var(--primary)" />}
                          disabled
                        />
                        <IconButton
                          icon={<ArrowForward color="var(--primary)" />}
                          disabled
                        />
                      </div>
                      <div className="flex items-center ml-1">
                        <IconButton icon={<Clock color="var(--primary)" />} />
                      </div>
                    </div>
                  </div>
                  <SearchBar placeholder={`Search ${workspace?.name}`} />
                  <div className="hidden sm:flex flex-[1_0_auto] items-center justify-end mr-1">
                    <IconButton icon={<Help color="var(--primary)" />} />
                  </div>
                </div>
              )}
            </div>
            {/* Main */}
            <div className="w-screen h-[calc(100svh-40px)] grid grid-cols-[70px_auto]">
              {/* Rail */}
              <div className="relative w-[4.375rem] flex flex-col items-center gap-3 pt-2 z-[1000] bg-transparent">
                {!loading && (
                  <>
                    <WorkspaceSwitcher />
                    <div className="relative flex flex-col items-center w-[3.25rem]">
                      <RailButton
                        title="Home"
                        icon={<Home color="var(--primary)" filled />}
                        active
                      />
                      <RailButton
                        title="DMs"
                        icon={<Messages color="var(--primary)" />}
                      />
                      <RailButton
                        title="Activity"
                        icon={<Notifications color="var(--primary)" />}
                      />
                      <RailButton
                        title="Later"
                        icon={<Bookmark color="var(--primary)" />}
                      />
                      <RailButton
                        title="More"
                        icon={<MoreHoriz color="var(--primary)" />}
                      />
                    </div>
                    <div className="flex flex-col items-center gap-4 mt-auto pb-6 w-full">
                      <div className="cursor-pointer flex items-center justify-center w-9 h-9 rounded-full bg-[#565759]">
                        <Plus color="var(--primary)" />
                      </div>
                      <div className="relative h-9 w-9">
                        <UserButton />
                        <div className="absolute left-0 top-0 flex items-center justify-center pointer-events-none">
                          <div className="relative w-full h-full">
                            <Avatar
                              width={36}
                              borderRadius={8}
                              fontSize={20}
                              fontWeight={700}
                              data={{
                                name: user.fullName!,
                                image: user.imageUrl,
                              }}
                            />
                            <span className="absolute w-3.5 h-3.5 rounded-full flex items-center justify-center -bottom-[3px] -right-[3px] bg-[#111215]">
                              <div className="w-[8.5px] h-[8.5px] rounded-full bg-[#3daa7c]" />
                            </span>
                          </div>
                        </div>
                      </div>
                    </div>
                  </>
                )}
              </div>
              <WorkspaceLayout>{children}</WorkspaceLayout>
            </div>
          </div>
        </StreamVideo>
      </Chat>
    </AppContext.Provider>
  );
};

export default Layout;

A lot is going on here, so let’s break things down:

  • Context Management: The AppContext stores shared information within the entire app, like the current workspace, channels, chat client, video client, and more.

  • Setting Up Chat and Video Clients: Within the useEffect, we have a setUpChatAndVideo function that sets up the chat and video clients from Stream. It connects the user to the chat client and sets up the video client for calls.

  • Token Provider: The tokenProvider function asks for a token from our /api/token endpoint. This token is needed for Stream's services to know who the user is.

  • Main Components: The layout is split into different main parts:

    • Toolbar: The toolbar has navigation buttons, a search bar, and a help button.

    • Rail: This is a vertical section with buttons like "Home," "DMs," "Activity," and more.

    • WorkspaceSwitcher: This part lets users switch between workspaces.

    • WorkspaceLayout: The WorkspaceLayout contains the sidebar and the main channel content.

Adding a Token API Route

In the last section, we added a token provider that sends a request to /api/token to get Stream user tokens. Next, we'll create the API route that will handle this request.

Create a /app/api/token directory, then add a route.ts file with the following:

import { StreamClient } from '@stream-io/node-sdk';

const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!;
const SECRET = process.env.STREAM_API_SECRET!;

export async function POST(request: Request) {
  const client = new StreamClient(API_KEY, SECRET);

  const body = await request.json();

  const userId = body?.userId;

  if (!userId) {
    return Response.error();
  }

  const token = client.generateUserToken({ user_id: userId });

  const response = {
    userId: userId,
    token: token,
  };

  return Response.json(response);
}

In the code above, we use Stream's Node SDK to create a token for a user based on their userId. This token will authenticate users for Stream's chat and video features.

Workspace Switcher Component

Next, let’s create the WorkspaceSwitcher component we added to our layout in the previous section.

Create a WorkspaceSwitcher.tsx file in the components directory and add the following code:

import { MutableRefObject, useContext, useState } from 'react';
import { useRouter } from 'next/navigation';
import clsx from 'clsx';

import { AppContext, Workspace } from '@/app/client/layout';
import Avatar from './Avatar';
import Plus from './icons/Plus';
import useClickOutside from '@/hooks/useClickOutside';

const WorkspaceSwitcher = () => {
  const router = useRouter();
  const [open, setOpen] = useState(false);
  const {
    workspace,
    setWorkspace,
    otherWorkspaces,
    setOtherWorkspaces,
    setChannel,
  } = useContext(AppContext);

  const domNode = useClickOutside(() => {
    setOpen(false);
  }, true) as MutableRefObject<HTMLDivElement>;

  const switchWorkspace = (otherWorkspace: Workspace) => {
    setOtherWorkspaces([
      ...otherWorkspaces.filter((w) => w.id !== otherWorkspace.id),
      workspace,
    ]);
    setWorkspace(otherWorkspace);
    setChannel(otherWorkspace.channels[0]);
    router.push(
      `/client/${otherWorkspace.id}/${otherWorkspace.channels[0].id}`
    );
  };

  return (
    <div
      onClick={() => setOpen((prev) => !prev)}
      className="relative w-9 h-9 mb-[5px] cursor-pointer"
    >
      <Avatar
        width={36}
        borderRadius={8}
        fontSize={20}
        fontWeight={700}
        data={{ name: workspace.name, image: workspace.image }}
      />
      <div
        ref={domNode}
        className={clsx(
          'z-[99] absolute top-11 -left-3 flex-col items-start text-channel-gray text-left w-[360px] rounded-xl overflow-hidden bg-[#212428] border border-[#797c8126] py-1',
          open ? 'flex' : 'hidden'
        )}
      >
        <div className="w-full px-4 py-2 text-[15px] leading-7 hover:bg-[#36383b]">
          <div className="leading-[22px] font-bold truncate">
            {workspace.name}
          </div>
          <div className="text-[13px] leading-[18px]">
            {workspace.name.replace(/\s/g, '').toLowerCase()}.slack.com
          </div>
        </div>
        <div className="w-full h-[1px] my-2 bg-[#797c8126]" />
        <div className="flex flex-col text-[12.8px] leading-[1.38463] m-[4px_12px_4px_16px]">
          <span className="font-bold">Never miss a notification</span>
          <div>
            <span className="cursor-pointer text-[#1D9BD1] hover:underline">
              Get the Slack app
            </span>{' '}
            to see notifications from your other workspaces
          </div>
        </div>
        <div className="w-full h-[1px] my-2 bg-[#797c8126]" />
        {otherWorkspaces.map((otherWorkspace) => (
          <button
            key={otherWorkspace.id}
            className="px-4 flex items-center w-full h-[52px] hover:bg-[#37393d] gap-3 text-[14.8px]"
            onClick={() => switchWorkspace(otherWorkspace)}
          >
            <Avatar
              width={36}
              borderRadius={8}
              fontSize={20}
              fontWeight={700}
              data={{ name: otherWorkspace.name, image: otherWorkspace.image }}
            />
            <div className="flex flex-col text-left">
              <div className="leading-[22px] font-bold truncate">
                {otherWorkspace.name}
              </div>
              <div className="text-[13px] leading-[18px]">
                {otherWorkspace.name.replace(/\s/g, '').toLowerCase()}.slack.com
              </div>
            </div>
          </button>
        ))}
        <button
          className="px-4 flex items-center w-full h-[52px] hover:bg-[#37393d] gap-3 text-[14.8px]"
          onClick={() => router.push(`/get-started`)}
        >
          <div className="w-9 h-9 flex items-center justify-center rounded-lg bg-[#f8f8f80f]">
            <Plus color="var(--primary)" filled />
          </div>
          <div className="flex flex-col text-left text-white">
            Add a workspace
          </div>
        </button>
      </div>
    </div>
  );
};

export default WorkspaceSwitcher;

In the WorkspaceSwitcher component, we have a dropdown when the user clicks the workspace button. This dropdown lets users easily switch between workspaces or add a new one.

  • Switching Workspaces: The switchWorkspace function updates the current workspace and channel, and then navigates the user to the new workspace's main page.

  • Click Outside to Close: The useClickOutside hook is used to close the workspace switcher dropdown when the user clicks anywhere outside of it.

  • Add a Workspace: The button at the bottom lets users create a new workspace, directing them to the setup page.

Building a Workspace Layout Component

Next, we'll create the WorkspaceLayout component that we added in the previous section, similar to how we created the WorkspaceSwitcher component.

Create a WorkspaceLayout.tsx file in the components directory and add the following code:

'use client';
import { ReactNode, useContext, useEffect, useRef, useState } from 'react';
import clsx from 'clsx';

import { AppContext } from '../app/client/layout';
import Sidebar from './Sidebar';

interface WorkspaceLayoutProps {
  children: ReactNode;
}

const WorkspaceLayout = ({ children }: WorkspaceLayoutProps) => {
  const { loading } = useContext(AppContext);
  const layoutRef = useRef<HTMLDivElement>(null);
  const [layoutWidth, setLayoutWidth] = useState(0);

  useEffect(() => {
    if (!layoutRef.current) {
      return;
    }

    const resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) {
        setLayoutWidth(entry.contentRect.width);
      }
    });

    resizeObserver.observe(layoutRef.current);

    return () => {
      resizeObserver.disconnect();
    };
  }, [layoutRef]);

  return (
    <div
      ref={layoutRef}
      className={clsx(
        'relative flex mr-1 mb-1 rounded-md overflow-hidden border border-solid',
        loading ? 'border-transparent' : 'border-[#797c814d]'
      )}
    >
      {/* Sidebar */}
      <Sidebar layoutWidth={layoutWidth} />
      {layoutWidth > 0 && <div className="bg-[#1a1d21] grow">{children}</div>}
    </div>
  );
};

export default WorkspaceLayout;

The WorkspaceLayout component provides a consistent structure for the entire workspace. It includes:

  • Sidebar Integration: The Sidebar is included in this layout to give users easy access to all workspace channels.

  • Layout Width: The component uses a ResizeObserver to get the current width of the layout and ensure the sidebar can be resized appropriately.

Adding a Channel Preview Component

Next, we'll create the ChannelPreview component, which shows a preview of each channel in the workspace.

Create a ChannelPreview.tsx file in the components directory and add the following code:

import { useContext } from 'react';
import { ChannelPreviewUIComponentProps } from 'stream-chat-react';
import { usePathname, useRouter } from 'next/navigation';

import { AppContext } from '../app/client/layout';
import Hash from './icons/Hash';
import SidebarButton from './SidebarButton';

const ChannelPreview = ({
  channel,
  displayTitle,
  unread,
}: ChannelPreviewUIComponentProps) => {
  const pathname = usePathname();
  const router = useRouter();
  const { workspace, setChannel } = useContext(AppContext);

  const goToChannel = () => {
    const channelId = channel.id;
    setChannel(workspace.channels.find((c) => c.id === channelId)!);
    router.push(`/client/${workspace.id}/${channelId}`);
  };

  const channelActive = () => {
    const pathChannelId = pathname.split('/').filter(Boolean).pop();
    return pathChannelId === channel.id;
  };

  return (
    <SidebarButton
      icon={Hash}
      title={displayTitle}
      onClick={goToChannel}
      active={channelActive()}
      boldText={Boolean(unread)}
    />
  );
};

export default ChannelPreview;

In the code above:

  • Channel Preview: The ChannelPreview component shows each channel in the sidebar. Users can click on a channel to open it using the goToChannel function, which navigates to the selected channel.

  • Bold Text for Unread Messages: If there are unread messages in a channel, the channel name is shown in bold text, making it easy for users to see which channels need attention.

  • Active Channel Highlight: The channelActive function checks if the current channel is active and highlights it in the sidebar so that users know which channel they are currently in.

Adding a Sidebar

The primary function of the Sidebar component is to give users quick access to channels.

Create a Sidebar.tsx file in the components directory and add the following code:

'use client';
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useUser } from '@clerk/nextjs';
import { ChannelList } from 'stream-chat-react';
import clsx from 'clsx';

import { AppContext } from '../app/client/layout';
import ArrowDropdown from './icons/ArrowDropdown';
import CaretDown from './icons/CaretDown';
import ChannelPreview from './ChannelPreview';
import Compose from './icons/Compose';
import IconButton from './IconButton';
import Refine from './icons/Refine';
import Send from './icons/Send';
import SidebarButton from './SidebarButton';
import Threads from './icons/Threads';

const [minWidth, defaultWidth] = [215, 275];

type SidebarProps = {
  layoutWidth: number;
};

const Sidebar = ({ layoutWidth }: SidebarProps) => {
  const { user } = useUser();
  const { loading, workspace } = useContext(AppContext);

  const [width, setWidth] = useState<number>(() => {
    const savedWidth =
      parseInt(window.localStorage.getItem('sidebarWidth') as string) ||
      defaultWidth;
    window.localStorage.setItem('sidebarWidth', String(savedWidth));
    return savedWidth;
  });
  const maxWidth = useMemo(() => layoutWidth - 374, [layoutWidth]);

  const isDragged = useRef(false);

  useEffect(() => {
    if (!layoutWidth) return;

    const onMouseMove = (e: MouseEvent) => {
      if (!isDragged.current) {
        return;
      }
      document.body.style.userSelect = 'none';
      document.body.style.cursor = 'col-resize';
      document.querySelectorAll('.sidebar-btn').forEach((el) => {
        el.setAttribute('style', 'cursor: col-resize');
      });
      setWidth((previousWidth) => {
        const newWidth = previousWidth + e.movementX / 1.3;
        if (newWidth < minWidth) {
          return minWidth;
        } else if (newWidth > maxWidth) {
          return maxWidth;
        }
        return newWidth;
      });
    };

    const onMouseUp = () => {
      document.body.style.userSelect = 'auto';
      document.body.style.cursor = 'auto';
      document.querySelectorAll('.sidebar-btn').forEach((el) => {
        el.removeAttribute('style');
      });
      isDragged.current = false;
    };

    window.removeEventListener('mousemove', onMouseMove);
    window.addEventListener('mousemove', onMouseMove);
    window.addEventListener('mouseup', onMouseUp);

    return () => {
      window.removeEventListener('mousemove', onMouseMove);
      window.removeEventListener('mouseup', () => onMouseUp);
    };
  }, [layoutWidth, maxWidth]);

  useEffect(() => {
    if (!layoutWidth || layoutWidth < 0) return;

    if (width) {
      let newWidth = width;
      if (width > maxWidth) {
        newWidth = maxWidth;
      }
      setWidth(newWidth);
      localStorage.setItem('sidebarWidth', String(width));
    }
  }, [width, layoutWidth, maxWidth]);

  return (
    <div
      id="sidebar"
      style={{ width: `${width}px` }}
      className={clsx(
        'hidden relative px-2 sm:flex flex-col flex-shrink-0 gap-3 min-w-0 min-h-0 max-h-[calc(100svh-44px)] bg-[#10121499] border-r-[1px] border-solid',
        loading ? 'border-r-transparent' : 'border-r-[#797c814d]'
      )}
    >
      {!loading && (
        <>
          <div className="pl-1 w-full h-[49px] flex items-center justify-between">
            <div className="max-w-[calc(100%-80px)]">
              <button className="w-fit max-w-full rounded-md py-[3px] px-2 flex items-center text-white hover:bg-hover-gray">
                <span className="truncate text-[18px] font-[900] leading-[1.33334]">
                  {workspace.name}
                </span>
                <div className="flex-shrink-0">
                  <CaretDown size={18} color="var(--primary)" />
                </div>
              </button>
            </div>
            <div className="flex ">
              <IconButton
                icon={
                  <Refine className="fill-icon-gray group-hover:fill-white" />
                }
                className="w-9 h-9 hover:bg-hover-gray"
              />
              <IconButton
                icon={
                  <Compose className="fill-icon-gray group-hover:fill-white" />
                }
                className="w-9 h-9 hover:bg-hover-gray"
              />
            </div>
          </div>
          <div className="w-full flex flex-col">
            <SidebarButton icon={Threads} iconSize="lg" title="Threads" />
            <SidebarButton icon={Send} iconSize="lg" title="Drafts & sent" />
          </div>
          <div className="w-full flex flex-col">
            <div className="h-7 -ml-1.5 flex items-center px-4 text-[15px] leading-7">
              <button className="hover:bg-hover-gray rounded-md">
                <ArrowDropdown color="var(--icon-gray)" />
              </button>
              <button className="flex px-[5px] max-w-full rounded-md text-sidebar-gray font-medium hover:bg-hover-gray">
                Channels
              </button>
            </div>
            <ChannelList
              filters={{ workspaceId: workspace.id }}
              Preview={ChannelPreview}
              sort={{
                created_at: 1,
              }}
              LoadingIndicator={() => null}
              lockChannelOrder
            />
          </div>
          {/* Handle */}
          <div
            className="absolute -right-1 w-2 h-full bg-transparent cursor-col-resize"
            onMouseDown={() => {
              isDragged.current = true;
            }}
          />
        </>
      )}
    </div>
  );
};

export default Sidebar;

In the code above:

  • Resizable Sidebar: The Sidebar can be resized by the user, letting them adjust the width however they like.

  • Channel List: The ChannelList from stream-chat-react shows all the channels in the workspace. The list can be filtered and sorted, helping users quickly find the channels they need.

Next, add the following styles to globals.css to modify the default styling of the ChannelList:

...
@layer components {
  #sidebar
    .str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react {
    background: none;
    border: none;
  }

  #sidebar
    .str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react
    > div {
    padding: 0;
  }
}

Creating the Workspace API Route

To fetch workspace data, we need an API route that returns the workspace information.

Create a route.ts file in a /api/workspaces/[workspaceId] directory and add the following code:

import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';

import prisma from '@/lib/prisma';

export async function GET(
  _: Request,
  { params }: { params: Promise<{ workspaceId: string }> }
) {
  const { userId } = await auth();

  if (!userId) {
    return NextResponse.json(
      { error: 'Authentication required' },
      { status: 401 }
    );
  }

  const workspaceId = (await params).workspaceId;

  if (!workspaceId || Array.isArray(workspaceId)) {
    return NextResponse.json(
      { error: 'Invalid workspace ID' },
      { status: 400 }
    );
  }

  try {
    // Check if the user is a member of the workspace
    const membership = await prisma.membership.findUnique({
      where: {
        userId_workspaceId: {
          userId,
          workspaceId,
        },
      },
    });

    if (!membership) {
      return NextResponse.json({ error: 'Access denied' }, { status: 403 });
    }

    // Fetch the workspace along with related data
    const workspace = await prisma.workspace.findUnique({
      where: { id: workspaceId },
      include: {
        channels: true,
        memberships: true,
        invitations: {
          where: { acceptedAt: null },
        },
      },
    });

    if (!workspace) {
      return NextResponse.json(
        { error: 'Workspace not found' },
        { status: 404 }
      );
    }

    // Fetch the other workspaces the user is a member of excluding the current workspace
    const otherWorkspaces = await prisma.workspace.findMany({
      where: {
        memberships: {
          some: {
            userId,
            workspaceId: { not: workspaceId },
          },
        },
      },
      include: {
        channels: true,
        memberships: true,
        invitations: {
          where: { acceptedAt: null },
        },
      },
    });

    return NextResponse.json({ workspace, otherWorkspaces }, { status: 200 });
  } catch (error) {
    console.error('Error fetching workspace:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  } finally {
    await prisma.$disconnect();
  }
}

This route handles GET requests for fetching the workspace data based on the workspace ID provided in the URL:

  • Authentication: The route first checks if the user is authenticated by verifying their session.

  • Membership Validation: It also checks whether the user is a member of the requested workspace before returning the data.

  • Data Retrieval: If the user is authorized, the route retrieves the workspace, channels, and membership data from the database, along with any pending invitations.

Building the Channel Page

The channel page will display the current channel in a specific workspace. It uses several hooks and contexts to ensure all the channel information is loaded and displayed correctly.

Create a /client/[workspaceId]/[channelId]/ directory, and a page.tsx file with add the following code:

'use client';
import { useContext, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useUser } from '@clerk/nextjs';
import { Channel as ChannelType } from 'stream-chat';
import { DefaultStreamChatGenerics } from 'stream-chat-react';
import { StreamCall, useCalls } from '@stream-io/video-react-sdk';
import clsx from 'clsx';

import { AppContext } from '../../layout';
import CaretDown from '@/components/icons/CaretDown';
import Files from '@/components/icons/Files';
import Hash from '@/components/icons/Hash';
import Headphones from '@/components/icons/Headphones';
import Message from '@/components/icons/Message';
import MoreVert from '@/components/icons/MoreVert';
import Pin from '@/components/icons/Pin';
import Plus from '@/components/icons/Plus';
import User from '@/components/icons/User';

interface ChannelProps {
  params: {
    workspaceId: string;
    channelId: string;
  };
}

const Channel = ({ params }: ChannelProps) => {
  const { workspaceId, channelId } = params;
  const router = useRouter();
  const { user } = useUser();
  const [currentCall] = useCalls();
  const {
    chatClient,
    loading,
    setLoading,
    workspace,
    setWorkspace,
    setOtherWorkspaces,
    channel,
    setChannel,
    channelCall,
    setChannelCall,
    videoClient,
  } = useContext(AppContext);

  const [chatChannel, setChatChannel] =
    useState<ChannelType<DefaultStreamChatGenerics>>();
  const [channelLoading, setChannelLoading] = useState(true);
  const [pageWidth, setPageWidth] = useState(0);
  const layoutRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (loading || !layoutRef.current) return;
    const resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) {
        setPageWidth(entry.contentRect.width);
      }
    });
    resizeObserver.observe(layoutRef.current);

    return () => {
      resizeObserver.disconnect();
    };
  }, [layoutRef, loading]);

  useEffect(() => {
    const loadWorkspace = async () => {
      try {
        const response = await fetch(`/api/workspaces/${workspaceId}`);
        const result = await response.json();
        if (response.ok) {
          setWorkspace(result.workspace);
          setOtherWorkspaces(result.otherWorkspaces);
          localStorage.setItem(
            'activitySession',
            JSON.stringify({ workspaceId, channelId })
          );
          setLoading(false);
        } else {
          console.error('Error fetching workspace data:', result.error);
          router.push('/');
        }
      } catch (error) {
        console.error('Error fetching workspace data:', error);
        router.push('/');
      }
    };

    const loadChannel = async () => {
      const currentMembers = workspace.memberships.map((m) => m.userId);
      const chatChannel = chatClient.channel('messaging', channelId, {
        members: currentMembers,
        name: channel.name,
        description: channel.description,
        workspaceId: channel.workspaceId,
      });

      await chatChannel.create();

      if (currentCall?.id === channelId) {
        setChannelCall(currentCall);
      } else {
        const channelCall = videoClient?.call('default', channelId);
        setChannelCall(channelCall);
      }

      setChatChannel(chatChannel);
      setChannelLoading(false);
    };

    const loadWorkspaceAndChannel = async () => {
      if (!workspace) {
        await loadWorkspace();
      } else {
        if (!channel)
          setChannel(workspace.channels.find((c) => c.id === channelId)!);
        if (loading) setLoading(false);
        if (chatClient && channel) loadChannel();
      }
    };

    if ((!chatChannel || chatChannel?.id !== channelId) && user)
      loadWorkspaceAndChannel();
  }, [
    channel,
    channelId,
    chatChannel,
    chatClient,
    currentCall,
    loading,
    router,
    setChannel,
    setChannelCall,
    setLoading,
    setOtherWorkspaces,
    setWorkspace,
    user,
    videoClient,
    workspace,
    workspaceId,
  ]);

  useEffect(() => {
    if (currentCall?.id === channelId) {
      setChannelCall(currentCall);
    }
  }, [currentCall, channelId, setChannelCall]);

  if (loading) return null;

  return (
    <div
      ref={layoutRef}
      className="channel bg-[#1a1d21] font-lato w-full h-full z-100 flex flex-col overflow-hidden text-channel-gray"
    >
      {/* Toolbar */}
      <div className="pl-4 pr-3 h-[49px] flex items-center flex-shrink-0 justify-between">
        <div className="flex flex-[1_1_0] items-center min-w-0">
          <button className="min-w-[96px] px-2 py-[3px] -ml-1 mr-2 flex flex-[0_auto] items-center text-[17.8px] rounded-md text-channel-gray hover:bg-[#d1d2d30b] leading-[1.33334]">
            <span className="mr-1 align-text-bottom">
              <Hash color="var(--channel-gray)" size={18} />
            </span>
            <span className="truncate font-[900]">{channel?.name}</span>
          </button>
          <div
            className={clsx(
              'w-[96px] flex-[1_1_0] min-w-[96px] mr-2 pt-1 text-[12.8px] text-[#e8e8e8b3]',
              pageWidth > 0 && pageWidth < 500 ? 'hidden' : 'flex'
            )}
          >
            <span className="min-w-[96px] max-w-[min(70%,540px)] truncate">
              {channel?.description}
            </span>
          </div>
        </div>
        <div className="flex flex-none ml-auto items-center">
          <button
            className={clsx(
              'flex items-center pl-2 py-[3px] rounded-lg h-7 border border-[#797c814d] text-[#e8e8e8b3] hover:bg-[#25272b]',
              pageWidth > 0 && pageWidth < 605 ? 'hidden' : 'flex'
            )}
          >
            <User color="var(--icon-gray)" />
            <span className="pl-1 pr-2 text-[12.8px]">
              {workspace.memberships.length}
            </span>
          </button>
          <button className="group rounded-lg flex w-7 h-7 ml-2 items-center justify-center hover:bg-[#d1d2d30b]">
            <MoreVert className="fill-[#e8e8e8b3] group-hover:fill-channel-gray" />
          </button>
        </div>
      </div>
      {/* Tab Bar */}
      <div className="w-full min-w-full max-w-full h-[38px] flex items-center pl-4 pr-3 shadow-[inset_0_-1px_0_0_#797c814d] gap-1">
        <div className="flex items-center cursor-pointer w-[92.45px] h-full p-2 gap-1 text-[13px] leading-[1.38463] text-center font-bold rounded-t-lg hover:bg-hover-gray border-b-[2px] border-white">
          <Message color="var(--primary)" />
          Messages
        </div>
        <div className="group flex items-center cursor-pointer text-[#b9babd] h-full p-2 gap-1 text-[13px] leading-[1.38463] text-center font-bold rounded-t-lg hover:bg-hover-gray hover:text-white">
          <Files className="fill-icon-gray group-hover:fill-white" size={16} />
          Files
        </div>
        <div className="group flex items-center cursor-pointer text-[#b9babd] h-full p-2 gap-1 text-[13px] leading-[1.38463] text-center font-bold rounded-t-lg hover:bg-hover-gray hover:text-white">
          <Pin className="fill-icon-gray group-hover:fill-white" size={16} />
          Pins
        </div>
        <div className="group flex items-center justify-center cursor-pointer h-7 w-7 rounded-full hover:bg-hover-gray">
          <Plus
            filled
            className="fill-icon-gray group-hover:fill-white"
            size={16}
          />
        </div>
      </div>
      {/* Chat */}
      <div className="relative flex flex-col w-full h-full flex-1 overflow-hidden ">
        {/* Body */}
        <div className="relative flex-1">
          <div className="absolute -top-2 bottom-0 flex w-full overflow-hidden">
            <div
              style={{
                width: pageWidth > 0 ? pageWidth : '100%',
              }}
              className="relative"
            >
              <div className="absolute h-full inset-[0_-50px_0_0] overflow-y-scroll overflow-x-hidden z-[2]">
                {/* Messages */}
                <div>Hello World!</div>
              </div>
            </div>
          </div>
        </div>
        {/* Footer */}
        <div className="relative max-h-[calc(100%-36px)] flex flex-col -mt-2 px-5">
          <div id="message-input" className="flex-1"></div>
          <div className="w-full flex items-center h-6 pl-3 pr-2"></div>
        </div>
      </div>
    </div>
  );
};

export default Channel;

Let’s break this down:

  • Layout Management: The component uses the layoutRef and ResizeObserver to manage and adjust the page layout dynamically based on the width of the channel section.

  • Channel Loading: The component first checks if the workspace and channel information are available, and if not, it makes an API call to load the data.

  • Storing Activity Session: After loading the workspace data, we store the activity session in localStorage. This session contains the workspaceId and channelId to remember the user’s last active workspace and channel.

  • Chat and Video Clients: We initialize the chat and video clients to allow real-time messaging and calling functionality within the channel.

  • Toolbar and Footer: The toolbar shows details about the current channel, such as its name and description, while the footer contains an input area for sending messages.

Setting Up the Client Page

The Client component is a utility page that redirects users to their last active workspace and channel. It does this by checking the activitySession stored in localStorage. If no activity session is found, the user is redirected to the homepage.

Create a page.tsx file in the /app/client directory with the following code:

'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

export default function Client() {
  const router = useRouter();

  useEffect(() => {
    const fetchActivitySession = async () => {
      const activitySession = localStorage.getItem('activitySession');
      if (activitySession) {
        const { workspaceId, channelId } = await JSON.parse(activitySession);
        router.push(`/client/${workspaceId}/${channelId}`);
      } else {
        router.push('/');
      }
    };

    fetchActivitySession();
  }, [router]);

  return null;
}

Building the Workspace Page

Finally, we will create a second utility page which will handle the logic of redirecting the user to a channel within a workspace.

Create a page.tsx file in the /client/[workspaceId] directory:

'use client';
import { useContext, useEffect } from 'react';
import { useRouter } from 'next/navigation';

import { AppContext, Workspace } from '../layout';

interface WorkspacePageProps {
  params: {
    workspaceId: string;
  };
}

export default function WorkspacePage({ params }: WorkspacePageProps) {
  const { workspaceId } = params;
  const { workspace, setWorkspace, setOtherWorkspaces } =
    useContext(AppContext);
  const router = useRouter();

  useEffect(() => {
    const goToChannel = (workspace: Workspace) => {
      const channelId = workspace.channels[0].id;
      localStorage.setItem(
        'activitySession',
        JSON.stringify({ workspaceId: workspace.id, channelId })
      );
      router.push(`/client/${workspace.id}/${channelId}`);
    };

    const loadWorkspace = async () => {
      try {
        const response = await fetch(`/api/workspaces/${workspaceId}`);
        const result = await response.json();
        if (response.ok) {
          setWorkspace(result.workspace);
          setOtherWorkspaces(result.otherWorkspaces);
          goToChannel(result.workspace);
        } else {
          console.error('Error fetching workspace data:', result.error);
        }
      } catch (error) {
        console.error('Error fetching workspace data:', error);
      }
    };

    if (!workspace) {
      loadWorkspace();
    } else {
      goToChannel(workspace);
    }
  }, [workspace, workspaceId, setWorkspace, setOtherWorkspaces, router]);

  return null;
}

In the code above, if the workspace data isn't loaded yet, we fetch it from the /api/workspaces/[workspaceId] route, and navigate the user to the first available channel in that workspace.

And with that, we now have a solid foundation for our Slack clone!

Conclusion

In this first part of building the Slack clone, we:

  • Set up the project, including workspace creation, channel management, and integration with Stream and Clerk.

  • Created API routes for managing workspaces and channels.

  • Built essential components for navigating between workspaces and channels.

In the next part, we will focus on implementing real-time messaging and managing channels.

Stay tuned!

99
Subscribe to my newsletter

Read articles from Oluwabusayo Jacobs directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Oluwabusayo Jacobs
Oluwabusayo Jacobs

Dev in the Tropics | Software Engineer