Back to Blog
Blog

Building a Product Hunt Clone with Cosmic and Next.js

Cosmic AI's avatar

Cosmic AI

April 4, 2025

Hero image

Introduction

Product Hunt has become the go-to platform for discovering new products and services in tech. In this tutorial, we'll build a simplified Product Hunt clone using Next.js 15 (App Router) with TypeScript for the frontend and Cosmic as our headless CMS. You'll learn how to implement core features like product listings, upvoting, user comments, and product collections.

What we'll be building:

product-hunt-clone.png

Project Setup

Let's begin by creating a new Next.js project:

npx create-next-app product-hunt-clone cd product-hunt-clone

Choose the following options:

  • TypeScript: Yes
  • ESLint: Yes
  • Tailwind CSS: Yes
  • App Router: Yes
  • Import alias: Yes (use @/ as the import alias)

Next, let's install the Cosmic SDK:

npm install @cosmicjs/sdk

Setting Up Cosmic

First, create a new bucket in your Cosmic dashboard.

Next, we'll create a script to set up our content model and seed some initial data:

import { createBucketClient } from "@cosmicjs/sdk"; import { format } from "date-fns"; const cosmic = createBucketClient({ bucketSlug: process.env.COSMIC_BUCKET_SLUG || "", writeKey: process.env.COSMIC_WRITE_KEY || "", readKey: process.env.COSMIC_READ_KEY || "", }); async function uploadMedia(url: string, filename: string) { const response = await fetch(url); const buffer = await response.blob().then((b) => b.arrayBuffer()); const { media } = await cosmic.media.insertOne({ media: { originalname: filename, buffer: Buffer.from(buffer) }, }); return media; } async function seedObjectTypes() { if (!cosmic) { console.error("Cosmic client not configured"); return; } const types = [ { title: "Products", slug: "products", emoji: "🚀", metafields: [ { title: "Name", key: "name", type: "text", required: true }, { title: "Tagline", key: "tagline", type: "text", required: true }, { title: "Description", key: "description", type: "textarea", required: true, }, { title: "Image", key: "image", type: "file", required: true, media_validation_type: "image", }, { title: "Website URL", key: "website_url", type: "text", required: true, }, { title: "Categories", key: "categories", type: "objects", object_type: "categories", required: true, }, { title: "Upvotes", key: "upvotes", type: "number", default: 0 }, { title: "Launch Date", key: "launch_date", type: "date", required: true, }, { title: "Maker", key: "maker", type: "object", object_type: "makers" }, ], }, { title: "Categories", slug: "categories", emoji: "🏷️", metafields: [ { title: "Name", key: "name", type: "text", required: true }, { title: "Icon", key: "icon", type: "text", required: true }, ], }, { title: "Makers", slug: "makers", emoji: "👨‍💻", metafields: [ { title: "Name", key: "name", type: "text", required: true }, { title: "Avatar", key: "avatar", type: "file", required: true, media_validation_type: "image", }, { title: "Twitter URL", key: "twitter_url", type: "text" }, { title: "Website", key: "website", type: "text" }, ], }, { title: "Comments", slug: "comments", emoji: "💬", metafields: [ { title: "Content", key: "content", type: "textarea", required: true }, { title: "Product", key: "product", type: "object", object_type: "products", required: true, }, { title: "Author", key: "author", type: "object", object_type: "makers", required: true, }, { title: "Posted At", key: "posted_at", type: "date", required: true }, ], }, { title: "Collections", slug: "collections", emoji: "📚", metafields: [ { title: "Name", key: "name", type: "text", required: true }, { title: "Description", key: "description", type: "textarea", required: true, }, { title: "Products", key: "products", type: "objects", object_type: "products", required: true, }, { title: "Curator", key: "curator", type: "object", object_type: "makers", required: true, }, ], }, ]; await Promise.all(types.map((type) => cosmic.objectTypes.insertOne(type))); } async function seedContent() { // Upload media files const productImage1 = await uploadMedia( "https://images.unsplash.com/photo-1611162617213-7d7a39e9b1d7?w=800", "product1.jpg" ); const productImage2 = await uploadMedia( "https://imgix.cosmicjs.com/17f13d50-117f-11f0-91ec-af6adca2ead2-1779487.jpg?w=800", "product2.jpg" ); const avatar1 = await uploadMedia( "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200", "avatar1.jpg" ); const avatar2 = await uploadMedia( "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200", "avatar2.jpg" ); // Create categories const { object: tech } = await cosmic.objects.insertOne({ title: "Technology", type: "categories", metadata: { name: "Technology", icon: "💻", }, }); const { object: productivity } = await cosmic.objects.insertOne({ title: "Productivity", type: "categories", metadata: { name: "Productivity", icon: "⏱️", }, }); // Create makers const { object: maker1 } = await cosmic.objects.insertOne({ title: "Alex Johnson", type: "makers", metadata: { name: "Alex Johnson", avatar: avatar1.name, twitter_url: "https://twitter.com/alexjohnson", website: "https://alexjohnson.dev", }, }); const { object: maker2 } = await cosmic.objects.insertOne({ title: "Sam Chen", type: "makers", metadata: { name: "Sam Chen", avatar: avatar2.name, twitter_url: "https://twitter.com/samchen", website: "https://samchen.io", }, }); // Create products const { object: product1 } = await cosmic.objects.insertOne({ title: "TaskMaster Pro", type: "products", metadata: { name: "TaskMaster Pro", tagline: "The ultimate productivity tool for teams", description: "TaskMaster Pro is a comprehensive task management solution designed for teams of all sizes. With features like real-time collaboration, customizable workflows, and detailed analytics, it helps teams stay organized and efficient.", image: productImage1.name, website_url: "https://taskmasterpro.com", categories: [productivity.id], upvotes: 127, launch_date: format(new Date(), "yyyy-MM-dd"), maker: maker1.id, }, }); const { object: product2 } = await cosmic.objects.insertOne({ title: "SecureChat", type: "products", metadata: { name: "SecureChat", tagline: "End-to-end encrypted messaging for businesses", description: "SecureChat provides enterprise-grade encrypted communication channels for sensitive business conversations. With features like message expiration, screenshot prevention, and role-based access controls, it keeps your team's communications secure.", image: productImage2.name, website_url: "https://securechat.io", categories: [tech.id], upvotes: 85, launch_date: format(new Date(), "yyyy-MM-dd"), maker: maker2.id, }, }); // Create comments await cosmic.objects.insertOne({ title: "Comment on TaskMaster Pro", type: "comments", metadata: { content: "Been using TaskMaster Pro for a month now, and it's completely transformed our team's workflow. The kanban board feature is especially useful!", product: product1.id, author: maker2.id, posted_at: format(new Date(), "yyyy-MM-dd"), }, }); await cosmic.objects.insertOne({ title: "Comment on SecureChat", type: "comments", metadata: { content: "Our legal team loves SecureChat. The compliance features make it easy to meet our regulatory requirements while keeping communication efficient.", product: product2.id, author: maker1.id, posted_at: format(new Date(), "yyyy-MM-dd"), }, }); // Create collection await cosmic.objects.insertOne({ title: "Top Productivity Tools", type: "collections", metadata: { name: "Top Productivity Tools", description: "A curated collection of the best productivity tools for modern teams", products: [product1.id, product2.id], curator: maker1.id, }, }); } async function seed() { await seedObjectTypes(); await seedContent(); console.log( "✅ Cosmic bucket has been seeded with object types and demo content!" ); } seed();

Run the seed script to set up your Cosmic bucket with both object types and demo content:

npx ts-node scripts/seed-cosmic.ts

Environment Configuration

Create a .env.local file with your Cosmic credentials:

COSMIC_BUCKET_SLUG=your-bucket-slug COSMIC_READ_KEY=your-read-key COSMIC_WRITE_KEY=your-write-key

Creating Our API Service

Let's create a service to interact with the Cosmic API:

// lib/cosmic.ts import { createBucketClient } from "@cosmicjs/sdk"; export const cosmic = createBucketClient({ bucketSlug: process.env.COSMIC_BUCKET_SLUG || "", readKey: process.env.COSMIC_READ_KEY, }); export interface Maker { id: string; title: string; slug: string; metadata: { name: string; avatar: string; twitter_url?: string; website?: string; }; } export interface Category { id: string; title: string; slug: string; metadata: { name: string; icon: string; }; } export interface Product { id: string; title: string; slug: string; metadata: { name: string; tagline: string; description: string; image: string; website_url: string; categories: Category[]; upvotes: number; launch_date: string; maker: Maker; }; } export interface Comment { id: string; title: string; slug: string; metadata: { content: string; product: Product; author: Maker; posted_at: string; }; } export interface Collection { id: string; title: string; slug: string; metadata: { name: string; description: string; products: Product[]; curator: Maker; }; } export async function getProducts(limit = 10, skip = 0) { const { objects } = await cosmic.objects .find({ type: "products", }) .props("id,title,slug,metadata") .depth(2) .limit(limit) .skip(skip) .sort("-metadata.upvotes"); return objects as unknown as Product[]; } export async function getProductBySlug(slug: string) { const { object } = await cosmic.objects .findOne({ type: "products", slug, }) .props("id,title,slug,metadata") .depth(2); return object as unknown as Product; } export async function getCommentsByProductId(productId: string) { const { objects } = await cosmic.objects .find({ type: "comments", "metadata.product.id": productId, }) .props("id,title,slug,metadata") .depth(2) .sort("-metadata.posted_at"); return objects as unknown as Comment[]; } export async function getCategories() { const { objects } = await cosmic.objects .find({ type: "categories", }) .props("id,title,slug,metadata"); return objects as unknown as Category[]; } export async function getCollections() { const { objects } = await cosmic.objects .find({ type: "collections", }) .props("id,title,slug,metadata") .depth(2); return objects as unknown as Collection[]; } export async function upvoteProduct(productId: string) { const { object } = await cosmic.objects.findOne({ id: productId, }); if (!object) throw new Error("Product not found"); const currentUpvotes = object.metadata?.upvotes || 0; const { object: updatedObject } = await cosmic.objects.updateOne( { id: productId, }, { metadata: { ...object.metadata, upvotes: currentUpvotes + 1, }, } ); return updatedObject; }

Building the UI Components

Now let's create the necessary UI components:

Header Component

// components/header.tsx import Link from "next/link"; import { getCategories } from "@/lib/cosmic"; export async function Header() { const categories = await getCategories(); return ( <header className="sticky top-0 bg-white border-b z-10"> <div className="container mx-auto px-4 py-4 flex justify-between items-center"> <div className="flex items-center space-x-6"> <Link href="/" className="text-2xl font-bold text-red-500"> ProductHunt Clone </Link> <nav className="hidden md:flex space-x-4"> <Link href="/" className="hover:text-red-500"> Home </Link> <div className="relative group"> <button className="hover:text-red-500">Categories</button> <div className="absolute left-0 mt-2 w-48 bg-white shadow-lg rounded-md hidden group-hover:block"> {categories.map((category) => ( <Link key={category.id} href={`/categories/${category.slug}`} className="block px-4 py-2 hover:bg-gray-100" > <span className="mr-2">{category.metadata.icon}</span> {category.metadata.name} </Link> ))} </div> </div> <Link href="/collections" className="hover:text-red-500"> Collections </Link> </nav> </div> <div className="flex items-center space-x-4"> <button className="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600"> Submit </button> <button className="border border-gray-300 px-4 py-2 rounded-md hover:bg-gray-100"> Login </button> </div> </div> </header> ); }

ProductCard Component

// components/product-card.tsx "use client"; import Image from "next/image"; import Link from "next/link"; import { useState } from "react"; import { Product } from "@/lib/cosmic"; interface ProductCardProps { product: Product; } export function ProductCard({ product }: ProductCardProps) { const [upvotes, setUpvotes] = useState(product.metadata.upvotes); const [isUpvoting, setIsUpvoting] = useState(false); const handleUpvote = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (isUpvoting) return; setIsUpvoting(true); try { const response = await fetch(`/api/products/${product.id}/upvote`, { method: "POST", }); if (response.ok) { setUpvotes(upvotes + 1); } } catch (error) { console.error("Failed to upvote:", error); } finally { setIsUpvoting(false); } }; return ( <Link href={`/products/${product.slug}`}> <div className="border rounded-lg p-6 hover:shadow-md transition-shadow flex gap-6"> <div className="w-16 h-16 relative flex-shrink-0"> <Image src={product.metadata.image.imgix_url} alt={product.metadata.name} fill className="object-cover rounded-lg" /> </div> <div className="flex-grow"> <h3 className="text-lg font-bold">{product.metadata.name}</h3> <p className="text-gray-600">{product.metadata.tagline}</p> <div className="flex mt-2 gap-2"> {product.metadata.categories.map((category) => ( <span key={category.id} className="text-xs bg-gray-100 px-2 py-1 rounded" > {category.metadata.icon} {category.metadata.name} </span> ))} </div> </div> <button onClick={handleUpvote} disabled={isUpvoting} className="flex flex-col items-center justify-center bg-gray-100 px-3 py-2 rounded-md hover:bg-gray-200" > <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5" > <path d="m18 15-6-6-6 6" /> </svg> <span className="text-sm font-medium">{upvotes}</span> </button> </div> </Link> ); }

Building the Pages

Homepage

// app/page.tsx import { ProductCard } from "@/components/product-card"; import { Header } from "@/components/header"; import { getProducts } from "@/lib/cosmic"; export default async function Home() { const products = await getProducts(20); return ( <main> <Header /> <div className="container mx-auto px-4 py-8"> <h1 className="text-3xl font-bold mb-8"> Today&apos;s Featured Products </h1> <div className="space-y-4"> {products.map((product) => ( <ProductCard key={product.id} product={product} /> ))} </div> </div> </main> ); }

Product Detail Page

// app/products/[slug]/page.tsx import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; import { Header } from "@/components/header"; import { getProductBySlug, getCommentsByProductId } from "@/lib/cosmic"; import { UpvoteButton } from "./upvote-button"; import { format } from "date-fns"; import { CommentForm } from "./comment-form"; interface ProductPageProps { params: { slug: string; }; } export default async function ProductPage({ params }: ProductPageProps) { try { const product = await getProductBySlug(params.slug); const comments = await getCommentsByProductId(product.id); return ( <main> <Header /> <div className="container mx-auto px-4 py-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="md:col-span-2"> <div className="flex items-start gap-6"> <div className="w-24 h-24 relative flex-shrink-0"> <Image src={product.metadata.image.imgix_url} alt={product.metadata.name} fill className="object-cover rounded-lg" /> </div> <div> <h1 className="text-3xl font-bold"> {product.metadata.name} </h1> <p className="text-xl text-gray-600 mt-2"> {product.metadata.tagline} </p> <div className="flex mt-4 gap-2"> {product.metadata.categories.map((category) => ( <Link href={`/categories/${category.slug}`} key={category.id} className="text-sm bg-gray-100 px-3 py-1 rounded-full hover:bg-gray-200" > {category.metadata.icon} {category.metadata.name} </Link> ))} </div> </div> <UpvoteButton productId={product.id} initialUpvotes={product.metadata.upvotes} /> </div> <div className="mt-8"> <h2 className="text-xl font-bold mb-4">About this product</h2> <p className="text-gray-700 whitespace-pre-wrap"> {product.metadata.description} </p> </div> <div className="mt-12"> <div className="flex justify-between items-center mb-6"> <h2 className="text-xl font-bold"> Comments ({comments.length}) </h2> </div> <CommentForm productId={product.id} /> <div className="mt-8 space-y-6"> {comments.map((comment) => ( <div key={comment.id} className="border-b pb-6"> <div className="flex items-center gap-3"> <div className="w-10 h-10 relative"> <Image src={ comment.metadata.author.metadata.avatar.imgix_url } alt={comment.metadata.author.metadata.name} fill className="object-cover rounded-full" /> </div> <div> <h4 className="font-medium"> {comment.metadata.author.metadata.name} </h4> <p className="text-xs text-gray-500"> {format( new Date(comment.metadata.posted_at), "MMM d, yyyy" )} </p> </div> </div> <p className="mt-3 text-gray-700"> {comment.metadata.content} </p> </div> ))} </div> </div> </div> <div> <div className="border rounded-lg p-6 sticky top-24"> <a href={product.metadata.website_url} target="_blank" rel="noopener noreferrer" className="block w-full bg-red-500 text-white text-center py-3 rounded-md hover:bg-red-600 mb-6" > Visit Website </a> <div className="mb-6"> <h3 className="text-sm font-medium text-gray-500 mb-2"> LAUNCH DATE </h3> <p> {format( new Date(product.metadata.launch_date), "MMMM d, yyyy" )} </p> </div> <div> <h3 className="text-sm font-medium text-gray-500 mb-2"> MAKER </h3> <div className="flex items-center gap-3"> <div className="w-10 h-10 relative"> <Image src={product.metadata.maker.metadata.avatar.imgix_url} alt={product.metadata.maker.metadata.name} fill className="object-cover rounded-full" /> </div> <div> <h4 className="font-medium"> {product.metadata.maker.metadata.name} </h4> {product.metadata.maker.metadata.twitter_url && ( <a href={product.metadata.maker.metadata.twitter_url} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-500" > Twitter </a> )} </div> </div> </div> </div> </div> </div> </div> </main> ); } catch (error) { console.error(error); notFound(); } }

Upvote Button Component

// app/products/[slug]/upvote-button.tsx "use client"; import { useState } from "react"; interface UpvoteButtonProps { productId: string; initialUpvotes: number; } export function UpvoteButton({ productId, initialUpvotes }: UpvoteButtonProps) { const [upvotes, setUpvotes] = useState(initialUpvotes); const [isUpvoting, setIsUpvoting] = useState(false); const handleUpvote = async () => { if (isUpvoting) return; setIsUpvoting(true); try { const response = await fetch(`/api/products/${productId}/upvote`, { method: "POST", }); if (response.ok) { setUpvotes(upvotes + 1); } } catch (error) { console.error("Failed to upvote:", error); } finally { setIsUpvoting(false); } }; return ( <button onClick={handleUpvote} disabled={isUpvoting} className="flex flex-col items-center ml-auto bg-gray-100 px-4 py-2 rounded-md hover:bg-gray-200 transition-colors" > <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6" > <path d="m18 15-6-6-6 6" /> </svg> <span className="font-medium">{upvotes}</span> </button> ); }

Comment Form Component

// app/products/[slug]/comment-form.tsx "use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; interface CommentFormProps { productId: string; } export function CommentForm({ productId }: CommentFormProps) { const router = useRouter(); const [content, setContent] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!content.trim() || isSubmitting) return; setIsSubmitting(true); try { const response = await fetch(`/api/products/${productId}/comments`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content, // Note: In a real app, you'd get the actual user ID authorId: "mock-user-id", }), }); if (response.ok) { setContent(""); router.refresh(); } } catch (error) { console.error("Failed to post comment:", error); } finally { setIsSubmitting(false); } }; return ( <form onSubmit={handleSubmit} className="border rounded-lg p-4"> <textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="Share your thoughts about this product..." className="w-full border rounded-md p-3 min-h-[100px] focus:outline-none focus:ring-2 focus:ring-red-500" disabled={isSubmitting} /> <div className="flex justify-end mt-3"> <button type="submit" disabled={!content.trim() || isSubmitting} className="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 disabled:bg-gray-300 disabled:cursor-not-allowed" > {isSubmitting ? "Posting..." : "Post Comment"} </button> </div> </form> ); }

Collections Page

// app/collections/page.tsx import Image from "next/image"; import Link from "next/link"; import { Header } from "@/components/header"; import { getCollections } from "@/lib/cosmic"; export default async function CollectionsPage() { const collections = await getCollections(); return ( <main> <Header /> <div className="container mx-auto px-4 py-8"> <h1 className="text-3xl font-bold mb-8">Product Collections</h1> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {collections.map((collection) => ( <Link key={collection.id} href={`/collections/${collection.slug}`} className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow" > <div className="p-6"> <h2 className="text-xl font-bold mb-2"> {collection.metadata.name} </h2> <p className="text-gray-600 mb-4 line-clamp-2"> {collection.metadata.description} </p> <div className="flex items-center gap-3 mb-4"> <div className="w-8 h-8 relative"> <Image src={ collection.metadata.curator.metadata.avatar.imgix_url } alt={collection.metadata.curator.metadata.name} fill className="object-cover rounded-full" /> </div> <span className="text-sm"> Curated by{" "} <span className="font-medium"> {collection.metadata.curator.metadata.name} </span> </span> </div> <div className="flex -space-x-2"> {collection.metadata.products.slice(0, 3).map((product) => ( <div key={product.id} className="w-10 h-10 relative rounded-full border-2 border-white overflow-hidden" > <Image src={product.metadata.image.imgix_url} alt={product.metadata.name} fill className="object-cover" /> </div> ))} {collection.metadata.products.length > 3 && ( <div className="w-10 h-10 flex items-center justify-center bg-gray-100 rounded-full border-2 border-white text-xs font-medium"> +{collection.metadata.products.length - 3} </div> )} </div> </div> </Link> ))} </div> </div> </main> ); }

API Routes for Interactivity

Upvote API

// app/api/products/[id]/upvote/route.ts import { NextRequest, NextResponse } from "next/server"; import { upvoteProduct } from "@/lib/cosmic"; export async function POST( request: NextRequest, { params }: { params: { id: string } } ) { try { const productId = params.id; if (!productId) { return NextResponse.json( { error: "Product ID is required" }, { status: 400 } ); } const updatedProduct = await upvoteProduct(productId); return NextResponse.json( { success: true, upvotes: updatedProduct.metadata.upvotes }, { status: 200 } ); } catch (error) { console.error("Upvote error:", error); return NextResponse.json( { error: "Failed to upvote product" }, { status: 500 } ); } }

Comments API

// app/api/products/[id]/comments/route.ts import { NextRequest, NextResponse } from "next/server"; import { cosmic } from "@/lib/cosmic"; export async function POST( request: NextRequest, { params }: { params: { id: string } } ) { try { const productId = params.id; if (!productId) { return NextResponse.json( { error: "Product ID is required" }, { status: 400 } ); } const { content, authorId } = await request.json(); if (!content || !authorId) { return NextResponse.json( { error: "Content and author ID are required" }, { status: 400 } ); } // In a real app, you'd get the actual author from the session // This is just a mock implementation const { object: comment } = await cosmic.objects.insertOne({ title: `Comment on product ${productId}`, type: "comments", metadata: { content, product: productId, author: authorId, posted_at: new Date().toISOString(), }, }); return NextResponse.json({ success: true, comment }, { status: 201 }); } catch (error) { console.error("Comment error:", error); return NextResponse.json( { error: "Failed to post comment" }, { status: 500 } ); } }

Building the Category Page

// app/categories/[slug]/page.tsx import { notFound } from "next/navigation"; import { Header } from "@/components/header"; import { ProductCard } from "@/components/product-card"; import { cosmic } from "@/lib/cosmic"; import type { Category, Product } from "@/lib/cosmic"; interface CategoryPageProps { params: { slug: string; }; } export default async function CategoryPage({ params }: CategoryPageProps) { try { // Get the category const { object: category } = await cosmic.objects .findOne({ type: "categories", slug: params.slug, }) .props("id,title,slug,metadata"); if (!category) return notFound(); // Get products in this category const { objects: products } = await cosmic.objects .find({ type: "products", "metadata.categories.slug": params.slug, }) .props("id,title,slug,metadata") .depth(2) .sort("-metadata.upvotes"); return ( <main> <Header /> <div className="container mx-auto px-4 py-8"> <div className="flex items-center mb-8"> <span className="text-4xl mr-4"> {(category as Category).metadata.icon} </span> <h1 className="text-3xl font-bold"> {(category as Category).metadata.name} </h1> </div> <div className="space-y-4"> {products.length > 0 ? ( products.map((product) => ( <ProductCard key={product.id} product={product as unknown as Product} /> )) ) : ( <p className="text-gray-500 text-center py-10"> No products found in this category yet. </p> )} </div> </div> </main> ); } catch (error) { console.error(error); notFound(); } }

Collection Detail Page

// app/collections/[slug]/page.tsx import Image from "next/image"; import { notFound } from "next/navigation"; import { Header } from "@/components/header"; import { ProductCard } from "@/components/product-card"; import { cosmic } from "@/lib/cosmic"; import type { Collection, Product } from "@/lib/cosmic"; interface CollectionPageProps { params: { slug: string; }; } export default async function CollectionPage({ params }: CollectionPageProps) { try { // Get the collection const { object: collection } = await cosmic.objects .findOne({ type: "collections", slug: params.slug, }) .props("id,title,slug,metadata") .depth(2); if (!collection) return notFound(); const typedCollection = collection as unknown as Collection; return ( <main> <Header /> <div className="container mx-auto px-4 py-8"> <div className="mb-8"> <h1 className="text-3xl font-bold mb-3"> {typedCollection.metadata.name} </h1> <p className="text-lg text-gray-600 mb-6"> {typedCollection.metadata.description} </p> <div className="flex items-center gap-3"> <div className="w-10 h-10 relative"> <Image src={ typedCollection.metadata.curator.metadata.avatar.imgix_url } alt={typedCollection.metadata.curator.metadata.name} fill className="object-cover rounded-full" /> </div> <div> <p className="font-medium"> {typedCollection.metadata.curator.metadata.name} </p> <p className="text-sm text-gray-500">Curator</p> </div> </div> </div> <div className="space-y-4"> {typedCollection.metadata.products.map((product) => ( <ProductCard key={product.id} product={product as Product} /> ))} </div> </div> </main> ); } catch (error) { console.error(error); notFound(); } }

Conclusion

In this tutorial, we've built a functional Product Hunt clone using Next.js and Cosmic. We've implemented core features like:

  1. Product listings with upvoting functionality
  2. Product detail pages with comments
  3. Categories and collections for product organization
  4. API routes for interactive features

The application demonstrates how Cosmic's headless CMS can be used to power complex web applications with rich content models and relationships between different content types.

Next Steps

To continue improving this application, you could:

  1. Add user authentication with NextAuth.js
  2. Implement product submission functionality
  3. Add search capabilities
  4. Create user profiles
  5. Add analytics to track product popularity
  6. Implement email notifications for new products or comments

The flexibility of Cosmic's content model allows you to easily extend this application with additional features as needed.

You can find more developer resources at the Cosmic documentation and explore ready-to-use templates in the Cosmic templates gallery.

Hero image