Product Hunt has revolutionized how we discover new products, creating an active community of builders and enthusiasts. The platform thrives on real-time collaboration, from discussing a product’s potential to sharing feedback with product creators.
Collaboration on Product Hunt happens only through comments on posts, which can be limiting to users who want more ways to collaborate with others. For example, users may want to know which users are online or get notified when they’re mentioned in a comment.
Adding more powerful collaboration features will satisfy those users.
This article guides you through building a Product Hunt clone with real-time collaboration features. You’ll implement real-time commenting, live user presence, and in-app notifications.
The best part? You won’t write a single line of backend code.
Tech stack and project setup
This tutorial uses the following tech stack:
- Next.js: a full-stack React framework for building the app.
- Velt: for adding the collaboration features.
- Shadcn: UI component library for faster development.
- Zustand: for managing user state.
Project setup
Use the following command to scaffold a Next.js project:
npx create-next-app@latest my-app --yes
The --yes flag skips the prompts and creates a new Next.js project with saved preferences and defaults. The default setup enables TypeScript, Tailwind, ESLint, App Router, and Turbopack, with import alias @/*. This setup is enough for this tutorial.
Next, install the necessary packages for your app.
Run the following command to install Velt and Zustand:
npm install zustand @veltdev/react
Here’s what each package is for:
-
zustand: the Zustand library for state management. -
@veltdev/react: Velt SDK for React-based frameworks.
Initialize Shadcn using the following command:
npx shadcn@latest init
You’ve set up your project. In the next sections, you’ll build the Product Hunt clone.
But before that, what’s Velt, and why should you use Velt?
Why use Velt
If you’ve built a collaborative web app from the ground up, you know how much complexity goes into building the collaboration features.
You have to set up servers, manage Websocket connections and synchronization, and handle the UI state changes.
Handling these tasks yourself will slow down your development process.
Velt solves this problem for you. Velt provides ready-made full-stack components that allow you to add real-time collaboration features to your app without writing any backend code. Velt handles the UI and backend synchronization, storage, and push notifications. You get to focus on the core features that make your app unique.
Building the Product Hunt clone
In this section, you’ll build the project.
Note: The code snippets in this tutorial focus on the important parts of building the Product Hunt clone. The UI and styling parts will be skipped. For the full working code, check out the repo.
Configuring the Zustand store for user management
Create a new folder, helper, in the root of your product. Inside the helper folder, create a new file, userdb.ts.
Add the following code to the userdb.ts file:
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type User = {
uid: string;
displayName: string;
email: string;
photoUrl?: string;
};
export interface UserStore {
user: User | null;
setUser: (user: User) => void;
}
export const userIds = ["user001", "user002"];
export const names = ["Nany", "Mary"];
export const useUserStore = create<UserStore>()(
persist(
(set) => ({
user: null,
setUser: (user) => set({ user }),
}),
{
name: "user-storage",
}
)
);
Let’s understand what’s happening in the code:
-
userIdsandnamesare arrays of hardcoded users. In your app, you’d get your users from your auth function or database. -
createis a function from Zustand for creating a Zustand store. A Zustand store is a centralized location for managing your application’s state. The Zustand store has the shape of theUserStoreinterface. -
persist, from Zustand, is used to “persist” users in the browser’slocalStorage. -
useUserStoreis the hook you’ll use to interact with your Zustand store.
Building the components for the real-time collaboration features
As I mentioned earlier, the code snippets focus on the app's key features.
In this section, you’ll build the components where you’ll add the collaboration features.
The two components you’ll focus on are the Header and CommentsSection components.
Creating the Header component
Inside the components folder, create a header.tsx file.
Add the following code:
"use client";
import Link from "next/link";
import { ChevronDown, Search, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useUserStore } from "@/helper/userdb";
import { Input } from "./ui/input";
import { ThemeToggleButton } from "../hooks/theme-toggle";
export function Header() {
const { user } = useUserStore();
const navigationItems = [
{ name: "Products", href: "/" },
{ name: "Topics", href: "/topics" },
{ name: "Collections", href: "/collections" },
];
return (
<header className="mx-auto sticky top-0 z-50 border-b border bg-background/95">
<div className="w-full max-w-7xl mx-auto flex h-16 gap-6 items-center">
<div className="flex flex-1 justify-between items-center space-x-4">
<Link href="/" className="flex items-center space-x-2">
<div className="h-8 w-8 bg-orange-500 flex items-center justify-center rounded-full">
<span className="text-white font-bold text-lg">P</span>
</div>
</Link>
<div className="relative max-w-32 mr-8">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input placeholder="Search" className="pl-10 bg-gray-50 text-sm rounded-full" />
</div>
<nav className="hidden md:flex items-center space-x-6">
{navigationItems.map((item) => (
<div key={item.name} className="text-sm font-medium flex items-center gap-2">
{item.name}
<ChevronDown className="text-gray-400 h-4 w-4" />
</div>
))}
</nav>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="hidden sm:inline-flex">Submit</Button>
<ThemeToggleButton />
<Avatar className="w-8 h-8">
<AvatarImage src={user?.photoUrl} alt={user?.displayName} />
<AvatarFallback>{user?.displayName?.charAt(0)}</AvatarFallback>
</Avatar>
</div>
</div>
</header>
);
}
Let's break down what's happening in this code:
- You defined a
Headercomponent that renders the main navigation bar similar to Product Hunt. The component uses anavigationItemsarray containing route definitions. - The component uses the
useUserStorehook to access the current user data from your Zustand store. This provides user information for the avatar display without complex state management. - The header is structured with three main sections:
- Logo & branding: A Product Hunt-inspired orange "P" logo wrapped in a Next.js Link component for navigation
- Search & navigation: A search input with Lucide React search icon and a horizontal navigation menu with dropdown indicators
- User actions: Submission button, theme toggle, and user avatar for profile management
This component creates a fully functional Product Hunt-style header with essential navigation elements, UI controls, and responsive behavior.
Creating the CommentsSection component
The CommentsSection component is the discussion area, where users can add and reply to comments.
Create a comments-section.tsx file inside the components folder and add the following code:
"use client";
import { MessageCircle, Heart, Flag, MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
const mockComments = [
{
id: "1",
author: {
name: "Szymon Pruszyński",
avatar: "https://avatar.iran.liara.run/public/60",
role: "Maker",
},
content: "👋 Hey PH community! I'm a co-founder and COO@Your Next Store",
timestamp: "2 hours ago",
likes: 12,
},
];
export function CommentSection({ productId }: { productId: string }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<MessageCircle className="h-5 w-5" />
<span>Discussion</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{mockComments.map((comment) => (
<div key={comment.id} className="flex items-start space-x-3">
<Avatar className="h-10 w-10">
<AvatarImage src={comment.author.avatar} alt={comment.author.name} />
<AvatarFallback>{comment.author.name.split(" ").map(n => n[0]).join("")}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<div className="flex items-center space-x-2">
<span className="font-medium text-sm">{comment.author.name}</span>
<Badge variant="outline" className="text-xs bg-orange-50 text-orange-600 border-orange-200">
{comment.author.role}
</Badge>
<span className="text-xs text-muted-foreground">{comment.timestamp}</span>
</div>
<div className="text-sm text-foreground">{comment.content}</div>
<div className="flex items-center space-x-4">
<Button variant="ghost" size="sm" className="h-8 px-2">
<Heart className="h-4 w-4 mr-1" />
<span className="text-xs">{comment.likes}</span>
</Button>
<Button variant="ghost" size="sm" className="h-8 px-2">
<MessageCircle className="h-4 w-4 mr-1" />
<span className="text-xs">Reply</span>
</Button>
</div>
</div>
</div>
))}
</CardContent>
</Card>
);
}
Let's break down what's happening in this code:
- You defined a
CommentSectioncomponent that renders a discussion interface similar to Product Hunt. The component uses amockCommentsarray containing sample comment data. In your app, you'd fetch these comments from your database based on theproductIdprop. - You mapped through the
mockCommentsarray to render each comment with essential user information and engagement features. For each comment, the component displays:- Author avatar using the
Avatarcomponent with fallback initials. - Author name with a role badge (like "Maker").
- Comment timestamp and content.
- Engagement buttons for liking and replying to comments.
- Author avatar using the
This component creates a complete comment section interface that mimics Product Hunt's discussion area, providing users with a familiar environment for product conversations and community engagement.
Now you’re done with creating the Header and CommentsSection components, you’ll implement the collaboration features in the next section.
Implementing the collaboration features
In this section, you’ll implement the collaboration features using Velt. Before you add the collaboration features, you need to understand how Velt works.
First, you need an API key to integrate Velt with your application. Go to your Velt console and get your API key.
You have to upgrade to the paid version to use Velt in production.
Add your API key in your .env file:
NEXT_PUBLIC_VELT_ID="your-api-key"
Next, wrap your app in the VeltProvider component and pass in your API key.
<VeltProvider apiKey={process.env.NEXT_PUBLIC_VELT_ID!}>
<div className="min-h-screen bg-background">
<Header />
<main className="max-w-7xl px-16 max-md:px-3 max-md:container mx-auto py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-8">
<ProductHero product={productData} />
<ProductTabs product={productData} />
<CommentSection productId={productData.id} />
</div>
{/* Sidebar */}
<div className="space-y-6">
<ProductSidebar product={productData} />
<SimilarProducts />
</div>
</div>
</main>
</div>
</VeltProvider>
After that, you need:
- An authentication system that allows Velt to identify your app users.
- To initialize a document. A document is a collaborative space where users can interact.
Without these, Velt won’t work.
For the authentication system of this tutorial, we’ll use the Zustand store and the mock user database created earlier. In your application, your auth system will be incorporated into your auth flow.
With that out of the way, let’s get into adding the collaboration features.
The logic for authentication will be in the Header component. In your app, you can separate the logic into its own component.
Update the header.tsx file with the following code:
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useVeltClient } from "@veltdev/react";
import { names, userIds, useUserStore } from "@/helper/userdb";
export function Header() {
const { user, setUser } = useUserStore();
const { client } = useVeltClient();
const prevUserRef = useRef(user);
const isInitializingRef = useRef(false);
const predefinedUsers = useMemo(
() =>
userIds.map((uid, index) => ({
uid: uid,
displayName: names[index],
email: `${names[index].toLowerCase()}@gmail.com`,
photoUrl: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${names[index]}`,
})),
[]
);
// Initialize first user if none exists
useEffect(() => {
if (typeof window !== "undefined" && !user) {
const storedUser = localStorage.getItem("user-storage");
if (!storedUser) {
setUser(predefinedUsers[0]);
}
}
}, [user, setUser, predefinedUsers]);
// Velt user authentication
useEffect(() => {
if (!client || !user || isInitializingRef.current) return;
const initializeVelt = async () => {
isInitializingRef.current = true;
try {
const isUserSwitch = prevUserRef.current?.uid !== user.uid;
prevUserRef.current = user;
const veltUser = {
userId: user.uid,
name: user.displayName,
email: user.email,
photoUrl: user.photoUrl,
};
await client.identify(veltUser);
await client.setDocuments([{ id: "product-hunt-velt" }]);
} catch (error) {
console.error("Error initializing Velt:", error);
} finally {
isInitializingRef.current = false;
}
};
initializeVelt();
}, [client, user]);
// rest of code
}
Let's break down what's happening in this code:
- You updated the
Headercomponent to handle Velt user authentication and initialization. The component uses apredefinedUsersarray containing mock user data. In your app, you'd typically fetch authenticated users from your backend or authentication service. - The component uses the
useVeltClienthook to access the Velt client instance anduseUserStoreto manage application user state. This is the foundation for connecting your users with Velt's collaboration system. - You implemented two key
useEffecthooks for user management:-
User initialization: Checks
localStoragefor existing user data and sets a default user if none exists, ensuring there's always an active user for collaboration features. - Velt authentication: Detects when the Velt client is available and a user is selected, then automatically handles the Velt identification process.
-
User initialization: Checks
- The Velt authentication process includes several important steps:
-
User identification: Creates a
veltUserobject with the current user's details and callsclient.identify(veltUser)to register them with Velt's real-time system. Velt requires these fields:userId,name,email,organization, andphotoUrlto identify users. -
Document setup: Configures the collaboration context using
client.setDocuments()with a unique document ID for your Product Hunt clone. - User switching: Detects when users change and automatically re-authenticates them with Velt, which means collaboration features work seamlessly across user sessions.
-
User identification: Creates a
- The component includes safety mechanisms like
isInitializingRefto prevent duplicate authentication calls and comprehensive error handling to ensure robust collaboration functionality.
This code creates a complete Velt authentication system that automatically manages user identification, document context, and real-time collaboration setup, providing the foundation for all collaborative features in your Product Hunt clone.
Now that you’re done with setting up the auth system, you’ll add comments, live user presence, and notification features in the next section.
Adding comments
In this subsection, you’ll add commenting to your app.
Update the header.tsx file to add the VeltCommentsSidebar and VeltSidebar components.
// header.tsx
"use client";
// REMOVED: Unused imports for user authentication and state management
import { VeltCommentsSidebar, VeltSidebarButton } from "@veltdev/react";
import useTheme from "../hooks/theme-toggle";
export function Header() {
// REMOVED: User authentication logic, Velt client initialization, and user management
const { theme } = useTheme();
return (
<header className="mx-auto sticky top-0 z-50 border-b border bg-background/95">
<div className="w-full max-w-7xl max-md:px-3 max-md:container mx-auto flex h-16 gap-1 lg:gap-6 items-center space-x-4 sm:justify-between sm:space-x-0">
{/* REMOVED: Logo, search, and navigation menu sections */}
<div className="flex items-center justify-between lg:space-x-4">
{/* REMOVED: VeltPresence and VeltNotificationsTool components */}
{/* Velt Comments Sidebar - Displays all comments in a collapsible panel */}
<VeltCommentsSidebar darkMode={theme === "dark"} />
{/* REMOVED: User dropdown, submit button, and theme toggle */}
{/* Velt Sidebar Button - Toggles the comments sidebar visibility */}
<VeltSidebarButton darkMode={theme === "dark"} className="m-0" />
</div>
</div>
</header>
);
}
Let's break down what's happening in this code:
- You updated the
Headercomponent to integrate Velt's comment UI components. The component uses theuseThemehook to determine the current theme for proper dark/light mode styling of the Velt components. - The component renders two key Velt collaboration elements:
-
VeltCommentsSidebar: A collapsible panel that displays all comments and discussions happening on the current page. This provides users with a centralized view of all collaboration activity. -
VeltSidebarButton: A toggle button that controls the visibility of the comments sidebar, allowing users to show or hide the collaboration panel as needed.
-
- Both Velt components are configured with the
darkModeprop that automatically adapts their styling based on your app's current theme.
Update the CommentsSection component to allow users to add inline comments.
// comments-section.tsx
"use client";
// REMOVED: Unused imports for mock comments and UI components
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { VeltInlineCommentsSection } from "@veltdev/react";
import useTheme from "../../hooks/theme-toggle";
interface CommentSectionProps {
productId: string;
}
export function CommentSection({ productId }: CommentSectionProps) {
// REMOVED: Mock comments data, state management, and scroll behavior
const { theme } = useTheme();
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
{/* REMOVED: MessageCircle icon */}
<span>Discussion</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Velt Inline Comments Section - Enables commenting on specific page elements */}
<VeltInlineCommentsSection
targetElementId="discussion-container"
sortBy="createdAt"
sortOrder="desc"
darkMode={theme === "dark"}
multiThread
/>
{/* REMOVED: Manual comments list rendering and individual comment components */}
{/* Target element for inline comments - Velt will attach comments to this section */}
<section id="discussion-container">
{/* Content that users can comment on inline */}
</section>
</CardContent>
</Card>
);
}
Let's break down what's happening in this code:
- You update the
CommentSectioncomponent to integrate Velt's inline commenting system. The component uses theuseThemehook to ensure the commenting interface matches your application's dark/light theme. - You added the
VeltInlineCommentsSectioncomponent with several key configuration properties:-
targetElementId: Set to "discussion-container" to link comments to the specific section where users can discuss the product. -
sortByandsortOrder: Configure how comments are organized (newest first by creation date). -
darkMode: Automatically adapts the commenting UI to match your application's theme. -
multiThread: Enables threaded conversations where users can reply to specific comments.
-
- The target
<section id="discussion-container">element serves as the anchor point for Velt's inline commenting system. The inline comments are bound to the element. - This integration provides a complete, real-time commenting system that allows users to:
- Add comments to specific parts of the product discussion
- Reply to existing comments in threaded conversations
- See other users' comments in real-time
- Collaborate seamlessly without page refreshes
These three Velt components replace what would normally require complex backend development for comment storage, real-time synchronization, and user interface creation, providing professional-grade collaboration features with minimal code.
In the next subsection, you’ll add live user presence to your clone.
Adding Presence
The Presence feature allows users to see the avatars of users who are online/viewing a document.
Update the Header component to add the Presence feature.
// header.tsx
"use client";
// REMOVED: Unused imports for user authentication and state management
import {
VeltCommentsSidebar,
VeltSidebarButton,
VeltPresence
} from "@veltdev/react";
import useTheme from "../hooks/theme-toggle";
export function Header() {
// REMOVED: User authentication logic, Velt client initialization, and user management
const { theme } = useTheme();
return (
<header className="mx-auto sticky top-0 z-50 border-b border bg-background/95">
<div className="w-full max-w-7xl max-md:px-3 max-md:container mx-auto flex h-16 gap-1 lg:gap-6 items-center space-x-4 sm:justify-between sm:space-x-0">
{/* REMOVED: Logo, search, and navigation menu sections */}
<div className="flex items-center justify-between lg:space-x-4">
{/* Velt Presence - Shows active users with avatars */}
<VeltPresence />
{/* REMOVED: VeltNotificationsTool component */}
{/* Velt Comments Sidebar - Displays all comments in a collapsible panel */}
<VeltCommentsSidebar darkMode={theme === "dark"} />
{/* REMOVED: User dropdown, submit button, and theme toggle */}
{/* Velt Sidebar Button - Toggles the comments sidebar visibility */}
<VeltSidebarButton darkMode={theme === "dark"} className="m-0" />
</div>
</div>
</header>
);
}
VeltPresence displays avatars of all currently active users viewing the same product page, providing real-time awareness of who's online.
In the next subsection, you’ll add notifications.
Adding In-App Notification
In-app Notifications allow users to receive email notifications when they’re mentioned in a document. To add notifications to your app, you’ll have to enable notifications in your Velt console.
Go to the Notifications section in the Configurations section of the Velt Console and enable Notifications.
Update your header.tsx file to add the VeltNotificationTool, which is the component for toggling the notification panel.
// header.tsx
"use client";
import {
VeltCommentsSidebar,
VeltSidebarButton,
VeltPresence,
VeltNotificationsTool
} from "@veltdev/react";
import useTheme from "../hooks/theme-toggle";
export function Header() {
const { theme } = useTheme();
return (
<header className="mx-auto sticky top-0 z-50 border-b border bg-background/95">
<div className="w-full max-w-7xl max-md:px-3 max-md:container mx-auto flex h-16 gap-1 lg:gap-6 items-center space-x-4 sm:justify-between sm:space-x-0">
<div className="flex items-center justify-between lg:space-x-4">
{/* Velt Presence - Shows active users with avatars */}
<VeltPresence />
{/* Velt Notifications Tool - Displays notification bell for collaboration alerts */}
<VeltNotificationsTool darkMode={theme === "dark"} />
{/* Velt Comments Sidebar - Displays all comments in a collapsible panel */}
<VeltCommentsSidebar darkMode={theme === "dark"} />
{/* Velt Sidebar Button - Toggles the comments sidebar visibility */}
<VeltSidebarButton darkMode={theme === "dark"} className="m-0" />
</div>
</div>
</header>
);
}
The VeltNotificationsTool opens the Notifications Panel that shows all Notifications grouped in categories.
Demo
Start your development server using the following command:
npm run dev
Open your browser, and you should see something like this:
Conclusion
You’ve built a Product Hunt clone with real-time collaborations: Comments, Presence, and in-app Notifications.
The best part is that you didn’t write any backend logic. Velt handles the backend logic, letting you focus on the core features of your app.
If you're working on your own community platform or SaaS product, you can extend your app further by adding other powerful Velt features like screen recording, cursor tracking, reactions, and real-time huddles. Adding these features can make your app more robust and give your users an even better collaborative experience.


Top comments (0)