Skip to content

Repository files navigation


Project Banner
nextdotjsmongodbtailwindcssclerkshadcnuizodtypescript

A full stack Threads Clone

Build this project step by step with our detailed tutorial on JavaScript Mastery YouTube. Join the JSM family!
  1. 🤖 Introduction
  2. ⚙️ Tech Stack
  3. 🔋 Features
  4. 🤸 Quick Start
  5. 🕸️ Snippets
  6. 🔗 Links
  7. 🚀 More

🚨 Tutorial

This repository contains the code corresponding to an in-depth tutorial available on our YouTube channel, JavaScript Mastery.

If you prefer visual learning, this is the perfect resource for you. Follow our tutorial to learn how to build projects like these step-by-step in a beginner-friendly manner!

Build a full stack Threads clone using Next.js 14+ with a redesigned look transformed from a Figma design, user interaction to community management, technical implementation, and various features, including nested deep comments, notifications, real-time-search, and more.

If you're getting started and need assistance or face any bugs, join our active Discord community with over 27k+ members. It's a place where people help each other out.

  • Next.js
  • MongoDB
  • Shadcn UI
  • TailwindCSS
  • Clerk
  • Webhooks
  • Serverless APIs
  • React Hook Form
  • Zod
  • TypeScript

👉 Authentication: Authentication using Clerk for email, password, and social logins (Google and GitHub) with a comprehensive profile management system.

👉 Visually Appealing Home Page: A visually appealing home page showcasing the latest threads for an engaging user experience.

👉 Create Thread Page: A dedicated page for users to create threads, fostering community engagement

👉 Commenting Feature: A commenting feature to facilitate discussions within threads.

👉 Nested Commenting: Commenting system with nested threads, providing a structured conversation flow.

👉 User Search with Pagination: A user search feature with pagination for easy exploration and discovery of other users.

👉 Activity Page: Display notifications on the activity page when someone comments on a user's thread, enhancing user engagement.

👉 Profile Page: User profile pages for showcasing information and enabling modification of profile settings.

👉 Create and Invite to Communities: Allow users to create new communities and invite others using customizable template emails.

👉 Community Member Management: A user-friendly interface to manage community members, allowing role changes and removals.

👉 Admin-Specific Community Threads: Enable admins to create threads specifically for their community.

👉 Community Search with Pagination: A community search feature with pagination for exploring different communities.

👉 Community Profiles: Display community profiles showcasing threads and members for a comprehensive overview.

👉 Figma Design Implementation: Transform Figma designs into a fully functional application with pixel-perfect and responsive design.

👉 Blazing-Fast Performance: Optimal performance and instantaneous page switching for a seamless user experience.

👉 Server Side Rendering: Utilize Next.js with Server Side Rendering for enhanced performance and SEO benefits.

👉 MongoDB with Complex Schemas: Handle complex schemas and multiple data populations using MongoDB.

👉 File Uploads with UploadThing: File uploads using UploadThing for a seamless media sharing experience.

👉 Real-Time Events Listening: Real-time events listening with webhooks to keep users updated.

👉 Middleware, API Actions, and Authorization: Utilize middleware, API actions, and authorization for robust application security.

👉 Next.js Layout Route Groups: New Next.js layout route groups for efficient routing

👉 Data Validation with Zod: Data integrity with data validation using Zod

👉 Form Management with React Hook Form: Efficient management of forms with React Hook Form for a streamlined user input experience.

and many more, including code architecture and reusability

Follow these steps to set up the project locally on your machine.

Prerequisites

Make sure you have the following installed on your machine:

Cloning the Repository

git clone https://github.com/adrianhajdin/threads.git
cd threads

Installation

Install the project dependencies using npm:

npm install

Set Up Environment Variables

Create a new file named .env in the root of your project and add the following content:

MONGODB_URL=CLERK_SECRET_KEY=UPLOADTHING_SECRET=UPLOADTHING_APP_ID=NEXT_CLERK_WEBHOOK_SECRET=NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=

Replace the placeholder values with your actual credentials. You can obtain these credentials by signing up for the corresponding websites on MongoDB, Clerk, and Uploadthing.

Running the Project

npm run dev

Open http://localhost:3000 in your browser to view the project.

clerk.route.ts
/* eslint-disable camelcase */// Resource: https://clerk.com/docs/users/sync-data-to-your-backend// Above article shows why we need webhooks i.e., to sync data to our backend// Resource: https://docs.svix.com/receiving/verifying-payloads/why// It's a good practice to verify webhooks. Above article shows why we should do itimport{Webhook,WebhookRequiredHeaders}from"svix";import{headers}from"next/headers";import{IncomingHttpHeaders}from"http";import{NextResponse}from"next/server";import{addMemberToCommunity,createCommunity,deleteCommunity,removeUserFromCommunity,updateCommunityInfo,}from"@/lib/actions/community.actions";// Resource: https://clerk.com/docs/integration/webhooks#supported-events// Above document lists the supported eventstypeEventType=|"organization.created"|"organizationInvitation.created"|"organizationMembership.created"|"organizationMembership.deleted"|"organization.updated"|"organization.deleted";typeEvent={data: Record<string,string|number|Record<string,string>[]>;object: "event";type: EventType;};exportconstPOST=async(request: Request)=>{constpayload=awaitrequest.json();constheader=headers();constheads={"svix-id": header.get("svix-id"),"svix-timestamp": header.get("svix-timestamp"),"svix-signature": header.get("svix-signature"),};// Activitate Webhook in the Clerk Dashboard.// After adding the endpoint, you'll see the secret on the right side.constwh=newWebhook(process.env.NEXT_CLERK_WEBHOOK_SECRET||"");letevnt: Event|null=null;try{evnt=wh.verify(JSON.stringify(payload),headsasIncomingHttpHeaders&WebhookRequiredHeaders)asEvent;}catch(err){returnNextResponse.json({message: err},{status: 400});}consteventType: EventType=evnt?.type!;// Listen organization creation eventif(eventType==="organization.created"){// Resource: https://clerk.com/docs/reference/backend-api/tag/Organizations#operation/CreateOrganization// Show what evnt?.data sends from above resourceconst{ id, name, slug, logo_url, image_url, created_by }=evnt?.data??{};try{// @ts-ignoreawaitcreateCommunity(// @ts-ignoreid,name,slug,logo_url||image_url,"org bio",created_by);returnNextResponse.json({message: "User created"},{status: 201});}catch(err){console.log(err);returnNextResponse.json({message: "Internal Server Error"},{status: 500});}}// Listen organization invitation creation event.// Just to show. You can avoid this or tell people that we can create a new mongoose action and// add pending invites in the database.if(eventType==="organizationInvitation.created"){try{// Resource: https://clerk.com/docs/reference/backend-api/tag/Organization-Invitations#operation/CreateOrganizationInvitationconsole.log("Invitation created",evnt?.data);returnNextResponse.json({message: "Invitation created"},{status: 201});}catch(err){console.log(err);returnNextResponse.json({message: "Internal Server Error"},{status: 500});}}// Listen organization membership (member invite & accepted) creationif(eventType==="organizationMembership.created"){try{// Resource: https://clerk.com/docs/reference/backend-api/tag/Organization-Memberships#operation/CreateOrganizationMembership// Show what evnt?.data sends from above resourceconst{ organization, public_user_data }=evnt?.data;console.log("created",evnt?.data);// @ts-ignoreawaitaddMemberToCommunity(organization.id,public_user_data.user_id);returnNextResponse.json({message: "Invitation accepted"},{status: 201});}catch(err){console.log(err);returnNextResponse.json({message: "Internal Server Error"},{status: 500});}}// Listen member deletion eventif(eventType==="organizationMembership.deleted"){try{// Resource: https://clerk.com/docs/reference/backend-api/tag/Organization-Memberships#operation/DeleteOrganizationMembership// Show what evnt?.data sends from above resourceconst{ organization, public_user_data }=evnt?.data;console.log("removed",evnt?.data);// @ts-ignoreawaitremoveUserFromCommunity(public_user_data.user_id,organization.id);returnNextResponse.json({message: "Member removed"},{status: 201});}catch(err){console.log(err);returnNextResponse.json({message: "Internal Server Error"},{status: 500});}}// Listen organization updation eventif(eventType==="organization.updated"){try{// Resource: https://clerk.com/docs/reference/backend-api/tag/Organizations#operation/UpdateOrganization// Show what evnt?.data sends from above resourceconst{ id, logo_url, name, slug }=evnt?.data;console.log("updated",evnt?.data);// @ts-ignoreawaitupdateCommunityInfo(id,name,slug,logo_url);returnNextResponse.json({message: "Member removed"},{status: 201});}catch(err){console.log(err);returnNextResponse.json({message: "Internal Server Error"},{status: 500});}}// Listen organization deletion eventif(eventType==="organization.deleted"){try{// Resource: https://clerk.com/docs/reference/backend-api/tag/Organizations#operation/DeleteOrganization// Show what evnt?.data sends from above resourceconst{ id }=evnt?.data;console.log("deleted",evnt?.data);// @ts-ignoreawaitdeleteCommunity(id);returnNextResponse.json({message: "Organization deleted"},{status: 201});}catch(err){console.log(err);returnNextResponse.json({message: "Internal Server Error"},{status: 500});}}};
community.actions.ts
"use server";import{FilterQuery,SortOrder}from"mongoose";importCommunityfrom"../models/community.model";importThreadfrom"../models/thread.model";importUserfrom"../models/user.model";import{connectToDB}from"../mongoose";exportasyncfunctioncreateCommunity(id: string,name: string,username: string,image: string,bio: string,createdById: string// Change the parameter name to reflect it's an id){try{connectToDB();// Find the user with the provided unique idconstuser=awaitUser.findOne({id: createdById});if(!user){thrownewError("User not found");// Handle the case if the user with the id is not found}constnewCommunity=newCommunity({
id,
name,
username,
image,
bio,createdBy: user._id,// Use the mongoose ID of the user});constcreatedCommunity=awaitnewCommunity.save();// Update User modeluser.communities.push(createdCommunity._id);awaituser.save();returncreatedCommunity;}catch(error){// Handle any errorsconsole.error("Error creating community:",error);throwerror;}}exportasyncfunctionfetchCommunityDetails(id: string){try{connectToDB();constcommunityDetails=awaitCommunity.findOne({ id }).populate(["createdBy",{path: "members",model: User,select: "name username image _id id",},]);returncommunityDetails;}catch(error){// Handle any errorsconsole.error("Error fetching community details:",error);throwerror;}}exportasyncfunctionfetchCommunityPosts(id: string){try{connectToDB();constcommunityPosts=awaitCommunity.findById(id).populate({path: "threads",model: Thread,populate: [{path: "author",model: User,select: "name image id",// Select the "name" and "_id" fields from the "User" model},{path: "children",model: Thread,populate: {path: "author",model: User,select: "image _id",// Select the "name" and "_id" fields from the "User" model},},],});returncommunityPosts;}catch(error){// Handle any errorsconsole.error("Error fetching community posts:",error);throwerror;}}exportasyncfunctionfetchCommunities({
searchString ="",
pageNumber =1,
pageSize =20,
sortBy ="desc",}: {searchString?: string;pageNumber?: number;pageSize?: number;sortBy?: SortOrder;}){try{connectToDB();// Calculate the number of communities to skip based on the page number and page size.constskipAmount=(pageNumber-1)*pageSize;// Create a case-insensitive regular expression for the provided search string.constregex=newRegExp(searchString,"i");// Create an initial query object to filter communities.constquery: FilterQuery<typeofCommunity>={};// If the search string is not empty, add the $or operator to match either username or name fields.if(searchString.trim()!==""){query.$or=[{username: {$regex: regex}},{name: {$regex: regex}},];}// Define the sort options for the fetched communities based on createdAt field and provided sort order.constsortOptions={createdAt: sortBy};// Create a query to fetch the communities based on the search and sort criteria.constcommunitiesQuery=Community.find(query).sort(sortOptions).skip(skipAmount).limit(pageSize).populate("members");// Count the total number of communities that match the search criteria (without pagination).consttotalCommunitiesCount=awaitCommunity.countDocuments(query);constcommunities=awaitcommunitiesQuery.exec();// Check if there are more communities beyond the current page.constisNext=totalCommunitiesCount>skipAmount+communities.length;return{ communities, isNext };}catch(error){console.error("Error fetching communities:",error);throwerror;}}exportasyncfunctionaddMemberToCommunity(communityId: string,memberId: string){try{connectToDB();// Find the community by its unique idconstcommunity=awaitCommunity.findOne({id: communityId});if(!community){thrownewError("Community not found");}// Find the user by their unique idconstuser=awaitUser.findOne({id: memberId});if(!user){thrownewError("User not found");}// Check if the user is already a member of the communityif(community.members.includes(user._id)){thrownewError("User is already a member of the community");}// Add the user's _id to the members array in the communitycommunity.members.push(user._id);awaitcommunity.save();// Add the community's _id to the communities array in the useruser.communities.push(community._id);awaituser.save();returncommunity;}catch(error){// Handle any errorsconsole.error("Error adding member to community:",error);throwerror;}}exportasyncfunctionremoveUserFromCommunity(userId: string,communityId: string){try{connectToDB();constuserIdObject=awaitUser.findOne({id: userId},{_id: 1});constcommunityIdObject=awaitCommunity.findOne({id: communityId},{_id: 1});if(!userIdObject){thrownewError("User not found");}if(!communityIdObject){thrownewError("Community not found");}// Remove the user's _id from the members array in the communityawaitCommunity.updateOne({_id: communityIdObject._id},{$pull: {members: userIdObject._id}});// Remove the community's _id from the communities array in the userawaitUser.updateOne({_id: userIdObject._id},{$pull: {communities: communityIdObject._id}});return{success: true};}catch(error){// Handle any errorsconsole.error("Error removing user from community:",error);throwerror;}}exportasyncfunctionupdateCommunityInfo(communityId: string,name: string,username: string,image: string){try{connectToDB();// Find the community by its _id and update the informationconstupdatedCommunity=awaitCommunity.findOneAndUpdate({id: communityId},{ name, username, image });if(!updatedCommunity){thrownewError("Community not found");}returnupdatedCommunity;}catch(error){// Handle any errorsconsole.error("Error updating community information:",error);throwerror;}}exportasyncfunctiondeleteCommunity(communityId: string){try{connectToDB();// Find the community by its ID and delete itconstdeletedCommunity=awaitCommunity.findOneAndDelete({id: communityId,});if(!deletedCommunity){thrownewError("Community not found");}// Delete all threads associated with the communityawaitThread.deleteMany({community: communityId});// Find all users who are part of the communityconstcommunityUsers=awaitUser.find({communities: communityId});// Remove the community from the 'communities' array for each userconstupdateUserPromises=communityUsers.map((user)=>{user.communities.pull(communityId);returnuser.save();});awaitPromise.all(updateUserPromises);returndeletedCommunity;}catch(error){console.error("Error deleting community: ",error);throwerror;}}
CommunityCard.tsx
importImagefrom"next/image";importLinkfrom"next/link";import{Button}from"../ui/button";interfaceProps{id: string;name: string;username: string;imgUrl: string;bio: string;members: {image: string;}[];}functionCommunityCard({ id, name, username, imgUrl, bio, members }: Props){return(<articleclassName='community-card'><divclassName='flex flex-wrap items-center gap-3'><Linkhref={`/communities/${id}`}className='relative h-12 w-12'><Imagesrc={imgUrl}alt='community_logo'fillclassName='rounded-full object-cover'/></Link><div><Linkhref={`/communities/${id}`}><h4className='text-base-semibold text-light-1'>{name}</h4></Link><pclassName='text-small-medium text-gray-1'>@{username}</p></div></div><pclassName='mt-4 text-subtle-medium text-gray-1'>{bio}</p><divclassName='mt-5 flex flex-wrap items-center justify-between gap-3'><Linkhref={`/communities/${id}`}><Buttonsize='sm'className='community-card_btn'>View</Button></Link>{members.length>0&&(<divclassName='flex items-center'>{members.map((member,index)=>(<Imagekey={index}src={member.image}alt={`user_${index}`}width={28}height={28}className={`${
index!==0&&"-ml-2"} rounded-full object-cover`}/>))}{members.length>3&&(<pclassName='ml-1 text-subtle-medium text-gray-1'>{members.length}+Users</p>)}</div>)}</div></article>);}exportdefaultCommunityCard;
constants.index.ts
exportconstsidebarLinks=[{imgURL: "/assets/home.svg",route: "/",label: "Home",},{imgURL: "/assets/search.svg",route: "/search",label: "Search",},{imgURL: "/assets/heart.svg",route: "/activity",label: "Activity",},{imgURL: "/assets/create.svg",route: "/create-thread",label: "Create Thread",},{imgURL: "/assets/community.svg",route: "/communities",label: "Communities",},{imgURL: "/assets/user.svg",route: "/profile",label: "Profile",},];exportconstprofileTabs=[{value: "threads",label: "Threads",icon: "/assets/reply.svg"},{value: "replies",label: "Replies",icon: "/assets/members.svg"},{value: "tagged",label: "Tagged",icon: "/assets/tag.svg"},];exportconstcommunityTabs=[{value: "threads",label: "Threads",icon: "/assets/reply.svg"},{value: "members",label: "Members",icon: "/assets/members.svg"},{value: "requests",label: "Requests",icon: "/assets/request.svg"},];
globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
/* main */
.main-container {
@apply flex min-h-screen flex-1 flex-col items-center bg-dark-1 px-6 pb-10 pt-28 max-md:pb-32 sm:px-10;
}
/* Head Text */
.head-text {
@apply text-heading2-bold text-light-1;
}
/* Activity */
.activity-card {
@apply flex items-center gap-2 rounded-md bg-dark-2 px-7 py-4;
}
/* No Result */
.no-result {
@apply text-center !text-base-regular text-light-3;
}
/* Community Card */
.community-card {
@apply w-full rounded-lg bg-dark-3 px-4 py-5 sm:w-96;
}
.community-card_btn {
@apply rounded-lg bg-primary-500 px-5 py-1.5 text-small-regular !text-light-1 !important;
}
/* thread card */
.thread-card_bar {
@apply relative mt-2 w-0.5 grow rounded-full bg-neutral-800;
}
/* User card */
.user-card {
@apply flex flex-col justify-between gap-4 max-xs:rounded-xl max-xs:bg-dark-3 max-xs:p-4 xs:flex-row xs:items-center;
}
.user-card_avatar {
@apply flex flex-1 items-start justify-start gap-3 xs:items-center;
}
.user-card_btn {
@apply h-auto min-w-[74px] rounded-lg bg-primary-500 text-[12px] text-light-1 !important;
}
.searchbar {
@apply flex gap-1 rounded-lg bg-dark-3 px-4 py-2;
}
.searchbar_input {
@apply border-none bg-dark-3 text-base-regular text-light-4 outline-none !important;
}
.topbar {
@apply fixed top-0 z-30 flex w-full items-center justify-between bg-dark-2 px-6 py-3;
}
.bottombar {
@apply fixed bottom-0 z-10 w-full rounded-t-3xl bg-glassmorphism p-4 backdrop-blur-lg xs:px-7 md:hidden;
}
.bottombar_container {
@apply flex items-center justify-between gap-3 xs:gap-5;
}
.bottombar_link {
@apply relative flex flex-col items-center gap-2 rounded-lg p-2 sm:flex-1 sm:px-2 sm:py-2.5;
}
.leftsidebar {
@apply sticky left-0 top-0 z-20 flex h-screen w-fit flex-col justify-between overflow-auto border-r border-r-dark-4 bg-dark-2 pb-5 pt-28 max-md:hidden;
}
.leftsidebar_link {
@apply relative flex justify-start gap-4 rounded-lg p-4;
}
.pagination {
@apply mt-10 flex w-full items-center justify-center gap-5;
}
.rightsidebar {
@apply sticky right-0 top-0 z-20 flex h-screen w-fit flex-col justify-between gap-12 overflow-auto border-l border-l-dark-4 bg-dark-2 px-10 pb-6 pt-28 max-xl:hidden;
}
}
@layer utilities {
.css-invert {
@apply invert-[50%] brightness-200;
}
.custom-scrollbar::-webkit-scrollbar {
width:3px;
height:3px;
border-radius:2px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background:#09090a;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background:#5c5c7b;
border-radius:50px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background:#7878a3;
}
}
/* Clerk Responsive fix */
.cl-organizationSwitcherTrigger .cl-userPreview .cl-userPreviewTextContainer {
@apply max-sm:hidden;
}
.cl-organizationSwitcherTrigger
.cl-organizationPreview
.cl-organizationPreviewTextContainer {
@apply max-sm:hidden;
}
/* Shadcn Component Styles *//* Tab */
.tab {
@apply flex min-h-[50px] flex-1 items-center gap-3 bg-dark-2 text-light-2 data-[state=active]:bg-[#0e0e12] data-[state=active]:text-light-2 !important;
}
.no-focus {
@apply focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 !important;
}
/* Account Profile */
.account-form_image-label {
@apply flex h-24 w-24 items-center justify-center rounded-full bg-dark-4 !important;
}
.account-form_image-input {
@apply cursor-pointer border-none bg-transparent outline-none file:text-blue !important;
}
.account-form_input {
@apply border border-dark-4 bg-dark-3 text-light-1 !important;
}
/* Comment Form */
.comment-form {
@apply mt-10 flex items-center gap-4 border-y border-y-dark-4 py-5 max-xs:flex-col !important;
}
.comment-form_btn {
@apply rounded-3xl bg-primary-500 px-8 py-2 !text-small-regular text-light-1 max-xs:w-full !important;
}
next.config.js
/** @type {import('next').NextConfig} */constnextConfig={experimental: {serverActions: true,serverComponentsExternalPackages: ["mongoose"],},images: {remotePatterns: [{protocol: "https",hostname: "img.clerk.com",},{protocol: "https",hostname: "images.clerk.dev",},{protocol: "https",hostname: "uploadthing.com",},{protocol: "https",hostname: "placehold.co",},],typescript: {ignoreBuildErrors: true,},},};module.exports=nextConfig;
tailwind.config.js
/** @type {import('tailwindcss').Config} */module.exports={darkMode: ["class"],content: ["./pages/**/*.{ts,tsx}","./components/**/*.{ts,tsx}","./app/**/*.{ts,tsx}","./src/**/*.{ts,tsx}",],theme: {container: {center: true,padding: "2rem",screens: {"2xl": "1400px",},},fontSize: {"heading1-bold": ["36px",{lineHeight: "140%",fontWeight: "700",},],"heading1-semibold": ["36px",{lineHeight: "140%",fontWeight: "600",},],"heading2-bold": ["30px",{lineHeight: "140%",fontWeight: "700",},],"heading2-semibold": ["30px",{lineHeight: "140%",fontWeight: "600",},],"heading3-bold": ["24px",{lineHeight: "140%",fontWeight: "700",},],"heading4-medium": ["20px",{lineHeight: "140%",fontWeight: "500",},],"body-bold": ["18px",{lineHeight: "140%",fontWeight: "700",},],"body-semibold": ["18px",{lineHeight: "140%",fontWeight: "600",},],"body-medium": ["18px",{lineHeight: "140%",fontWeight: "500",},],"body-normal": ["18px",{lineHeight: "140%",fontWeight: "400",},],"body1-bold": ["18px",{lineHeight: "140%",fontWeight: "700",},],"base-regular": ["16px",{lineHeight: "140%",fontWeight: "400",},],"base-medium": ["16px",{lineHeight: "140%",fontWeight: "500",},],"base-semibold": ["16px",{lineHeight: "140%",fontWeight: "600",},],"base1-semibold": ["16px",{lineHeight: "140%",fontWeight: "600",},],"small-regular": ["14px",{lineHeight: "140%",fontWeight: "400",},],"small-medium": ["14px",{lineHeight: "140%",fontWeight: "500",},],"small-semibold": ["14px",{lineHeight: "140%",fontWeight: "600",},],"subtle-medium": ["12px",{lineHeight: "16px",fontWeight: "500",},],"subtle-semibold": ["12px",{lineHeight: "16px",fontWeight: "600",},],"tiny-medium": ["10px",{lineHeight: "140%",fontWeight: "500",},],"x-small-semibold": ["7px",{lineHeight: "9.318px",fontWeight: "600",},],},extend: {colors: {"primary-500": "#877EFF","secondary-500": "#FFB620",blue: "#0095F6","logout-btn": "#FF5A5A","navbar-menu": "rgba(16, 16, 18, 0.6)","dark-1": "#000000","dark-2": "#121417","dark-3": "#101012","dark-4": "#1F1F22","light-1": "#FFFFFF","light-2": "#EFEFEF","light-3": "#7878A3","light-4": "#5C5C7B","gray-1": "#697C89",glassmorphism: "rgba(16, 16, 18, 0.60)",},boxShadow: {"count-badge": "0px 0px 6px 2px rgba(219, 188, 159, 0.30)","groups-sidebar": "-30px 0px 60px 0px rgba(28, 28, 31, 0.50)",},screens: {xs: "400px",},keyframes: {"accordion-down": {from: {height: 0},to: {height: "var(--radix-accordion-content-height)"},},"accordion-up": {from: {height: "var(--radix-accordion-content-height)"},to: {height: 0},},},animation: {"accordion-down": "accordion-down 0.2s ease-out","accordion-up": "accordion-up 0.2s ease-out",},},},plugins: [require("tailwindcss-animate")],};
thread.actions.ts
"use server";import{revalidatePath}from"next/cache";import{connectToDB}from"../mongoose";importUserfrom"../models/user.model";importThreadfrom"../models/thread.model";importCommunityfrom"../models/community.model";exportasyncfunctionfetchPosts(pageNumber=1,pageSize=20){connectToDB();// Calculate the number of posts to skip based on the page number and page size.constskipAmount=(pageNumber-1)*pageSize;// Create a query to fetch the posts that have no parent (top-level threads) (a thread that is not a comment/reply).constpostsQuery=Thread.find({parentId: {$in: [null,undefined]}}).sort({createdAt: "desc"}).skip(skipAmount).limit(pageSize).populate({path: "author",model: User,}).populate({path: "community",model: Community,}).populate({path: "children",// Populate the children fieldpopulate: {path: "author",// Populate the author field within childrenmodel: User,select: "_id name parentId image",// Select only _id and username fields of the author},});// Count the total number of top-level posts (threads) i.e., threads that are not comments.consttotalPostsCount=awaitThread.countDocuments({parentId: {$in: [null,undefined]},});// Get the total count of postsconstposts=awaitpostsQuery.exec();constisNext=totalPostsCount>skipAmount+posts.length;return{ posts, isNext };}interfaceParams{text: string,author: string,communityId: string|null,path: string,}exportasyncfunctioncreateThread({ text, author, communityId, path }: Params){try{connectToDB();constcommunityIdObject=awaitCommunity.findOne({id: communityId},{_id: 1});constcreatedThread=awaitThread.create({
text,
author,community: communityIdObject,// Assign communityId if provided, or leave it null for personal account});// Update User modelawaitUser.findByIdAndUpdate(author,{$push: {threads: createdThread._id},});if(communityIdObject){// Update Community modelawaitCommunity.findByIdAndUpdate(communityIdObject,{$push: {threads: createdThread._id},});}revalidatePath(path);}catch(error: any){thrownewError(`Failed to create thread: ${error.message}`);}}asyncfunctionfetchAllChildThreads(threadId: string): Promise<any[]>{constchildThreads=awaitThread.find({parentId: threadId});constdescendantThreads=[];for(constchildThreadofchildThreads){constdescendants=awaitfetchAllChildThreads(childThread._id);descendantThreads.push(childThread, ...descendants);}returndescendantThreads;}exportasyncfunctiondeleteThread(id: string,path: string): Promise<void>{try{connectToDB();// Find the thread to be deleted (the main thread)constmainThread=awaitThread.findById(id).populate("author community");if(!mainThread){thrownewError("Thread not found");}// Fetch all child threads and their descendants recursivelyconstdescendantThreads=awaitfetchAllChildThreads(id);// Get all descendant thread IDs including the main thread ID and child thread IDsconstdescendantThreadIds=[id,
...descendantThreads.map((thread)=>thread._id),];// Extract the authorIds and communityIds to update User and Community models respectivelyconstuniqueAuthorIds=newSet([
...descendantThreads.map((thread)=>thread.author?._id?.toString()),// Use optional chaining to handle possible undefined valuesmainThread.author?._id?.toString(),].filter((id)=>id!==undefined));constuniqueCommunityIds=newSet([
...descendantThreads.map((thread)=>thread.community?._id?.toString()),// Use optional chaining to handle possible undefined valuesmainThread.community?._id?.toString(),].filter((id)=>id!==undefined));// Recursively delete child threads and their descendantsawaitThread.deleteMany({_id: {$in: descendantThreadIds}});// Update User modelawaitUser.updateMany({_id: {$in: Array.from(uniqueAuthorIds)}},{$pull: {threads: {$in: descendantThreadIds}}});// Update Community modelawaitCommunity.updateMany({_id: {$in: Array.from(uniqueCommunityIds)}},{$pull: {threads: {$in: descendantThreadIds}}});revalidatePath(path);}catch(error: any){thrownewError(`Failed to delete thread: ${error.message}`);}}exportasyncfunctionfetchThreadById(threadId: string){connectToDB();try{constthread=awaitThread.findById(threadId).populate({path: "author",model: User,select: "_id id name image",})// Populate the author field with _id and username.populate({path: "community",model: Community,select: "_id id name image",})// Populate the community field with _id and name.populate({path: "children",// Populate the children fieldpopulate: [{path: "author",// Populate the author field within childrenmodel: User,select: "_id id name parentId image",// Select only _id and username fields of the author},{path: "children",// Populate the children field within childrenmodel: Thread,// The model of the nested children (assuming it's the same "Thread" model)populate: {path: "author",// Populate the author field within nested childrenmodel: User,select: "_id id name parentId image",// Select only _id and username fields of the author},},],}).exec();returnthread;}catch(err){console.error("Error while fetching thread:",err);thrownewError("Unable to fetch thread");}}exportasyncfunctionaddCommentToThread(threadId: string,commentText: string,userId: string,path: string){connectToDB();try{// Find the original thread by its IDconstoriginalThread=awaitThread.findById(threadId);if(!originalThread){thrownewError("Thread not found");}// Create the new comment threadconstcommentThread=newThread({text: commentText,author: userId,parentId: threadId,// Set the parentId to the original thread's ID});// Save the comment thread to the databaseconstsavedCommentThread=awaitcommentThread.save();// Add the comment thread's ID to the original thread's children arrayoriginalThread.children.push(savedCommentThread._id);// Save the updated original thread to the databaseawaitoriginalThread.save();revalidatePath(path);}catch(err){console.error("Error while adding comment:",err);thrownewError("Unable to add comment");}}
uploadthing.ts
// Resource: https://docs.uploadthing.com/api-reference/react#generatereacthelpers// Copy paste (be careful with imports)import{generateReactHelpers}from"@uploadthing/react/hooks";importtype{OurFileRouter}from"@/app/api/uploadthing/core";exportconst{ useUploadThing, uploadFiles }=generateReactHelpers<OurFileRouter>();
user.actions.ts
"use server";import{FilterQuery,SortOrder}from"mongoose";import{revalidatePath}from"next/cache";importCommunityfrom"../models/community.model";importThreadfrom"../models/thread.model";importUserfrom"../models/user.model";import{connectToDB}from"../mongoose";exportasyncfunctionfetchUser(userId: string){try{connectToDB();returnawaitUser.findOne({id: userId}).populate({path: "communities",model: Community,});}catch(error: any){thrownewError(`Failed to fetch user: ${error.message}`);}}interfaceParams{userId: string;username: string;name: string;bio: string;image: string;path: string;}exportasyncfunctionupdateUser({
userId,
bio,
name,
path,
username,
image,}: Params): Promise<void>{try{connectToDB();awaitUser.findOneAndUpdate({id: userId},{username: username.toLowerCase(),
name,
bio,
image,onboarded: true,},{upsert: true});if(path==="/profile/edit"){revalidatePath(path);}}catch(error: any){thrownewError(`Failed to create/update user: ${error.message}`);}}exportasyncfunctionfetchUserPosts(userId: string){try{connectToDB();// Find all threads authored by the user with the given userIdconstthreads=awaitUser.findOne({id: userId}).populate({path: "threads",model: Thread,populate: [{path: "community",model: Community,select: "name id image _id",// Select the "name" and "_id" fields from the "Community" model},{path: "children",model: Thread,populate: {path: "author",model: User,select: "name image id",// Select the "name" and "_id" fields from the "User" model},},],});returnthreads;}catch(error){console.error("Error fetching user threads:",error);throwerror;}}// Almost similar to Thead (search + pagination) and Community (search + pagination)exportasyncfunctionfetchUsers({
userId,
searchString ="",
pageNumber =1,
pageSize =20,
sortBy ="desc",}: {userId: string;searchString?: string;pageNumber?: number;pageSize?: number;sortBy?: SortOrder;}){try{connectToDB();// Calculate the number of users to skip based on the page number and page size.constskipAmount=(pageNumber-1)*pageSize;// Create a case-insensitive regular expression for the provided search string.constregex=newRegExp(searchString,"i");// Create an initial query object to filter users.constquery: FilterQuery<typeofUser>={id: {$ne: userId},// Exclude the current user from the results.};// If the search string is not empty, add the $or operator to match either username or name fields.if(searchString.trim()!==""){query.$or=[{username: {$regex: regex}},{name: {$regex: regex}},];}// Define the sort options for the fetched users based on createdAt field and provided sort order.constsortOptions={createdAt: sortBy};constusersQuery=User.find(query).sort(sortOptions).skip(skipAmount).limit(pageSize);// Count the total number of users that match the search criteria (without pagination).consttotalUsersCount=awaitUser.countDocuments(query);constusers=awaitusersQuery.exec();// Check if there are more users beyond the current page.constisNext=totalUsersCount>skipAmount+users.length;return{ users, isNext };}catch(error){console.error("Error fetching users:",error);throwerror;}}exportasyncfunctiongetActivity(userId: string){try{connectToDB();// Find all threads created by the userconstuserThreads=awaitThread.find({author: userId});// Collect all the child thread ids (replies) from the 'children' field of each user threadconstchildThreadIds=userThreads.reduce((acc,userThread)=>{returnacc.concat(userThread.children);},[]);// Find and return the child threads (replies) excluding the ones created by the same userconstreplies=awaitThread.find({_id: {$in: childThreadIds},author: {$ne: userId},// Exclude threads authored by the same user}).populate({path: "author",model: User,select: "name image _id",});returnreplies;}catch(error){console.error("Error fetching replies: ",error);throwerror;}}
utils.ts
import{typeClassValue,clsx}from"clsx";import{twMerge}from"tailwind-merge";// generated by shadcnexportfunctioncn(...inputs: ClassValue[]){returntwMerge(clsx(inputs));}// created by chatgptexportfunctionisBase64Image(imageData: string){constbase64Regex=/^data:image\/(png|jpe?g|gif|webp);base64,/;returnbase64Regex.test(imageData);}// created by chatgptexportfunctionformatDateString(dateString: string){constoptions: Intl.DateTimeFormatOptions={year: "numeric",month: "short",day: "numeric",};constdate=newDate(dateString);constformattedDate=date.toLocaleDateString(undefined,options);consttime=date.toLocaleTimeString([],{hour: "numeric",minute: "2-digit",});return`${time} - ${formattedDate}`;}// created by chatgptexportfunctionformatThreadCount(count: number): string{if(count===0){return"No Threads";}else{constthreadCount=count.toString().padStart(2,"0");constthreadWord=count===1 ? "Thread" : "Threads";return`${threadCount}${threadWord}`;}}

Assets used in the project are here

Advance your skills with Next.js 14 Pro Course

Enjoyed creating this project? Dive deeper into our PRO courses for a richer learning adventure. They're packed with detailed explanations, cool features, and exercises to boost your skills. Give it a go!

Project Banner

Accelerate your professional journey with the Expert Training program

And if you're hungry for more than just a course and want to understand how we learn and tackle tech challenges, hop into our personalized masterclass. We cover best practices, different web skills, and offer mentorship to boost your confidence. Let's learn and grow together!

Project Banner

About

Develop Threads, Next.js 13 app that skyrocketed to 100 million sign-ups in less than 5 days, and dethroned giants like Twitter, ChatGPT, and TikTok to become the fastest-growing app ever!

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages