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
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:
Go to the dashboard and click "Create application".
Name your application “Slack clone”.
Under “Sign in options,” choose Email, Username, and Google.
Click the "Create application" to complete the setup.
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:
Navigate to your dashboard's "Configure" tab.
Under "User & Authentication", select "Email, Phone, Username".
Find the "Name" option in the "Personal Information" section and toggle it on..
Click the gear icon next to "Name" and set it as required.
Click “Continue” to save your changes.
Installing Clerk in Your Project
Next, let's add Clerk to your Next.js project:
-
Install the Clerk package by running the command below:
npm install @clerk/nextjs
-
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
andyour_clerk_secret_key
with the keys from your project's overview page. -
To use Clerk's authentication throughout your app, you need to wrap your application with
ClerkProvider
. Update yourapp/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:
-
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
-
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> ); }
-
Create the Sign-In Page: Create a
page.tsx
file in theapp/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> ); }
-
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 thesrc
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
, andworkspaceId
. 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’sSignOutButton
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 usesallFieldsValid
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:
Go to the Setup Page: Navigate to your app's
/get-started
page.Fill in the Necessary Details: Enter the workspace name, channel name, and email addresses of the members you want to invite.
Add an Image (Optional): If you wish, add an image URL to make the workspace look more personal.
Submit the Form: Click the "Submit" button to create the workspace.
Verify the Creation: Ensure the workspace is created successfully and all invited members receive invitations.
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:
Sign Up: Go to the Stream sign-up page and create an account using your email or a social login.
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1726664432078/966254af-e0b3-4a54-b395-52667e6374b7.png)
* 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:
Create a New App: In your Stream dashboard, click the "Create App" button.
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.
-
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:
Navigate to the "Roles & Permissions" tab under "Chat messaging."
Select the "user" role and choose the "messaging" scope.
Click the “Edit” button and select the following permissions:
* Create Message
* Read Channel
* Read Channel Members
* Create Reaction
* Upload Attachments
* Create Attachments
- Save and confirm the changes.
Installing Stream SDKs
To start using Stream in our Next.js project, we need to install a few SDKs:
-
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
-
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
andyour_stream_api_secret
with the keys from your Stream dashboard. -
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:
- 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:
* Go to the [ngrok website](https://dashboard.ngrok.com/signup) and sign up for a free account.
* [Download and install ngrok](https://dashboard.ngrok.com/get-started/setup), then start a tunnel to your local server (assuming it's running on port 3000):
```bash
ngrok http 3000 --domain=YOUR_DOMAIN
```
Replace `YOUR_DOMAIN` with the [generated ngrok domain](https://dashboard.ngrok.com/cloud-edge/domains).
- Create a Webhook Endpoint in Clerk:
* **Navigate to Webhooks**: In your [Clerk dashboard](https://dashboard.clerk.com/last-active?path=webhooks), 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:
```dockerfile
WEBHOOK_SECRET=your_clerk_webhook_signing_secret
```
Replace `your_clerk_webhook_signing_secret` with the signing secret from the webhooks page.
![Signing secret](https://cdn.hashnode.com/res/hashnode/image/upload/v1726741867125/7c6ffd89-36ac-4c4b-a5e0-bd665796612d.png)
-
Install Svix: We need Svix to verify and handle incoming webhooks. Run the following command to install the package:
npm install svix
-
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 aroute.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`.
-
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 asetUpChatAndVideo
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 thegoToChannel
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
fromstream-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
andResizeObserver
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 theworkspaceId
andchannelId
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!
Top comments (0)