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 .
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 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 .
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:
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?
First, you'll need to create a free account with Clerk. Go to the
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.
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:
CODEnpm install @clerk/nextjs
Create an
.env.localfile and add the following environment variables:
CODENEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key
CLERK_SECRET_KEY=your_clerk_secret_key
Replace
your_clerk_publishable_keyandyour_clerk_secret_keywith 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.tsxfile like this:
CODEimport 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.localfile:
CODENEXT_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:
CODEimport { 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.tsxfile in theapp/sign-in/[[...sign-in]]directory and add the code below:
CODEimport { 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.tsfile in thesrcdirectory with the following code:
CODEimport { 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)(.*)',
],
};
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
useStatehook to store the values the user enters and usesallFieldsValidto ensure all required fields are filled in correctly.When the form is submitted, it makes a
POSTrequest to the/api/workspaces/createroute, 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.
By following these steps, you can confirm that the workspace creation flow functions correctly.
Setting Up Stream In Your Application
What is Stream?
and
To start using Stream, you'll need to create an account:
Sign Up: Go to the
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:
CODE* **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.
:
CODE// 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:
CODE* 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):
CODE```bash
ngrok http 3000 --domain=YOUR_DOMAIN
```
CODEReplace `YOUR_DOMAIN` with the [generated ngrok domain](https://dashboard.ngrok.com/cloud-edge/domains).
- Create a Webhook Endpoint in Clerk:
CODE* **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:
CODE```dockerfile
WEBHOOK_SECRET=your_clerk_webhook_signing_secret
```
CODEReplace `your_clerk_webhook_signing_secret` with the signing secret from the webhooks page.

Install Svix: We need
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
activitySessionstored inlocalStorage. If no activity session is found, the user is redirected to the homepage.
Create a
page.tsxfile in the/app/clientdirectory with the following code:
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.tsxfile in the/client/[workspaceId]directory:
CODE'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 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!
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
SOCIAL SHARE CARD GENERATOR