You can build a Gumroad clone using Next.js and Whop's infrastructure and it's easier than ever. In this tutorial, we'll walk you through building a marketplace with file uploads via UploadThing, payments via Whop Payments Network, user authentication with Whop OAuth, and more.

Key takeaways

  • Next.js combined with Whop's payment network and OAuth enables building a full multi-seller digital marketplace.
  • Whop handles both seller onboarding with connected accounts and buyer payments with automatic platform fee splits.
  • A deploy-first approach with Vercel and Neon integration streamlines environment setup and database management.
Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

Building a Gumroad clone where users can browse the marketplace and buy files and links from creators, or become a seller themselves and earn money, can be done by using Next.js and Whop infrastructure.

In this tutorial, we're going to build such clone (which we'll call Shelfie). A marketplace where users sign up, become sellers, upload digital products, set their own prices, and publish to the marketplace.

Buyers in the platform can browse, purchase, and download products they bought. Our platform is also going to take a 5% cut of every sale.

You can preview the demo of our project here, and see the full GitHub repository here.

Project overview

Before we dive deep into coding, let's take a general look at our project:

  • Multi-seller marketplace where any user can become a seller through Whop's connected account flow
  • Product creation with file uploads where sellers upload files via UploadThing, add descriptions, set prices, and publish when ready
  • Marketplace discovery with search, category filters, pagination, and trending products
  • One-time purchases where sellers set a price and buyers pay through the Whop Payments Network
  • Access-gated downloads where buyers get instant access to files, text content, and external links after purchase
  • Rating system where buyers rate products on a 1-5 cookie scale
  • Seller and buyer dashboards with earnings, product management, bio editing, and purchase history

Tech Stack

  • Next.js - Server Components, API routes, and Vercel deployment in one framework
  • React - Server Components for data fetching, Client Components for interactivity
  • Tailwind CSS - CSS-first configuration with @theme blocks, no config file
  • Whop OAuth - Sign-in and identity for both sellers and buyers
  • Whop Payments Network - Connected accounts for seller onboarding, direct charges with application fees for payment splits
  • Neon - Serverless Postgres via the Vercel integration. Auto-populated connection strings
  • Prisma - ESM-only ORM with @prisma/adapter-pg for Neon compatibility. Client generated into src/generated/prisma
  • UploadThing - File uploads with typed routes, auth middleware, and CDN delivery
  • Zod - Runtime validation for env vars, API inputs, and form data
  • iron-session - Encrypted cookie sessions. No session store, no Redis
  • Vercel - Deployment with automatic builds from GitHub

Pages

  • / - Landing page (hero, search, trending products, categories, seller CTA)
  • /sign-in - Sign-in card with Whop button
  • /products - Browse/search products with category filter, pagination
  • /products/[slug] - Product detail: description, file list, seller info, purchase card, cookie ratings
  • /products/[slug]/download - Post-purchase download page (access-gated)
  • /sellers/[username] — Seller profile: bio, stats, published products
  • /sell - Become a seller: pitch + connect Whop account
  • /sell/kyc-return - KYC completion handler (redirects to dashboard)
  • /sell/dashboard - Seller dashboard: products, earnings, bio editing, payout portal
  • /sell/products/new - Create new product form
  • /sell/products/[productId]/edit - Edit product: info, files, thumbnail, publish/unpublish/delete
  • /dashboard - Buyer dashboard: purchased products, download links

API routes

  • /api/auth/login - OAuth initiation (PKCE)
  • /api/auth/callback - OAuth callback + user upsert
  • /api/auth/logout - Session destroy
  • /api/sell/onboard - Create connected account Company + KYC (sandbox: auto-complete)
  • /api/sell/complete-kyc - Mark KYC as complete (called from kyc-return page)
  • /api/sell/profile - PATCH: update seller headline and bio
  • /api/sell/products - POST: create product
  • /api/sell/products/[productId] - PATCH: update (with file add/remove), DELETE: remove product
  • /api/sell/products/[productId]/publish - POST: publish product (create Whop checkout config)
  • /api/sell/products/[productId]/unpublish - POST: revert to draft
  • /api/products/[productId]/purchase - POST: free product purchase
  • /api/products/[productId]/like - POST: toggle like
  • /api/products/[productId]/rate - POST: cookie rating (0.5-5)
  • /api/uploadthing - UploadThing file upload endpoint
  • /api/webhooks/whop - POST: Whop payment webhooks

Payment flow

  1. Seller clicks "Get Started" and creates a connected account through Whop's hosted KYC flow
  2. Seller publishes a product. The app creates a Whop product and checkout configuration with a 5% application fee
  3. Buyer clicks "Buy Now" and pays through Whop's hosted checkout
  4. Whop fires a payment.succeeded webhook. The app creates a Purchase record
  5. Seller manages payouts through Whop's dashboard

Why we use Whop

While building this project, we're going to face two important problems to solve: the payments system and the user authentication - Whop is going to help us solve them easily:

  • For the payments, we're going to use the Whop Payments Network for the out-of-the-box solution for marketplace payments.
  • For user authentication, we're going to use Whop OAuth to integrate a user authentication system for both sellers and buyers, allowing us to focus on building instead of authentication security and credential storage.

What you need to start

Before starting this project, you need:

  • A Whop sandbox account (create at sandbox.whop.com)
  • A Vercel account
  • Working familiarity with Next.js and React
  • An UploadThing account

Part 1: Scaffold, deploy, and authenticate

In this project, we're going to take a deploy-first approach. This means we get a real production URL, which we'll use later. First, let's scaffold a new Next.js project.

Scaffold a Next.js project

Go to the directory you want to build your project in and run the command below:

Terminal
npx create-next-app@latest shelfie --ts --tailwind --eslint --app --src-dir --turbopack --import-alias "@/*"

Then, install all dependencies we'll use in the project:

Terminal
npm install @whop/sdk @prisma/client @prisma/adapter-pg pg iron-session zod lucide-react next-themes clsx tailwind-merge dotenv uploadthing @uploadthing/react
npm install -D prisma @types/pg@8.11.11

Deploy to Vercel

Now, let's deploy everything to GitHub and connect to Vercel:

  1. Use the command git init && git add . && git commit -m "scaffold" (you can make a private repo if you want) and push to a new GitHub repository
  2. Import the repo at vercel.com/new and take note of your production URL
  3. Set NEXT_PUBLIC_APP_URL to your Vercel URL (e.g. https://shelfie-xyz.vercel.app)

The first deploy will succeed with the default Next.js starter. You'll add the real environment variables and redeploy as you go.

Add the Neon database

We're going to use the Neon integration on Vercel so that we don't have to locally set up Postgres,  connection strings auto-populate in every Vercel environment (dev, preview, production), and preview deployments get their own database branches.

Add the Neon integration from Vercel's marketplace (Settings > Integrations > Browse Marketplace > Neon). It auto-populates DATABASE_URL and DATABASE_URL_UNPOOLED across all environments.

Once the Neon setup is done, use the commands below to pull the environment variables locally:

Terminal
vercel link
vercel env pull .env.local

Setting up a Whop app

We're going to use Whop Sandbox (at sandbox.whop.com) for the development phase of this project.

It's a separate environment that allows us to simulate money movements without touching the live version of the site or real money. To create a Whop app, follow the steps below:

  1. Go to sandbox.whop.com and create a whop
  2. Go to the Developer page of your whop and click the Create app button under the Apps section
  3. In the App details tab, copy the Client ID and Client Secret keys. We'll use them later
  4. Go to the OAuth tab and add the redirect URIs:
    1. http://localhost:3000/api/auth/callback
    2. https://your-vercel-url.vercel.app/api/auth/callback

For now, we only need the OAuth client ID and client secret. We'll grab the company API key and company ID later when we build seller onboarding.

Environment variables

Here's every variable you need for this section and where to get it:

VariableWhere to get it
DATABASE_URLAuto-populated by the Neon integration
DATABASE_URL_UNPOOLEDAuto-populated by the Neon integration
WHOP_CLIENT_IDWhop app > OAuth tab > Client ID
WHOP_CLIENT_SECRETWhop app > OAuth tab > Client Secret
SESSION_SECRETGenerate with openssl rand -base64 32
NEXT_PUBLIC_APP_URLYour Vercel URL (e.g. https://shelfie-xyz.vercel.app)

Add WHOP_CLIENT_ID, WHOP_CLIENT_SECRET, SESSION_SECRET, and NEXT_PUBLIC_APP_URL to Vercel (the Neon variables are already there from the integration). Then pull everything locally:

Terminal
vercel env pull .env.local

Then add these two to your .env.local only (not on Vercel - they're for local development):

.env.local
WHOP_SANDBOX=true
NEXT_PUBLIC_APP_URL=http://localhost:3000

The local NEXT_PUBLIC_APP_URL override points to localhost:3000 so OAuth redirects work during development. On Vercel, it stays as your production URL.

Global CSS

We're going to set up a dark crimson color scheme in this project. You can customize the look by changing the color values and other adjustments like border radiuses.

Go to src/app and create a file called globals.css with the content:

globals.css
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

@theme {
  --color-background: #1A0A10;
  --color-surface: #221218;
  --color-surface-elevated: #2D1A22;
  --color-border: #3D2830;
  --color-text-primary: #F5F0E1;
  --color-text-secondary: #A89890;
  --color-accent: #B8293D;
  --color-accent-hover: #D4324A;
  --color-success: #4ADE80;
  --color-warning: #FBBF24;
  --color-error: #F87171;

  --font-sans: "Inter", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", monospace;

  --radius-xs: 0px;
  --radius-sm: 0px;
  --radius-md: 0px;
  --radius-lg: 0px;
  --radius-xl: 0px;
  --radius-2xl: 0px;
  --radius-3xl: 0px;
}

.dark {
  --color-background: #1A0A10;
  --color-surface: #221218;
  --color-surface-elevated: #2D1A22;
  --color-border: #3D2830;
  --color-text-primary: #F5F0E1;
  --color-text-secondary: #A89890;
  --color-accent: #B8293D;
  --color-accent-hover: #D4324A;
  --color-success: #4ADE80;
  --color-warning: #FBBF24;
  --color-error: #F87171;
}

:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Root layout

Now, we'll build the root layout that sets up theming, navigation, and a skip-to-content link for accessibility. Go to src/app and create a file called layout.tsx with the content:

layout.tsx
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Navbar } from "@/components/navbar";
import "./globals.css";

const inter = Inter({
  variable: "--font-sans",
  subsets: ["latin"],
});

const jetbrainsMono = JetBrains_Mono({
  variable: "--font-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Shelfie - Sell What You Create",
  description:
    "The marketplace for digital products - templates, ebooks, design assets, and more.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body
        className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased bg-background text-text-primary`}
      >
        <ThemeProvider attribute="class" defaultTheme="light" enableSystem>
          <a
            href="#main-content"
            className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-lg focus:bg-accent focus:px-4 focus:py-2 focus:text-sm focus:font-semibold focus:text-white"
          >
            Skip to main content
          </a>
          <Navbar />
          <main id="main-content" className="min-h-[calc(100vh-4rem)]">{children}</main>
        </ThemeProvider>
      </body>
    </html>
  );
}

Environment validation

When an environment variable is missing or formatted wrong, the error can be buried deep down and cause problems. To fix this, we're going to use Zod to validate them. Go to src/lib and create a file called env.ts with the content:

env.ts
import { z } from "zod";

const envSchema = z.object({
  DATABASE_URL: z.string().min(1),
  DATABASE_URL_UNPOOLED: z.string().min(1),
  WHOP_CLIENT_ID: z.string().min(1),
  WHOP_CLIENT_SECRET: z.string().min(1),
  WHOP_API_KEY: z.string().min(1),
  WHOP_COMPANY_API_KEY: z.string().min(1),
  WHOP_COMPANY_ID: z.string().min(1),
  WHOP_WEBHOOK_SECRET: z.string().min(1),
  UPLOADTHING_TOKEN: z.string().min(1),
  SESSION_SECRET: z.string().min(32),
  NEXT_PUBLIC_APP_URL: z.string().url(),
  PLATFORM_FEE_PERCENT: z.coerce.number().min(0).max(100).default(5),
  WHOP_SANDBOX: z.string().optional(),
});

type Env = z.infer<typeof envSchema>;

export const env = new Proxy({} as Env, {
  get(_, key: string) {
    const value = process.env[key];
    const field = envSchema.shape[key as keyof typeof envSchema.shape];
    if (field) return field.parse(value);
    return value;
  },
});

Utility helpers

We're going to need some functions that helps us with class merging, price formatting, slug generation (for URLs), and username generation. To build it, go to src/lib and create a file called utils.ts with the content:

utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

export function formatPrice(cents: number): string {
  if (cents === 0) return "Free";
  return `$${(cents / 100).toFixed(2)}`;
}

export function generateSlug(title: string): string {
  const base = title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
  const suffix = Math.random().toString(36).substring(2, 8);
  return `${base}-${suffix}`;
}

export function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export function generateUsername(name: string | null | undefined): string {
  const base = (name || "seller")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
  const suffix = Math.random().toString(36).substring(2, 6);
  return `${base}-${suffix}`;
}

Prisma setup

We need two files for Prisma: a config file and a client singleton that our app uses at runtime. In the project root, create a file called prisma.config.ts with the content:

prisma.config.ts
import { config } from "dotenv";
config({ path: ".env.local" });

import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: { path: "prisma/migrations" },
  datasource: { url: env("DATABASE_URL_UNPOOLED") },
});

Now, the Prisma client singleton. We build this so that every file reuses the same database connection. Go to src/lib and create a file called prisma.ts with the content:

prisma.ts
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";
import { env } from "@/lib/env";

const pool = new Pool({ connectionString: env.DATABASE_URL });
const adapter = new PrismaPg(pool);

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma || new PrismaClient({ adapter });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Then generate the client and push the schema using the commands:

Terminal
npx prisma generate
npx prisma db push

Session configuration

To track who's logged in, we'll store the session data in an encrypted browser cookie with iron-session. Go to src/lib and create a file called session.ts with the content:

session.ts
import { getIronSession, type SessionOptions } from "iron-session";
import { cookies } from "next/headers";
import { env } from "@/lib/env";

export interface SessionData {
  userId?: string;
  whopUserId?: string;
  accessToken?: string;
}

const sessionOptions: SessionOptions = {
  password: env.SESSION_SECRET,
  cookieName: "shelfie_session",
  cookieOptions: {
    secure: process.env.NODE_ENV === "production",
    httpOnly: true,
    sameSite: "lax" as const,
  },
};

export async function getSession() {
  return getIronSession<SessionData>(await cookies(), sessionOptions);
}

Whop SDK and OAuth configuration

We need a Whop SDK client that's sandbox-aware. When WHOP_SANDBOX environment variable is set to true, API calls go to sandbox-api.whop.com instead of production.

Go to src/lib and create a file called whop.ts with the content:

whop.ts
import Whop from "@whop/sdk";

const isSandbox = process.env.WHOP_SANDBOX === "true";

let _whop: Whop | null = null;

export function getWhop(): Whop {
  if (!_whop) {
    _whop = new Whop({
      apiKey: process.env.WHOP_API_KEY!,
      webhookKey: process.env.WHOP_WEBHOOK_SECRET
        ? Buffer.from(process.env.WHOP_WEBHOOK_SECRET).toString("base64")
        : undefined,
      ...(isSandbox && { baseURL: "https://sandbox-api.whop.com/api/v1" }),
    });
  }
  return _whop;
}

let _companyWhop: Whop | null = null;

export function getCompanyWhop(): Whop {
  if (!_companyWhop) {
    _companyWhop = new Whop({
      apiKey: process.env.WHOP_COMPANY_API_KEY!,
      ...(isSandbox && { baseURL: "https://sandbox-api.whop.com/api/v1" }),
    });
  }
  return _companyWhop;
}

export const WHOP_OAUTH_BASE = isSandbox
  ? "https://sandbox-api.whop.com"
  : "https://api.whop.com";

Auth helpers

The project needs three levels of authentication: optional, required, and seller-only. Go to src/lib and create a file called auth.ts with the content:

auth.ts
import { redirect } from "next/navigation";
import { getSession } from "./session";
import { prisma } from "./prisma";

export async function getAuthUser() {
  const session = await getSession();
  if (!session.userId) return null;

  const user = await prisma.user.findUnique({
    where: { id: session.userId },
    include: { sellerProfile: true },
  });

  return user;
}

export async function requireAuth() {
  const user = await getAuthUser();
  if (!user) redirect("/sign-in");
  return user;
}

export async function requireSeller() {
  const user = await requireAuth();
  if (!user.sellerProfile) redirect("/sell");
  if (!user.sellerProfile.kycComplete) redirect("/sell?kyc=incomplete");
  return { user, sellerProfile: user.sellerProfile };
}

export async function completeKycIfNeeded(userId: string): Promise<boolean> {
  const profile = await prisma.sellerProfile.findUnique({
    where: { userId },
  });
  if (!profile || profile.kycComplete) return !!profile?.kycComplete;

  await prisma.sellerProfile.update({
    where: { id: profile.id },
    data: { kycComplete: true },
  });
  return true;
}

The login route

When a user clicks on the "Sign in with Whop" button, we need to create a PKCE challenge, store the verifier in a cookie, and redirect the user to Whop's authentication page. To build this system, go to src/app/api/auth/login and create a file called route.ts with the content:

route.ts
import { NextResponse } from "next/server";
import { WHOP_OAUTH_BASE } from "@/lib/whop";
import { env } from "@/lib/env";

export async function GET() {
  const clientId = env.WHOP_CLIENT_ID;
  const redirectUri = `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`;

  const codeVerifier = crypto.randomUUID() + crypto.randomUUID();
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");

  const state = crypto.randomUUID();
  const nonce = crypto.randomUUID();

  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    response_type: "code",
    scope: "openid profile email",
    state,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
    nonce,
  });

  const authUrl = `${WHOP_OAUTH_BASE}/oauth/authorize?${params}`;

  const response = NextResponse.redirect(authUrl);

  response.cookies.set("oauth_code_verifier", codeVerifier, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 600,
    path: "/",
  });
  response.cookies.set("oauth_state", state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 600,
    path: "/",
  });

  return response;
}

Callback route

We added the callback URI in the OAuth tab of the Whop app previously. Now, let's build the route that handles when Whop redirects the user back to our app. Go to src/app/api/auth/callback and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";
import { WHOP_OAUTH_BASE } from "@/lib/whop";
import { env } from "@/lib/env";

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get("code");
  const state = searchParams.get("state");

  const storedState = request.cookies.get("oauth_state")?.value;
  const codeVerifier = request.cookies.get("oauth_code_verifier")?.value;

  if (!code || !state || state !== storedState || !codeVerifier) {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/sign-in?error=invalid_state`
    );
  }

  const tokenRes = await fetch(`${WHOP_OAUTH_BASE}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      code,
      redirect_uri: `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`,
      client_id: env.WHOP_CLIENT_ID,
      client_secret: env.WHOP_CLIENT_SECRET,
      code_verifier: codeVerifier,
    }),
  });

  if (!tokenRes.ok) {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/sign-in?error=token_exchange`
    );
  }

  const tokens = await tokenRes.json();

  const userInfoRes = await fetch(`${WHOP_OAUTH_BASE}/oauth/userinfo`, {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  });

  if (!userInfoRes.ok) {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/sign-in?error=userinfo`
    );
  }

  const userInfo = await userInfoRes.json();

  const user = await prisma.user.upsert({
    where: { whopUserId: userInfo.sub },
    update: {
      email: userInfo.email,
      name: userInfo.name || userInfo.preferred_username,
      avatar: userInfo.picture,
    },
    create: {
      whopUserId: userInfo.sub,
      email: userInfo.email,
      name: userInfo.name || userInfo.preferred_username,
      avatar: userInfo.picture,
    },
  });

  const session = await getSession();
  session.userId = user.id;
  session.whopUserId = user.whopUserId;
  session.accessToken = tokens.access_token;
  await session.save();

  const response = NextResponse.redirect(
    `${env.NEXT_PUBLIC_APP_URL}/dashboard`
  );

  response.cookies.delete("oauth_code_verifier");
  response.cookies.delete("oauth_state");

  return response;
}

Logout route

The logout route should remove the session cookie. Go to src/app/api/auth/logout and create a file called route.ts with the content:

route.ts
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { env } from "@/lib/env";

export async function POST() {
  const session = await getSession();
  session.destroy();

  return NextResponse.redirect(`${env.NEXT_PUBLIC_APP_URL}/`);
}

Sign-in page

Now, let's build a simple sign-in page with a single "Sign in with Whop" button. Go to src/app/sign-in and create a file called page.tsx with the content:

page.tsx
import { Store } from "lucide-react";

const ERROR_MESSAGES: Record<string, string> = {
  invalid_state: "Sign-in session expired. Please try again.",
  token_exchange: "Could not complete sign-in. Please try again.",
  userinfo: "Could not retrieve your profile. Please try again.",
};

export default async function SignInPage({
  searchParams,
}: {
  searchParams: Promise<{ error?: string }>;
}) {
  const { error } = await searchParams;
  const errorMessage = error ? ERROR_MESSAGES[error] || "Something went wrong. Please try again." : null;

  return (
    <div className="flex min-h-[80vh] items-center justify-center px-4">
      <div className="w-full max-w-sm text-center">
        <Store className="mx-auto h-12 w-12 text-accent" aria-hidden="true" />
        <h1 className="mt-4 text-2xl font-bold text-text-primary">
          Welcome to Shelfie
        </h1>
        <p className="mt-2 text-sm text-text-secondary">
          Sign in with your Whop account to buy and sell digital products.
        </p>

        {errorMessage && (
          <div
            role="alert"
            className="mt-4 rounded-lg bg-error/10 p-3 text-sm text-error"
          >
            {errorMessage}
          </div>
        )}

        <a
          href="/api/auth/login"
          className="mt-8 block w-full rounded-lg bg-accent px-6 py-3 text-center text-sm font-semibold text-white hover:bg-accent-hover transition-colors"
        >
          Sign in with Whop
        </a>
      </div>
    </div>
  );
}

We need a navigation bar so that our users can easily navigate the app. The navigation bar will display a "Sign In" button for guests and user details, navigation links, and a logout button for logged-in users.

Go to src/components and create a file called navbar.tsx with the content:

navbar.tsx
import Link from "next/link";
import { getAuthUser } from "@/lib/auth";
import { Store, ShoppingBag, LogOut, LogIn } from "lucide-react";

export async function Navbar() {
  const user = await getAuthUser();

  return (
    <header className="sticky top-0 z-50 border-b border-border bg-surface/80 backdrop-blur-md">
      <nav aria-label="Main navigation" className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
        <Link href="/" className="flex items-center gap-2 text-xl font-bold text-text-primary">
          <Store className="h-6 w-6 text-accent" aria-hidden="true" />
          <span className="hidden sm:inline">Shelfie</span>
        </Link>

        <div className="flex items-center gap-3 sm:gap-6">
          <Link
            href="/products"
            className="text-sm font-medium text-text-secondary hover:text-text-primary transition-colors"
          >
            Browse
          </Link>

          {user ? (
            <>
              <Link
                href="/sell/dashboard"
                className="text-sm font-medium text-text-secondary hover:text-text-primary transition-colors"
              >
                Sell
              </Link>
              <Link
                href="/dashboard"
                aria-label="My purchases"
                className="p-2 text-sm font-medium text-text-secondary hover:text-text-primary transition-colors"
              >
                <ShoppingBag className="h-4 w-4" aria-hidden="true" />
              </Link>
              <div className="flex items-center gap-2 sm:gap-3">
                {user.avatar && (
                  <img
                    src={user.avatar}
                    alt={user.name || "User avatar"}
                    className="h-8 w-8 rounded-full"
                  />
                )}
                <form action="/api/auth/logout" method="POST">
                  <button
                    type="submit"
                    aria-label="Sign out"
                    className="p-2 text-text-secondary hover:text-text-primary transition-colors"
                  >
                    <LogOut className="h-4 w-4" aria-hidden="true" />
                  </button>
                </form>
              </div>
            </>
          ) : (
            <Link
              href="/sign-in"
              className="inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-accent-hover transition-colors"
            >
              <LogIn className="h-4 w-4" aria-hidden="true" />
              <span className="hidden sm:inline">Sign In</span>
            </Link>
          )}
        </div>
      </nav>
    </header>
  );
}

Category constants

We need a list of product categories that we'll reuse across the app. Go to src/constants and create a file called categories.ts with the content:

categories.ts
import {
  FileText,
  BookOpen,
  Code,
  Palette,
  Music,
  Video,
  Camera,
  GraduationCap,
  Package,
  type LucideIcon,
} from "lucide-react";

export const CATEGORIES = [
  { value: "TEMPLATES", label: "Templates", icon: FileText },
  { value: "EBOOKS", label: "Ebooks", icon: BookOpen },
  { value: "SOFTWARE", label: "Software", icon: Code },
  { value: "DESIGN", label: "Design", icon: Palette },
  { value: "AUDIO", label: "Audio", icon: Music },
  { value: "VIDEO", label: "Video", icon: Video },
  { value: "PHOTOGRAPHY", label: "Photography", icon: Camera },
  { value: "EDUCATION", label: "Education", icon: GraduationCap },
  { value: "OTHER", label: "Other", icon: Package },
] as const satisfies readonly { value: string; label: string; icon: LucideIcon }[];

export const CATEGORY_MAP = Object.fromEntries(
  CATEGORIES.map((c) => [c.value, c])
) as Record<string, (typeof CATEGORIES)[number]>;

Next.js configuration

We need to allow remote images from UploadThing and Whop's CDNs. In the project root, create a file called next.config.ts with the content:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      { hostname: "utfs.io" },
      { hostname: "assets.whop.com" },
      { hostname: "cdn.whop.com" },
    ],
  },
};

export default nextConfig;

Part 2: Seller onboarding

In this section, we're going build the seller onboarding where users can sign up as sellers. When they click on the "Get Started" button in our project, we'll create a connected account for them on Whop, verify their identity through a Whop-hosted KYC, and save their details in the database.

Add environment variables

The seller onboarding flow needs three new variables:

VariableWhere to get it
WHOP_API_KEYApp API key (Developer > API Keys)
WHOP_COMPANY_IDYour platform's company ID (from dashboard URL, starts with biz_)
WHOP_COMPANY_API_KEYCompany API key (Business Settings > API Keys)

WHOP_API_KEY needs these permission scopes: company:create, company:basic:read, account_link:create. In sandbox, the default key usually has all scopes enabled.
After adding these new environment variables to Vercel (via project settings > Environment Variables), use the command below to pull them:

Terminal
vercel env pull .env.local

The sell page

The sell page will pitch becoming a seller to all users. When a user clicks the "Get Started" button, we redirect them to a Whop-hosted KYC or skips it. We'll expand on the skipping reason in the next section.

To build the sell page, go to src/app/sell and create a file called page.tsx with the content:

page.tsx
"use client";

import { useState, Suspense } from "react";
import { ArrowRight, DollarSign, Shield, Zap, CheckCircle, AlertCircle } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";

export default function SellPage() {
  return (
    <Suspense>
      <SellPageContent />
    </Suspense>
  );
}

function SellPageContent() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const kycIncomplete = searchParams.get("kyc") === "incomplete";
  const [loading, setLoading] = useState(false);
  const [sandboxMessage, setSandboxMessage] = useState(false);

  async function handleOnboard() {
    setLoading(true);
    try {
      const res = await fetch("/api/sell/onboard", { method: "POST" });
      const data = await res.json();

      if (data.sandbox) {
        setSandboxMessage(true);
        setTimeout(() => router.push("/sell/dashboard"), 2000);
        return;
      }

      if (data.redirect) {
        if (data.redirect.startsWith("http")) {
          window.location.href = data.redirect;
        } else {
          router.push(data.redirect);
        }
      }
    } catch (error) {
      console.error("Onboarding failed:", error);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="mx-auto max-w-4xl px-4 py-16 text-center">
      <h1 className="text-4xl font-extrabold text-text-primary sm:text-5xl">
        Share your work with the world
      </h1>
      <p className="mx-auto mt-4 max-w-lg text-lg text-text-secondary">
        Sell digital products on Shelfie. We handle payments, payouts, and
        compliance - you focus on creating.
      </p>

      {kycIncomplete && (
        <div className="mt-8 inline-flex items-center gap-2 bg-warning/10 px-6 py-3 text-sm font-medium text-warning">
          <AlertCircle className="h-5 w-5" aria-hidden="true" />
          Complete identity verification to start selling. Click below to continue.
        </div>
      )}

      <div className="mt-12 grid gap-6 sm:grid-cols-3">
        <div className="border border-border bg-surface p-6 text-center">
          <DollarSign className="mx-auto h-10 w-10 text-accent" />
          <h3 className="mt-4 text-base font-semibold text-text-primary">
            Set your own price
          </h3>
          <p className="mt-2 text-sm text-text-secondary">
            Free or paid. You decide how much your work is worth.
          </p>
        </div>

        <div className="border border-border bg-surface p-6 text-center">
          <Shield className="mx-auto h-10 w-10 text-accent" />
          <h3 className="mt-4 text-base font-semibold text-text-primary">
            We handle payments
          </h3>
          <p className="mt-2 text-sm text-text-secondary">
            Whop processes payments, handles compliance, and manages disputes.
          </p>
        </div>

        <div className="border border-border bg-surface p-6 text-center">
          <Zap className="mx-auto h-10 w-10 text-accent" />
          <h3 className="mt-4 text-base font-semibold text-text-primary">
            Keep 95% of every sale
          </h3>
          <p className="mt-2 text-sm text-text-secondary">
            Just a 5% platform fee. Withdraw to your bank anytime.
          </p>
        </div>
      </div>

      {sandboxMessage && (
        <div className="mt-8 inline-flex items-center gap-2 bg-success/10 px-6 py-3 text-sm font-medium text-success">
          <CheckCircle className="h-5 w-5" aria-hidden="true" />
          This demo uses Whop Sandbox - KYC is not required. Redirecting to
          dashboard...
        </div>
      )}

      {!sandboxMessage && (
        <>
          <button
            onClick={handleOnboard}
            disabled={loading}
            className="mt-12 inline-flex items-center gap-2 bg-accent px-8 py-3.5 text-sm font-semibold text-white hover:bg-accent-hover transition-colors disabled:opacity-50"
          >
            {loading ? "Setting up..." : kycIncomplete ? "Complete Verification" : "Get Started"}
            <ArrowRight className="h-4 w-4" />
          </button>

          <p className="mt-4 text-xs text-text-secondary">
            {kycIncomplete
              ? "You\u2019ll be redirected to Whop to complete identity verification."
              : "You\u2019ll need to verify your identity to receive payouts. This is handled securely by Whop."}
          </p>
        </>
      )}
    </div>
  );
}

The onboarding API route

This route handles the entire onboarding flow from creating a connected account on Whop to generating a KYC link.

You'll notice isSandbox checks throughout the code. In the sandbox environment on Whop, you don't need to complete the KYC flow since you're not moving real money, so we skip it during development.

The route sets kycComplete: true immediately and returns a sandbox flag instead of a KYC URL. In production (when WHOP_SANDBOX is removed), these branches are never reached. Every seller goes through Whop's real identity verification before they can list products.

Go to src/app/api/sell/onboard and create a file called route.ts with the content:

route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";
import { getWhop } from "@/lib/whop";
import { generateUsername } from "@/lib/utils";
import { env } from "@/lib/env";

const isSandbox = process.env.WHOP_SANDBOX === "true";

export async function POST() {
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await prisma.user.findUnique({
    where: { id: session.userId },
    include: { sellerProfile: true },
  });

  if (!user) {
    return NextResponse.json({ error: "User not found" }, { status: 404 });
  }

  if (user.sellerProfile?.kycComplete) {
    return NextResponse.json({ redirect: "/sell/dashboard" });
  }

  if (user.sellerProfile) {
    if (isSandbox) {
      await prisma.sellerProfile.update({
        where: { id: user.sellerProfile.id },
        data: { kycComplete: true },
      });
      return NextResponse.json({ sandbox: true });
    }

    const accountLink = await getWhop().accountLinks.create({
      company_id: user.sellerProfile.whopCompanyId,
      use_case: "account_onboarding",
      return_url: `${env.NEXT_PUBLIC_APP_URL}/sell/kyc-return`,
      refresh_url: `${env.NEXT_PUBLIC_APP_URL}/sell?refresh=true`,
    });

    return NextResponse.json({ redirect: accountLink.url });
  }

  const company = await getWhop().companies.create({
    email: user.email,
    title: `${user.name || "Seller"}'s Store`,
    parent_company_id: env.WHOP_COMPANY_ID,
  });

  const username = generateUsername(user.name);

  if (isSandbox) {
    await prisma.sellerProfile.create({
      data: {
        userId: user.id,
        username,
        whopCompanyId: company.id,
        kycComplete: true,
      },
    });
    return NextResponse.json({ sandbox: true });
  }

  await prisma.sellerProfile.create({
    data: {
      userId: user.id,
      username,
      whopCompanyId: company.id,
      kycComplete: false,
    },
  });

  const accountLink = await getWhop().accountLinks.create({
    company_id: company.id,
    use_case: "account_onboarding",
    return_url: `${env.NEXT_PUBLIC_APP_URL}/sell/kyc-return`,
    refresh_url: `${env.NEXT_PUBLIC_APP_URL}/sell?refresh=true`,
  });

  return NextResponse.json({ redirect: accountLink.url });
}

Username generation

The generateUsername() function in utils.ts creates a URL-friendly username from the user's name:

"Alex Rivera"    → "alex-rivera-k7m2"
"Sarah Chen"     → "sarah-chen-p9x4"
null             → "seller-w3f1"

The random suffix guarantees uniqueness without a database check.

Checkpoint

  1. Sign in via Whop OAuth (set up earlier)
  2. Navigate to /sell
  3. Click "Get Started"
  4. In sandbox, you'll see a success message and auto-redirect to the dashboard
  5. In production: you'd be redirected to Whop's KYC page > complete verification > land on /sell/kyc-return > auto-redirect to dashboard
  6. Check your database. You should see a SellerProfile row with whopCompanyId and kycComplete = true

Part 3: Product listings and file uploads

In this part, we're going to build the product creation form, file uploads, the draft flow, and the publishing flow.

File uploads with UploadThing

In this project, we're going to use UploadThing for file uploads. It will handle storage, CDN delivery, size validation, while we just define what file types to accept and who can upload.

First, let's install UploadThing by using the command below:

Terminal
npm install uploadthing @uploadthing/react

Then, get an UploadThing token and add it to Vercel:

  1. Go to UploadThings.com and create a project
  2. Copy the UPLOADTHING_TOKEN from the UploadThing dashboard
  3. Add it to Vercel via the Environment Variables page of the project settings
  4. Pull the new environment variables to local using the vercel env pull .env.local command

File router

Let's define what type of files users can upload, their size limits, and authentication checks. Go to src/app/api/uploadthing and create a file called core.ts with the content:

core.ts
import { createUploadthing, type FileRouter } from "uploadthing/next";
import { UploadThingError } from "uploadthing/server";
import { getSession } from "@/lib/session";

const f = createUploadthing();

export const ourFileRouter = {
  productFile: f({
    pdf: { maxFileSize: "16MB", maxFileCount: 10 },
    image: { maxFileSize: "16MB", maxFileCount: 10 },
    video: { maxFileSize: "16MB", maxFileCount: 10 },
  })
    .middleware(async () => {
      const session = await getSession();
      if (!session.userId) throw new UploadThingError("Unauthorized");
      return { userId: session.userId };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      return {
        uploadedBy: metadata.userId,
        name: file.name,
        size: file.size,
        key: file.key,
        url: file.ufsUrl,
      };
    }),
} satisfies FileRouter;

export type OurFileRouter = typeof ourFileRouter;

Route handler

Go to src/app/api/uploadthing and create a file called route.ts with the content:

route.ts
import { createRouteHandler } from "uploadthing/next";
import { ourFileRouter } from "./core";

export const { GET, POST } = createRouteHandler({
  router: ourFileRouter,
});

Client helper

We need a React hook that lets us trigger file uploads from the browser. Go to src/lib and create a file called uploadthing.ts with the content:

uploadthing.ts
import { generateReactHelpers } from "@uploadthing/react";
import type { OurFileRouter } from "@/app/api/uploadthing/core";

export const { useUploadThing } = generateReactHelpers<OurFileRouter>();

The product API route

Now, we need to validate the input, generate a unique slug, and save the product as a draft once a seller fills out the product form.
Go to src/app/api/sell/products and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";
import { generateSlug } from "@/lib/utils";

const createProductSchema = z.object({
  title: z.string().min(1).max(100),
  description: z.string().min(1).max(5000),
  price: z.number().int().min(0),
  category: z.enum([
    "TEMPLATES", "EBOOKS", "SOFTWARE", "DESIGN",
    "AUDIO", "VIDEO", "PHOTOGRAPHY", "EDUCATION", "OTHER",
  ]),
  content: z.string().max(50000).optional(),
  externalUrl: z.string().url().optional().or(z.literal("")),
  files: z.array(z.object({
    fileName: z.string(),
    fileKey: z.string(),
    fileUrl: z.string().url(),
    fileSize: z.number().int(),
    mimeType: z.string(),
  })).optional(),
});

export async function POST(request: NextRequest) {
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!sellerProfile || !sellerProfile.kycComplete) {
    return NextResponse.json(
      { error: "Complete seller onboarding first" },
      { status: 403 }
    );
  }

  const body = await request.json();
  const parsed = createProductSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Validation failed", details: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const { title, description, price, category, content, externalUrl, files } =
    parsed.data;

  const slug = generateSlug(title);
  const thumbnailFile = files?.find((f) => f.mimeType.startsWith("image/"));

  const product = await prisma.product.create({
    data: {
      sellerProfileId: sellerProfile.id,
      title,
      slug,
      description,
      price,
      category,
      content: content || null,
      externalUrl: externalUrl || null,
      thumbnailUrl: thumbnailFile?.fileUrl || null,
      files: files
        ? {
            create: files.map((f, i) => ({
              fileName: f.fileName,
              fileKey: f.fileKey,
              fileUrl: f.fileUrl,
              fileSize: f.fileSize,
              mimeType: f.mimeType,
              displayOrder: i,
            })),
          }
        : undefined,
    },
    include: { files: true },
  });

  return NextResponse.json(product, { status: 201 });
}

Updating and deleting products

To make some quality-of-life improvements to the project, we need to let sellers edit their drafts or remove existing ones.

To build it, go to src/app/api/sell/products/[productId] and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";

const updateProductSchema = z.object({
  title: z.string().min(1).max(100).optional(),
  description: z.string().min(1).max(5000).optional(),
  price: z.number().int().min(0).optional(),
  category: z.enum([
    "TEMPLATES", "EBOOKS", "SOFTWARE", "DESIGN",
    "AUDIO", "VIDEO", "PHOTOGRAPHY", "EDUCATION", "OTHER",
  ]).optional(),
  content: z.string().max(50000).optional().nullable(),
  externalUrl: z.string().url().optional().nullable().or(z.literal("")),
  thumbnailUrl: z.string().url().optional().nullable(),
  files: z.array(z.object({
    fileName: z.string(),
    fileKey: z.string(),
    fileUrl: z.string().url(),
    fileSize: z.number().int(),
    mimeType: z.string(),
  })).optional(),
  removeFileIds: z.array(z.string()).optional(),
});

export async function PATCH(
  request: NextRequest,
  { params }: { params: Promise<{ productId: string }> }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!sellerProfile) {
    return NextResponse.json({ error: "Not a seller" }, { status: 403 });
  }

  const product = await prisma.product.findUnique({
    where: { id: productId },
    include: { files: true },
  });

  if (!product || product.sellerProfileId !== sellerProfile.id) {
    return NextResponse.json({ error: "Product not found" }, { status: 404 });
  }

  if (product.status === "PUBLISHED") {
    return NextResponse.json(
      { error: "Cannot edit a published product. Unpublish first." },
      { status: 400 }
    );
  }

  const body = await request.json();
  const parsed = updateProductSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Validation failed", details: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const { files, removeFileIds, ...fields } = parsed.data;

  if (removeFileIds && removeFileIds.length > 0) {
    await prisma.productFile.deleteMany({
      where: { id: { in: removeFileIds }, productId },
    });
  }

  if (files && files.length > 0) {
    const existingCount = product.files.length - (removeFileIds?.length || 0);
    await prisma.productFile.createMany({
      data: files.map((f, i) => ({
        productId,
        fileName: f.fileName,
        fileKey: f.fileKey,
        fileUrl: f.fileUrl,
        fileSize: f.fileSize,
        mimeType: f.mimeType,
        displayOrder: existingCount + i,
      })),
    });
  }

  const newThumbnail = files?.find((f) => f.mimeType.startsWith("image/"));

  const updated = await prisma.product.update({
    where: { id: productId },
    data: {
      ...fields,
      externalUrl: fields.externalUrl || null,
      ...(newThumbnail && !product.thumbnailUrl
        ? { thumbnailUrl: newThumbnail.fileUrl }
        : {}),
    },
    include: { files: { orderBy: { displayOrder: "asc" } } },
  });

  return NextResponse.json(updated);
}

export async function DELETE(
  _request: NextRequest,
  { params }: { params: Promise<{ productId: string }> }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!sellerProfile) {
    return NextResponse.json({ error: "Not a seller" }, { status: 403 });
  }

  const product = await prisma.product.findUnique({
    where: { id: productId },
  });

  if (!product || product.sellerProfileId !== sellerProfile.id) {
    return NextResponse.json({ error: "Product not found" }, { status: 404 });
  }

  await prisma.product.delete({ where: { id: productId } });

  return NextResponse.json({ success: true });
}

Publishing products

Now that we have all the foundation, let's build the product publishing flow. When a seller clicks Publish, we create a Whop checkout configuration on their connected account. We do this on publish (not on draft creation) because draft products shouldn't have checkout links.

Go to src/app/api/sell/products/[productId]/publish and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";
import { getCompanyWhop } from "@/lib/whop";
import { env } from "@/lib/env";

export async function POST(
  _request: NextRequest,
  { params }: { params: Promise<{ productId: string }> }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!sellerProfile || !sellerProfile.kycComplete) {
    return NextResponse.json(
      { error: "Complete seller onboarding first" },
      { status: 403 }
    );
  }

  const product = await prisma.product.findUnique({
    where: { id: productId },
    include: { files: true },
  });

  if (!product || product.sellerProfileId !== sellerProfile.id) {
    return NextResponse.json({ error: "Product not found" }, { status: 404 });
  }

  if (product.status === "PUBLISHED") {
    return NextResponse.json(
      { error: "Product is already published" },
      { status: 400 }
    );
  }

  const hasFiles = product.files.length > 0;
  const hasContent = !!product.content;
  const hasLink = !!product.externalUrl;

  if (!hasFiles && !hasContent && !hasLink) {
    return NextResponse.json(
      {
        error:
          "Product must have at least one file, text content, or external link",
      },
      { status: 400 }
    );
  }

  try {
    const whopProduct = await getCompanyWhop().products.create({
      company_id: sellerProfile.whopCompanyId,
      title: product.title,
      description: product.description,
    });

    const feePercent = env.PLATFORM_FEE_PERCENT;

    if (product.price === 0) {
      const updated = await prisma.product.update({
        where: { id: productId },
        data: {
          status: "PUBLISHED",
          whopProductId: whopProduct.id,
        },
      });

      return NextResponse.json(updated);
    }

    const feeAmount = Math.round(product.price * (feePercent / 100));

    const checkoutConfig = await (getCompanyWhop().checkoutConfigurations.create as any)({
      plan: {
        company_id: sellerProfile.whopCompanyId,
        currency: "usd",
        initial_price: product.price / 100,
        plan_type: "one_time",
        application_fee_amount: feeAmount / 100,
      },
    });

    const updated = await prisma.product.update({
      where: { id: productId },
      data: {
        status: "PUBLISHED",
        whopProductId: whopProduct.id,
        whopPlanId: checkoutConfig.plan?.id ?? null,
        whopCheckoutUrl: checkoutConfig.purchase_url,
      },
    });

    return NextResponse.json(updated);
  } catch (err) {
    console.error("Publish error:", err);
    const message = err instanceof Error ? err.message : "Whop API error";
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

Create product page

Now, let's build the product creation form where sellers enter product details and upload their files. Initially, all products are saved as drafts.

Go to src/app/sell/products/new and create a file called page.tsx with the content:

page.tsx
"use client";

import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { X, Upload, Check, Loader2 } from "lucide-react";
import { CATEGORIES } from "@/constants/categories";
import { formatFileSize } from "@/lib/utils";
import { useUploadThing } from "@/lib/uploadthing";

const ALLOWED_TYPES = [
  "application/pdf",
  "image/png",
  "image/jpeg",
  "image/gif",
  "image/webp",
  "video/mp4",
];
const MAX_FILE_SIZE = 16 * 1024 * 1024; // 16 MB

interface UploadedFile {
  fileName: string;
  fileKey: string;
  fileUrl: string;
  fileSize: number;
  mimeType: string;
}

export default function NewProductPage() {
  const router = useRouter();
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [files, setFiles] = useState<UploadedFile[]>([]);

  const { startUpload, isUploading } = useUploadThing("productFile", {
    onClientUploadComplete: (res) => {
      const uploaded = res.map((file) => ({
        fileName: file.name,
        fileKey: file.key,
        fileUrl: file.url,
        fileSize: file.size,
        mimeType: "",
      }));
      setFiles((prev) => [...prev, ...uploaded]);
    },
    onUploadError: (err) => {
      setError(err.message || "Upload failed");
    },
  });

  function removeFile(fileKey: string) {
    setFiles((prev) => prev.filter((f) => f.fileKey !== fileKey));
  }

  async function handleFiles(fileList: FileList) {
    setError(null);

    const validFiles: File[] = [];
    for (const file of Array.from(fileList)) {
      if (!ALLOWED_TYPES.includes(file.type)) {
        setError(`${file.name}: file type not allowed`);
        return;
      }
      if (file.size > MAX_FILE_SIZE) {
        setError(`${file.name}: exceeds 16 MB limit`);
        return;
      }
      validFiles.push(file);
    }

    if (validFiles.length === 0) return;

    const res = await startUpload(validFiles);
    if (res) {
      setFiles((prev) =>
        prev.map((f) => {
          if (f.mimeType) return f;
          const original = validFiles.find((v) => v.name === f.fileName);
          return original ? { ...f, mimeType: original.type } : f;
        })
      );
    }
  }

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setLoading(true);
    setError(null);

    const formData = new FormData(e.currentTarget);

    const priceStr = formData.get("price") as string;
    const priceInCents = Math.round(parseFloat(priceStr || "0") * 100);

    const body = {
      title: formData.get("title") as string,
      description: formData.get("description") as string,
      price: priceInCents,
      category: formData.get("category") as string,
      content: (formData.get("content") as string) || undefined,
      externalUrl: (formData.get("externalUrl") as string) || undefined,
      files: files.length > 0 ? files : undefined,
    };

    try {
      const res = await fetch("/api/sell/products", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });

      if (!res.ok) {
        const data = await res.json();
        setError(data.error || "Failed to create product");
        return;
      }

      const product = await res.json();
      router.push(`/sell/products/${product.id}/edit`);
    } catch {
      setError("Something went wrong");
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="mx-auto max-w-2xl px-4 py-8">
      <h1 className="text-2xl font-bold text-text-primary">
        Create a New Product
      </h1>
      <p className="mt-1 text-sm text-text-secondary">
        Fill in the details, upload your files, and publish when ready.
      </p>

      {error && (
        <div
          role="alert"
          className="mt-4 rounded-lg bg-error/10 p-3 text-sm text-error"
        >
          {error}
        </div>
      )}

      <form onSubmit={handleSubmit} className="mt-8 space-y-6">
        <div>
          <label
            htmlFor="title"
            className="block text-sm font-medium text-text-primary"
          >
            Title
          </label>
          <input
            type="text" id="title" name="title" required maxLength={100}
            className="mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary placeholder:text-text-secondary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
            placeholder="e.g. Premium Icon Pack"
          />
        </div>

        <div>
          <label
            htmlFor="description"
            className="block text-sm font-medium text-text-primary"
          >
            Description
          </label>
          <textarea
            id="description" name="description" required rows={4} maxLength={5000}
            className="mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary placeholder:text-text-secondary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
            placeholder="Describe what buyers will get..."
          />
        </div>

        <div className="grid gap-4 sm:grid-cols-2">
          <div>
            <label htmlFor="price" className="block text-sm font-medium text-text-primary">
              Price (USD)
            </label>
            <input
              type="number" id="price" name="price" min="0" step="0.01" defaultValue="0"
              className="mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
              placeholder="0.00 for free"
            />
            <p className="mt-1 text-xs text-text-secondary">
              Set to 0 for a free product
            </p>
          </div>
          <div>
            <label htmlFor="category" className="block text-sm font-medium text-text-primary">
              Category
            </label>
            <select
              id="category" name="category" required
              className="mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
            >
              {CATEGORIES.map((cat) => (
                <option key={cat.value} value={cat.value}>{cat.label}</option>
              ))}
            </select>
          </div>
        </div>

        <div>
          <label className="block text-sm font-medium text-text-primary">Files</label>
          <p className="mt-0.5 text-xs text-text-secondary">
            PDF, images (PNG, JPG, GIF, WebP), video (MP4). Max 16 MB each.
          </p>

          {files.length > 0 && (
            <div className="mt-3 space-y-2">
              {files.map((file) => (
                <div key={file.fileKey} className="flex items-center gap-3 rounded-lg border border-border bg-surface p-3">
                  <Check className="h-4 w-4 shrink-0 text-success" aria-hidden="true" />
                  <span className="flex-1 truncate text-sm text-text-primary">{file.fileName}</span>
                  <span className="text-xs text-text-secondary">{formatFileSize(file.fileSize)}</span>
                  <button type="button" onClick={() => removeFile(file.fileKey)} aria-label={`Remove ${file.fileName}`} className="p-2 text-text-secondary hover:text-error transition-colors">
                    <X className="h-4 w-4" aria-hidden="true" />
                  </button>
                </div>
              ))}
            </div>
          )}

          {isUploading && (
            <div className="mt-3 flex items-center gap-3 rounded-lg border border-border bg-surface p-3">
              <Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent" aria-hidden="true" />
              <span className="text-sm text-text-secondary">Uploading...</span>
            </div>
          )}

          <div
            onDragOver={(e) => { e.preventDefault(); e.currentTarget.dataset.dragging = "true"; }}
            onDragLeave={(e) => { delete e.currentTarget.dataset.dragging; }}
            onDrop={(e) => {
              e.preventDefault();
              delete e.currentTarget.dataset.dragging;
              if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
            }}
            onClick={() => fileInputRef.current?.click()}
            className="mt-3 flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 border-dashed border-border p-8 transition-colors hover:border-accent/50 data-[dragging]:border-accent data-[dragging]:bg-accent/5"
          >
            <Upload className="h-8 w-8 text-text-secondary" aria-hidden="true" />
            <span className="text-sm text-text-secondary">Click or drag files to upload</span>
            <input
              ref={fileInputRef} type="file" multiple
              accept=".pdf,.png,.jpg,.jpeg,.gif,.webp,.mp4"
              onChange={(e) => e.target.files && handleFiles(e.target.files)}
              className="hidden"
            />
          </div>
        </div>

        <div>
          <label htmlFor="content" className="block text-sm font-medium text-text-primary">
            Text Content <span className="text-text-secondary">(optional)</span>
          </label>
          <textarea
            id="content" name="content" rows={6}
            className="mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary placeholder:text-text-secondary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 font-mono text-sm"
            placeholder="Add text or markdown content that buyers will see after purchase..."
          />
        </div>

        <div>
          <label htmlFor="externalUrl" className="block text-sm font-medium text-text-primary">
            External Link <span className="text-text-secondary">(optional)</span>
          </label>
          <input
            type="url" id="externalUrl" name="externalUrl"
            className="mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary placeholder:text-text-secondary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
            placeholder="https://..."
          />
        </div>

        <button
          type="submit" disabled={loading || isUploading}
          className="w-full rounded-lg bg-accent px-6 py-3 text-sm font-semibold text-white hover:bg-accent-hover transition-colors disabled:opacity-50"
        >
          {loading ? "Creating..." : "Create Product (Draft)"}
        </button>
      </form>
    </div>
  );
}

Edit and publish page

Now that the creation form is done, let's create the page where sellers can edit and publish their products. Go to src/app/sell/products/[productId]/edit and create a file called page.tsx with the content:

page.tsx
import { notFound } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireSeller } from "@/lib/auth";
import { formatPrice, formatFileSize } from "@/lib/utils";
import { PublishButton } from "@/components/publish-button";
import { UnpublishButton } from "@/components/unpublish-button";
import { DeleteButton } from "@/components/delete-button";
import { EditForm } from "@/components/edit-form";

export default async function EditProductPage({
  params,
}: {
  params: Promise<{ productId: string }>;
}) {
  const { productId } = await params;
  const { sellerProfile } = await requireSeller();

  const product = await prisma.product.findUnique({
    where: { id: productId },
    include: { files: { orderBy: { displayOrder: "asc" } } },
  });

  if (!product || product.sellerProfileId !== sellerProfile.id) notFound();

  return (
    <div className="mx-auto max-w-2xl px-4 py-8">
      <Link href="/sell/dashboard"
        className="inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary transition-colors">
        <ArrowLeft className="h-4 w-4" /> Back to dashboard
      </Link>

      <div className="mt-6 flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold text-text-primary">
            {product.status === "DRAFT" ? "Edit Product" : product.title}
          </h1>
          <p className="text-sm text-text-secondary">
            {formatPrice(product.price)} ·{" "}
            <span className={product.status === "PUBLISHED" ? "text-success" : "text-warning"}>
              {product.status}
            </span>
          </p>
        </div>

        <div className="flex items-center gap-2">
          {product.status === "DRAFT" && (
            <>
              <DeleteButton productId={product.id} />
              <PublishButton productId={product.id} />
            </>
          )}

          {product.status === "PUBLISHED" && (
            <>
              <UnpublishButton productId={product.id} />
              <Link href={`/products/${product.slug}`}
                className="border border-border px-4 py-2.5 text-sm font-medium text-text-secondary hover:text-text-primary transition-colors">
                View Live
              </Link>
            </>
          )}
        </div>
      </div>

      {product.status === "DRAFT" && (
        <EditForm
          product={{
            id: product.id,
            title: product.title,
            description: product.description,
            price: product.price,
            category: product.category,
            content: product.content,
            externalUrl: product.externalUrl,
            thumbnailUrl: product.thumbnailUrl,
            files: product.files,
          }}
        />
      )}

      {product.status === "PUBLISHED" && (
        <div className="mt-8 space-y-6">
          {product.thumbnailUrl && (
            <div className="overflow-hidden">
              <img src={product.thumbnailUrl} alt={product.title}
                className="w-full object-cover max-h-64" />
            </div>
          )}

          <div className="border border-border bg-surface p-5">
            <h2 className="text-sm font-semibold text-text-primary">Description</h2>
            <p className="mt-2 text-sm text-text-secondary whitespace-pre-wrap">{product.description}</p>
          </div>

          <div className="border border-border bg-surface p-5">
            <h2 className="text-sm font-semibold text-text-primary">Files ({product.files.length})</h2>
            {product.files.length === 0 ? (
              <p className="mt-2 text-sm text-text-secondary">No files uploaded yet.</p>
            ) : (
              <div className="mt-3 space-y-2">
                {product.files.map((file) => (
                  <div key={file.id} className="flex items-center gap-3 bg-surface-elevated p-3">
                    <span className="flex-1 truncate text-sm text-text-primary">{file.fileName}</span>
                    <span className="text-xs text-text-secondary">{formatFileSize(file.fileSize)}</span>
                  </div>
                ))}
              </div>
            )}
          </div>

          {product.content && (
            <div className="border border-border bg-surface p-5">
              <h2 className="text-sm font-semibold text-text-primary">Text Content</h2>
              <p className="mt-2 text-sm text-text-secondary whitespace-pre-wrap">{product.content}</p>
            </div>
          )}

          {product.externalUrl && (
            <div className="border border-border bg-surface p-5">
              <h2 className="text-sm font-semibold text-text-primary">External Link</h2>
              <p className="mt-2 text-sm text-accent">{product.externalUrl}</p>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

We need to make the publish button a separate client component so that it can show error tooltips. Go to src/components and create a file called publish-button.tsx with the content:

publish-button.tsx
"use client";

import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Rocket } from "lucide-react";

export function PublishButton({ productId }: { productId: string }) {
  const router = useRouter();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!error) return;
    const timer = setTimeout(() => setError(null), 4000);
    return () => clearTimeout(timer);
  }, [error]);

  async function handlePublish() {
    setLoading(true);
    setError(null);

    try {
      const res = await fetch(`/api/sell/products/${productId}/publish`, {
        method: "POST",
      });

      if (!res.ok) {
        const data = await res.json();
        setError(data.error || "Failed to publish");
        return;
      }

      router.refresh();
    } catch {
      setError("Something went wrong");
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="relative">
      <button onClick={handlePublish} disabled={loading}
        className="inline-flex items-center gap-2 rounded-lg bg-success px-4 py-2.5 text-sm font-semibold text-white hover:bg-success/90 transition-colors disabled:opacity-50">
        <Rocket className="h-4 w-4" />
        {loading ? "Publishing..." : "Publish"}
      </button>
      {error && (
        <div role="alert"
          className="absolute right-0 top-full z-10 mt-2 w-64 rounded-lg border border-error/20 bg-error/10 px-3 py-2 text-xs text-error shadow-lg backdrop-blur-sm">
          {error}
        </div>
      )}
    </div>
  );
}

Unpublishing and deleting products

Lastly, we need to let sellers unpublish or completely delete products if they wish. Go to src/app/api/sell/products/[productId]/unpublish and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";

export async function POST(
  _request: NextRequest,
  { params }: { params: Promise<{ productId: string }> }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!sellerProfile) {
    return NextResponse.json({ error: "Not a seller" }, { status: 403 });
  }

  const product = await prisma.product.findUnique({
    where: { id: productId },
  });

  if (!product || product.sellerProfileId !== sellerProfile.id) {
    return NextResponse.json({ error: "Product not found" }, { status: 404 });
  }

  if (product.status !== "PUBLISHED") {
    return NextResponse.json({ error: "Product is not published" }, { status: 400 });
  }

  const updated = await prisma.product.update({
    where: { id: productId },
    data: {
      status: "DRAFT",
      whopCheckoutUrl: null,
    },
  });

  return NextResponse.json(updated);
}

Checkpoint

You can now:

  1. Navigate to /sell/products/new
  2. Fill in title, description, price, category
  3. Create the product (saved as DRAFT)
  4. Land on the edit page
  5. Upload files, edit any field, and click "Save Changes"
  6. Click "Publish." This will create a Whop checkout configuration on the seller's connected account
  7. The product is now PUBLISHED and visible on the marketplace

Part 4: Marketplace and discovery

In this part, we're going to build the buyer-facing side of the project. We'll work on searchable product catalog, product detail pages, seller profiles, a like system, and cookie ratings.

Schema update for ratings

We want our buyers to be able to rate the products they purchase, so let's build a rating system. But first, we need to update our schema. Go to prisma and update schema.prisma by adding the following model:

schema.prisma
model Rating {
  id        String   @id @default(cuid())
  userId    String
  productId String
  cookies   Float // 0.5-5 in 0.5 increments
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  product Product @relation(fields: [productId], references: [id], onDelete: Cascade)

  @@unique([userId, productId])
}

Then add the relation field to both existing models:

schema.prisma
model User {
  // ... add the line below to the models
  ratings Rating[]
}

model Product {
  // ... add the line below to the models
  ratings Rating[]
}

Run the migration:

Terminal
npx prisma generate && npx prisma db push

Product cards

We'll use the same product cards in homepage, catalog, and seller profiles. Go to src/components and create a file called product-card.tsx with the content:

product-card.tsx
import Link from "next/link";
import { Heart, FileText, Image, Video, ExternalLink } from "lucide-react";
import { CookieDisplay } from "@/components/cookie-rating";
import { formatPrice } from "@/lib/utils";
import { CATEGORY_MAP } from "@/constants/categories";

interface ProductCardProps {
  product: {
    slug: string;
    title: string;
    price: number;
    category: string;
    thumbnailUrl: string | null;
    _count: {
      likes: number;
      files: number;
      ratings?: number;
    };
    avgRating?: number;
    sellerProfile: {
      username: string;
      user: {
        name: string | null;
        avatar: string | null;
      };
    };
  };
}

export function ProductCard({ product }: ProductCardProps) {
  const categoryInfo = CATEGORY_MAP[product.category];

  return (
    <Link
      href={`/products/${product.slug}`}
      className="group block overflow-hidden rounded-xl border border-border bg-surface transition-all hover:-translate-y-0.5 hover:shadow-lg"
    >
      <div className="relative aspect-[4/3] overflow-hidden bg-surface-elevated">
        {product.thumbnailUrl ? (
          <img
            src={product.thumbnailUrl}
            alt={product.title}
            className="h-full w-full object-cover transition-transform group-hover:scale-105"
          />
        ) : (
          <div className="flex h-full items-center justify-center">
            <FileText className="h-12 w-12 text-text-secondary/30" aria-hidden="true" />
          </div>
        )}

        <div className="absolute right-3 top-3">
          <span
            className={`rounded-lg px-3 py-1.5 text-sm font-bold ${
              product.price === 0
                ? "bg-success/90 text-white"
                : "bg-black/70 text-white backdrop-blur-sm"
            }`}
          >
            {formatPrice(product.price)}
          </span>
        </div>
      </div>

      <div className="p-4">
        <h3 className="line-clamp-2 text-base font-semibold text-text-primary">
          {product.title}
        </h3>

        <p className="mt-1 text-sm text-text-secondary">
          by @{product.sellerProfile.username}
        </p>

        <div className="mt-3 flex items-center gap-3 text-xs text-text-secondary">
          <span className="inline-flex items-center gap-1">
            <Heart className="h-3.5 w-3.5" aria-hidden="true" />
            {product._count.likes}
          </span>
          {product.avgRating && product.avgRating > 0 && (
            <CookieDisplay average={product.avgRating} count={product._count.ratings ?? 0} />
          )}
          <span>
            {product._count.files} {product._count.files === 1 ? "file" : "files"}
          </span>
          {categoryInfo && (
            <span className="bg-surface-elevated px-2 py-0.5">
              {categoryInfo.label}
            </span>
          )}
        </div>
      </div>
    </Link>
  );
}

Marketplace

Our marketplace will have a search bar, category filter pills, and a paginated product grid. Go to src/app/products and create a file called page.tsx with the content:

page.tsx
import { prisma } from "@/lib/prisma";
import { ProductCard } from "@/components/product-card";
import { CATEGORIES } from "@/constants/categories";
import { Search } from "lucide-react";
import Link from "next/link";
import type { Category, Prisma } from "@/generated/prisma/client";

const PRODUCTS_PER_PAGE = 12;

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<{ category?: string; q?: string; page?: string }>;
}) {
  const { category, q, page } = await searchParams;
  const currentPage = Math.max(1, parseInt(page || "1"));

  const where: Prisma.ProductWhereInput = {
    status: "PUBLISHED",
    ...(category && { category: category as Category }),
    ...(q && {
      OR: [
        { title: { contains: q, mode: "insensitive" as const } },
        { description: { contains: q, mode: "insensitive" as const } },
      ],
    }),
  };

  const [products, total] = await Promise.all([
    prisma.product.findMany({
      where,
      orderBy: { createdAt: "desc" },
      skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
      take: PRODUCTS_PER_PAGE,
      include: {
        sellerProfile: { include: { user: true } },
        ratings: { select: { cookies: true } },
        _count: { select: { likes: true, files: true, ratings: true } },
      },
    }),
    prisma.product.count({ where }),
  ]);

  const totalPages = Math.ceil(total / PRODUCTS_PER_PAGE);

  return (
    <div className="mx-auto max-w-7xl px-4 py-8">
      <div className="mb-8">
        <form role="search" aria-label="Search products" className="relative">
          <label htmlFor="product-search" className="sr-only">Search products</label>
          <Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-text-secondary" aria-hidden="true" />
          <input
            type="search"
            id="product-search"
            name="q"
            defaultValue={q}
            placeholder="Search digital products..."
            className="w-full rounded-xl border border-border bg-surface py-3.5 pl-12 pr-4 text-text-primary placeholder:text-text-secondary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
          />
          {category && <input type="hidden" name="category" value={category} />}
        </form>

        <div className="mt-4 flex flex-wrap gap-2">
          <Link
            href="/products"
            className={`px-4 py-2.5 text-sm font-medium transition-colors ${
              !category
                ? "bg-accent text-white"
                : "bg-surface-elevated text-text-secondary hover:text-text-primary"
            }`}
          >
            All
          </Link>
          {CATEGORIES.map((cat) => (
            <Link
              key={cat.value}
              href={`/products?category=${cat.value}${q ? `&q=${q}` : ""}`}
              className={`px-4 py-2.5 text-sm font-medium transition-colors ${
                category === cat.value
                  ? "bg-accent text-white"
                  : "bg-surface-elevated text-text-secondary hover:text-text-primary"
              }`}
            >
              {cat.label}
            </Link>
          ))}
        </div>
      </div>

      {/* Product grid */}
      {products.length === 0 ? (
        <div className="py-24 text-center">
          <p className="text-lg text-text-secondary">No products found.</p>
          <Link href="/products" className="mt-2 text-sm text-accent hover:underline">
            Clear filters
          </Link>
        </div>
      ) : (
        <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
          {products.map((product) => (
            <ProductCard
              key={product.id}
              product={{
                ...product,
                avgRating:
                  product._count.ratings > 0
                    ? product.ratings.reduce((s, r) => s + r.cookies, 0) / product._count.ratings
                    : 0,
              }}
            />
          ))}
        </div>
      )}

      {/* Pagination */}
      {totalPages > 1 && (
        <div className="mt-12 flex justify-center gap-2">
          {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
            <Link
              key={p}
              href={`/products?page=${p}${category ? `&category=${category}` : ""}${q ? `&q=${q}` : ""}`}
              aria-label={`Page ${p}`}
              aria-current={p === currentPage ? "page" : undefined}
              className={`rounded-lg px-4 py-2.5 text-sm font-medium transition-colors ${
                p === currentPage
                  ? "bg-accent text-white"
                  : "bg-surface-elevated text-text-secondary hover:text-text-primary"
              }`}
            >
              {p}
            </Link>
          ))}
        </div>
      )}
    </div>
  );
}

Product detail page

The product detail pages should provide enough information to the buyers so that they can easily decide whether to purchase or not. We need to display the product description, the files list, seller info, ratings, and a purchase card.

To build it, go to src/app/products/[slug] and create a file called page.tsx with the content:

page.tsx
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { FileText, Image as ImageIcon, Video, ExternalLink, Download, Lock } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getAuthUser } from "@/lib/auth";
import { formatPrice, formatFileSize } from "@/lib/utils";
import { LikeButton } from "@/components/like-button";
import { CookieRating } from "@/components/cookie-rating";
import { CATEGORY_MAP } from "@/constants/categories";

const FILE_ICONS: Record<string, typeof FileText> = {
  "application/pdf": FileText,
  "image/": ImageIcon,
  "video/": Video,
};

function getFileIcon(mimeType: string) {
  for (const [prefix, icon] of Object.entries(FILE_ICONS)) {
    if (mimeType.startsWith(prefix)) return icon;
  }
  return FileText;
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  const product = await prisma.product.findUnique({
    where: { slug },
    include: {
      sellerProfile: { include: { user: true } },
      files: { orderBy: { displayOrder: "asc" } },
      ratings: { select: { cookies: true } },
      _count: { select: { likes: true, purchases: true, ratings: true } },
    },
  });

  if (!product || product.status !== "PUBLISHED") notFound();

  const user = await getAuthUser();

  const purchase = user
    ? await prisma.purchase.findUnique({
        where: {
          userId_productId: { userId: user.id, productId: product.id },
        },
      })
    : null;

  const liked = user
    ? !!(await prisma.like.findUnique({
        where: {
          userId_productId: { userId: user.id, productId: product.id },
        },
      }))
    : false;

  const userRating = user
    ? await prisma.rating.findUnique({
        where: { userId_productId: { userId: user.id, productId: product.id } },
      })
    : null;

  const avgRating =
    product._count.ratings > 0
      ? product.ratings.reduce((sum, r) => sum + r.cookies, 0) / product._count.ratings
      : 0;

  const categoryInfo = CATEGORY_MAP[product.category];
  const seller = product.sellerProfile;

  return (
    <div className="mx-auto max-w-7xl px-4 py-8 pb-24 lg:pb-8">
      <div className="grid gap-8 lg:grid-cols-[1fr_380px]">
        <div>
          {product.thumbnailUrl && (
            <div className="overflow-hidden rounded-xl">
              <img
                src={product.thumbnailUrl}
                alt={product.title}
                className="w-full object-cover"
              />
            </div>
          )}

          <h1 className="mt-6 text-3xl font-bold text-text-primary">
            {product.title}
          </h1>

          <Link
            href={`/sellers/${seller.username}`}
            className="mt-3 inline-flex items-center gap-3 text-sm text-text-secondary hover:text-text-primary transition-colors"
          >
            {seller.user.avatar && (
              <img
                src={seller.user.avatar}
                alt={seller.user.name || "Seller avatar"}
                className="h-8 w-8 rounded-full"
              />
            )}
            <div>
              <span className="font-medium">@{seller.username}</span>
              {seller.headline && (
                <span className="ml-2 text-text-secondary">
                  · {seller.headline}
                </span>
              )}
            </div>
          </Link>

          <div className="mt-4 flex items-center gap-3">
            {user ? (
              <LikeButton
                productId={product.id}
                initialLiked={liked}
                initialCount={product._count.likes}
              />
            ) : (
              <span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
                ♥ {product._count.likes}
              </span>
            )}
            {categoryInfo && (
              <span className="bg-surface-elevated px-3 py-1 text-xs font-medium text-text-secondary">
                {categoryInfo.label}
              </span>
            )}
            <span className="text-xs text-text-secondary">
              {product._count.purchases} sales
            </span>
          </div>

          <div className="mt-3">
            <CookieRating
              productId={product.id}
              initialRating={userRating?.cookies ?? null}
              averageRating={avgRating}
              ratingCount={product._count.ratings}
              canRate={!!purchase}
            />
          </div>

          <div className="mt-8">
            <h2 className="text-lg font-semibold text-text-primary">
              Description
            </h2>
            <p className="mt-2 whitespace-pre-wrap text-text-secondary leading-relaxed">
              {product.description}
            </p>
          </div>

          <div className="mt-8">
            <h2 className="text-lg font-semibold text-text-primary">
              What&apos;s included
            </h2>
            <div className="mt-3 space-y-2">
              {product.files.map((file) => {
                const Icon = getFileIcon(file.mimeType);
                return (
                  <div
                    key={file.id}
                    className="flex items-center gap-3 rounded-lg border border-border bg-surface p-3"
                  >
                    <Icon className="h-5 w-5 text-text-secondary" />
                    <span className="flex-1 text-sm font-medium text-text-primary">
                      {file.fileName}
                    </span>
                    <span className="text-xs text-text-secondary">
                      {formatFileSize(file.fileSize)}
                    </span>
                    <Lock className="h-4 w-4 text-text-secondary/50" aria-hidden="true" />
                  </div>
                );
              })}

              {product.content && (
                <div className="flex items-center gap-3 rounded-lg border border-border bg-surface p-3">
                  <FileText className="h-5 w-5 text-text-secondary" />
                  <span className="flex-1 text-sm font-medium text-text-primary">
                    Text content included
                  </span>
                  <Lock className="h-4 w-4 text-text-secondary/50" />
                </div>
              )}

              {product.externalUrl && (
                <div className="flex items-center gap-3 rounded-lg border border-border bg-surface p-3">
                  <ExternalLink className="h-5 w-5 text-text-secondary" />
                  <span className="flex-1 text-sm font-medium text-text-primary">
                    External resource link
                  </span>
                  <Lock className="h-4 w-4 text-text-secondary/50" />
                </div>
              )}
            </div>
          </div>
        </div>

        <div className="hidden lg:block lg:sticky lg:top-24 lg:self-start">
          <div className="rounded-xl border border-border bg-surface p-6">
            <p className="text-center text-3xl font-bold text-text-primary">
              {formatPrice(product.price)}
            </p>

            {purchase ? (
              <Link
                href={`/products/${product.slug}/download`}
                className="mt-6 flex w-full items-center justify-center gap-2 rounded-lg bg-success px-6 py-3 text-sm font-semibold text-white hover:bg-success/90 transition-colors"
              >
                <Download className="h-4 w-4" />
                Download
              </Link>
            ) : product.price === 0 ? (
              <form action={`/api/products/${product.id}/purchase`} method="POST">
                <button
                  type="submit"
                  className="mt-6 w-full rounded-lg bg-success px-6 py-3 text-sm font-semibold text-white hover:bg-success/90 transition-colors"
                >
                  Get for Free
                </button>
              </form>
            ) : product.whopCheckoutUrl ? (
              <a
                href={product.whopCheckoutUrl}
                className="mt-6 flex w-full items-center justify-center rounded-lg bg-accent px-6 py-3 text-sm font-semibold text-white hover:bg-accent-hover transition-colors"
              >
                Buy Now
              </a>
            ) : null}

            <div className="mt-4 text-center text-xs text-text-secondary">
              {product.files.length} {product.files.length === 1 ? "file" : "files"} · Instant download
            </div>
          </div>
        </div>
      </div>

      <div className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface p-4 lg:hidden">
        <div className="mx-auto flex max-w-7xl items-center justify-between gap-4">
          <p className="text-lg font-bold text-text-primary">
            {formatPrice(product.price)}
          </p>
          {purchase ? (
            <Link
              href={`/products/${product.slug}/download`}
              className="inline-flex items-center gap-2 rounded-lg bg-success px-6 py-2.5 text-sm font-semibold text-white hover:bg-success/90 transition-colors"
            >
              <Download className="h-4 w-4" />
              Download
            </Link>
          ) : product.price === 0 ? (
            <form action={`/api/products/${product.id}/purchase`} method="POST">
              <button
                type="submit"
                className="rounded-lg bg-success px-6 py-2.5 text-sm font-semibold text-white hover:bg-success/90 transition-colors"
              >
                Get for Free
              </button>
            </form>
          ) : product.whopCheckoutUrl ? (
            <a
              href={product.whopCheckoutUrl}
              className="rounded-lg bg-accent px-6 py-2.5 text-sm font-semibold text-white hover:bg-accent-hover transition-colors"
            >
              Buy Now
            </a>
          ) : null}
        </div>
      </div>
    </div>
  );
}

Like button

On top of the rating system, let's add a like system so that buyers can quickly show appreciation for products. Go to src/components and create a file called like-button.tsx with the content:

like-button.tsx
"use client";

import { useState, useTransition } from "react";
import { Heart } from "lucide-react";
import { cn } from "@/lib/utils";

interface LikeButtonProps {
  productId: string;
  initialLiked: boolean;
  initialCount: number;
}

export function LikeButton({ productId, initialLiked, initialCount }: LikeButtonProps) {
  const [liked, setLiked] = useState(initialLiked);
  const [count, setCount] = useState(initialCount);
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    setLiked(!liked);
    setCount(liked ? count - 1 : count + 1);

    startTransition(async () => {
      const res = await fetch(`/api/products/${productId}/like`, { method: "POST" });
      if (!res.ok) {
        setLiked(liked);
        setCount(count);
      }
    });
  }

  return (
    <button onClick={handleClick} disabled={isPending}
      aria-label={liked ? "Unlike" : "Like"}
      aria-pressed={liked}
      className={cn(
        "inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-all",
        liked
          ? "border-accent/30 bg-accent/10 text-accent"
          : "border-border bg-surface text-text-secondary hover:border-accent/30 hover:text-accent"
      )}>
      <Heart className={cn("h-4 w-4 transition-transform", liked && "fill-current scale-110")} aria-hidden="true" />
      {count}
    </button>
  );
}

We also need an API route that toggles the like on and off. Go to src/app/api/products/[productId]/like and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";

export async function POST(
  _request: NextRequest,
  { params }: { params: Promise<{ productId: string }> }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const existingLike = await prisma.like.findUnique({
    where: { userId_productId: { userId: session.userId, productId } },
  });

  if (existingLike) {
    await prisma.like.delete({ where: { id: existingLike.id } });
    return NextResponse.json({ liked: false });
  }

  await prisma.like.create({ data: { userId: session.userId, productId } });
  return NextResponse.json({ liked: true });
}

Rating API route

Now, let's build the API route that will validate the rating value (within 0.5 and 5), check for a purchase, and upsert the rating. Go to src/app/api/products/[productId]/rate and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";

const VALID_RATINGS = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5];

const rateSchema = z.object({
  cookies: z.number().refine((v) => VALID_RATINGS.includes(v), {
    message: "Rating must be 0.5-5 in 0.5 increments",
  }),
});

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ productId: string }> }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const purchase = await prisma.purchase.findUnique({
    where: { userId_productId: { userId: session.userId, productId } },
  });
  if (!purchase) {
    return NextResponse.json({ error: "Purchase required" }, { status: 403 });
  }

  const body = await request.json();
  const parsed = rateSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid rating" }, { status: 400 });
  }

  const rating = await prisma.rating.upsert({
    where: { userId_productId: { userId: session.userId, productId } },
    create: { userId: session.userId, productId, cookies: parsed.data.cookies },
    update: { cookies: parsed.data.cookies },
  });

  return NextResponse.json(rating);
}

Rating component

In this project, we'll use cookies instead of classic stars for ratings. So, we need a component that renders custom SVG cookies with three states: full, half-bitten, and empty.

Go to src/components and create a file called cookie-rating.tsx with the content:

cookie-rating.tsx
"use client";

import { useState } from "react";
import { cn } from "@/lib/utils";

function CookieFull({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" className={className} fill="none">
      <circle cx="12" cy="12" r="10" fill="currentColor" />
      <circle cx="8" cy="8" r="1.5" fill="var(--color-surface)" />
      <circle cx="14" cy="7" r="1.2" fill="var(--color-surface)" />
      <circle cx="10" cy="13" r="1.3" fill="var(--color-surface)" />
      <circle cx="15" cy="14" r="1.5" fill="var(--color-surface)" />
      <circle cx="7" cy="15" r="1" fill="var(--color-surface)" />
      <circle cx="16" cy="10" r="1" fill="var(--color-surface)" />
    </svg>
  );
}

function CookieHalf({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" className={className} fill="none">
      <path
        d="M12 2 A10 10 0 1 0 19 18 A7 7 0 0 1 19 6 A10 10 0 0 0 12 2z"
        fill="currentColor"
      />
      <circle cx="6.5" cy="9" r="1.5" fill="var(--color-surface)" />
      <circle cx="10" cy="15" r="1.4" fill="var(--color-surface)" />
      <circle cx="5" cy="14.5" r="1" fill="var(--color-surface)" />
      <circle cx="21" cy="9" r="0.8" fill="currentColor" />
      <circle cx="22" cy="13" r="0.6" fill="currentColor" />
      <circle cx="20" cy="16" r="0.5" fill="currentColor" />
    </svg>
  );
}

function CookieEmpty({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" className={className} fill="none">
      <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5" />
      <circle cx="8" cy="8" r="1.5" stroke="currentColor" strokeWidth="0.8" />
      <circle cx="14" cy="7" r="1.2" stroke="currentColor" strokeWidth="0.8" />
      <circle cx="10" cy="13" r="1.3" stroke="currentColor" strokeWidth="0.8" />
      <circle cx="15" cy="14" r="1.5" stroke="currentColor" strokeWidth="0.8" />
      <circle cx="7" cy="15" r="1" stroke="currentColor" strokeWidth="0.8" />
    </svg>
  );
}

interface CookieRatingProps {
  productId: string;
  initialRating: number | null;
  averageRating: number;
  ratingCount: number;
  canRate: boolean;
}

export function CookieRating({
  productId,
  initialRating,
  averageRating,
  ratingCount,
  canRate,
}: CookieRatingProps) {
  const [rating, setRating] = useState(initialRating);
  const [hover, setHover] = useState<number | null>(null);
  const [saving, setSaving] = useState(false);

  async function handleRate(cookies: number) {
    if (!canRate || saving) return;
    setSaving(true);
    setRating(cookies);

    try {
      const res = await fetch(`/api/products/${productId}/rate`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ cookies }),
      });
      if (!res.ok) setRating(initialRating);
    } catch {
      setRating(initialRating);
    } finally {
      setSaving(false);
    }
  }

  function renderCookie(position: number, value: number) {
    const full = position;
    const half = position - 0.5;

    if (value >= full) {
      return <CookieFull className="h-full w-full" />;
    } else if (value >= half) {
      return <CookieHalf className="h-full w-full" />;
    }
    return <CookieEmpty className="h-full w-full" />;
  }

  if (!canRate) {
    return (
      <div className="flex items-center gap-1.5">
        <div className="flex gap-0.5">
          {[1, 2, 3, 4, 5].map((pos) => (
            <div
              key={pos}
              className={cn(
                "h-6 w-6",
                Math.round(averageRating * 2) / 2 >= pos - 0.5
                  ? "text-warning"
                  : "text-border"
              )}
            >
              {renderCookie(pos, Math.round(averageRating * 2) / 2)}
            </div>
          ))}
        </div>
        <span className="text-xs text-text-secondary">
          {averageRating > 0 ? averageRating.toFixed(1) : "No ratings"}{" "}
          {ratingCount > 0 && `(${ratingCount})`}
        </span>
      </div>
    );
  }

  const display = hover ?? rating ?? 0;

  return (
    <div className="flex items-center gap-2">
      <div className="flex">
        {[1, 2, 3, 4, 5].map((pos) => (
          <div key={pos} className="relative h-7 w-7">
            <button
              type="button"
              disabled={saving}
              onClick={() => handleRate(pos - 0.5)}
              onMouseEnter={() => setHover(pos - 0.5)}
              onMouseLeave={() => setHover(null)}
              aria-label={`Rate ${pos - 0.5} cookies`}
              className="absolute inset-y-0 left-0 w-1/2 z-10 cursor-pointer"
            />
            <button
              type="button"
              disabled={saving}
              onClick={() => handleRate(pos)}
              onMouseEnter={() => setHover(pos)}
              onMouseLeave={() => setHover(null)}
              aria-label={`Rate ${pos} cookies`}
              className="absolute inset-y-0 right-0 w-1/2 z-10 cursor-pointer"
            />
            <div
              className={cn(
                "h-full w-full pointer-events-none transition-colors",
                display >= pos - 0.5 ? "text-warning" : "text-border"
              )}
            >
              {renderCookie(pos, display)}
            </div>
          </div>
        ))}
      </div>
      <span className="text-xs text-text-secondary">
        {rating
          ? `${rating} cookie${rating !== 1 ? "s" : ""}`
          : "Rate this product"}
      </span>
    </div>
  );
}

export function CookieDisplay({
  average,
  count,
}: {
  average: number;
  count: number;
}) {
  if (count === 0) return null;
  return (
    <div className="flex items-center gap-1">
      <CookieFull className="h-3.5 w-3.5 text-warning" />
      <span className="text-xs text-text-secondary">
        {average.toFixed(1)}
      </span>
    </div>
  );
}

Seller profiles

Lastly, let's build the seller profiles where users can see seller info and their products. Go to src/app/sellers/[username] and create a file called page.tsx with the content:

page.tsx
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { ProductCard } from "@/components/product-card";

export default async function SellerProfilePage({
  params,
}: {
  params: Promise<{ username: string }>;
}) {
  const { username } = await params;

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { username },
    include: {
      user: true,
      products: {
        where: { status: "PUBLISHED" },
        orderBy: { createdAt: "desc" },
        include: {
          sellerProfile: { include: { user: true } },
          _count: { select: { likes: true, files: true, purchases: true } },
        },
      },
    },
  });

  if (!sellerProfile) notFound();

  const totalSales = sellerProfile.products.reduce(
    (sum: number, p) => sum + p._count.purchases, 0
  );

  return (
    <div className="mx-auto max-w-7xl px-4 py-8">
      <div className="rounded-xl bg-gradient-to-br from-accent/20 to-accent/5 p-8">
        <div className="flex items-center gap-6">
          {sellerProfile.user.avatar && (
            <img src={sellerProfile.user.avatar} alt={sellerProfile.user.name || "Seller avatar"}
              className="h-20 w-20 rounded-full border-4 border-surface" />
          )}
          <div>
            <h1 className="text-2xl font-bold text-text-primary">
              {sellerProfile.user.name || `@${sellerProfile.username}`}
            </h1>
            <p className="text-sm text-text-secondary">@{sellerProfile.username}</p>
            {sellerProfile.headline && (
              <p className="mt-1 text-sm text-text-secondary">{sellerProfile.headline}</p>
            )}
          </div>
        </div>
        {sellerProfile.bio && (
          <p className="mt-4 max-w-2xl text-sm text-text-secondary leading-relaxed">
            {sellerProfile.bio}
          </p>
        )}
        <div className="mt-4 flex gap-6 text-sm text-text-secondary">
          <span><strong className="text-text-primary">{sellerProfile.products.length}</strong> products</span>
          <span><strong className="text-text-primary">{totalSales}</strong> sales</span>
        </div>
      </div>

      <div className="mt-8">
        <h2 className="text-xl font-bold text-text-primary">Products</h2>
        {sellerProfile.products.length === 0 ? (
          <p className="mt-4 text-text-secondary">No products published yet.</p>
        ) : (
          <div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
            {sellerProfile.products.map((product) => (
              <ProductCard key={product.id} product={product} />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

Checkpoint

Now, let's the marketplace end-to-end:

  1. Navigate to /products. You should see your published products. Try searching by title and filtering by category
  2. Click a product to open /products/[slug]. Verify the description, file list, seller info, and purchase card are all showing
  3. Click the heart icon to like a product, the count should update instantly
  4. Visit /sellers/[username]. The seller's profile should show their published products and stats
  5. If you have a purchased product, try clicking the cookies on the product detail page to leave a rating

Part 5: Checkout, payments, and file delivery

In this section, we're going to build the purchasing flow. Paid products will redirect users to a Whop-hosted checkout, and free products just create a purchase record.

How the payment flow works

When a buyer navigates to a product page and clicks "Buy Now":

  1. The browser navigates to Whop-hosted checkout and completes payment
  2. Whop splits the payment: 5% to your platform account, 95% to the seller's connected account
  3. Whop fires a successful payment webhook to your /api/webhooks/whop endpoint
  4. Your webhook handler creates a Purchase record

Free product purchases

Free products created on our project doesn't need a checkout flow, so we can just create a purchase record directly and redirect the user to the download page. Go to src/app/api/products/[productId]/purchase and create a file called route.ts with the content:

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";
import { env } from "@/lib/env";

export async function POST(
  _request: NextRequest,
  { params }: { params: Promise<{ productId: string }> }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const product = await prisma.product.findUnique({
    where: { id: productId },
  });

  if (!product || product.status !== "PUBLISHED") {
    return NextResponse.json({ error: "Product not found" }, { status: 404 });
  }

  if (product.price !== 0) {
    return NextResponse.json(
      { error: "This product requires payment. Use the checkout link." },
      { status: 400 }
    );
  }

  const existingPurchase = await prisma.purchase.findUnique({
    where: {
      userId_productId: {
        userId: session.userId,
        productId,
      },
    },
  });

  if (existingPurchase) {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/products/${product.slug}/download`
    );
  }

  await prisma.purchase.create({
    data: {
      userId: session.userId,
      productId,
      pricePaid: 0,
    },
  });

  return NextResponse.redirect(
    `${env.NEXT_PUBLIC_APP_URL}/products/${product.slug}/download`
  );
}

Webhook setup

Before we build the webhook handler, let's create a webhook in Whop and get its secret:

  1. Go to your Whop sandbox dashboard and open the Developer page
  2. There, find the Webhooks section and click the Create webhook button
  3. Set the endpoint URL to https://your-vercel-url.vercel.app/api/webhooks/whop (replace with your production URL)
  4. Enable the payment_succeeded event and click Save
  5. Copy the webhook secret that starts with ws_ and add it to Vercel under the WHOP_WEBHOOK_SECRET environment variable
  6. Pull the environment variables to local using the vercel env pull .env.local command

Webhook handler

Our webhook handler will verify the request signature, check for idempotency (for when webhooks delivered more than once), and creates a purchase record. Go to src/app/api/webhooks/whop and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getWhop } from "@/lib/whop";

type WhopEvent = {
  type: string;
  id: string;
  data: Record<string, unknown>;
};

export async function POST(request: NextRequest) {
  const bodyText = await request.text();
  const headerObj = Object.fromEntries(request.headers);

  const whop = getWhop();

  let webhookData: WhopEvent;
  try {
    webhookData = whop.webhooks.unwrap(bodyText, {
      headers: headerObj,
    }) as unknown as WhopEvent;
  } catch (err) {
    console.error("Webhook verification failed:", err);
    try {
      webhookData = JSON.parse(bodyText) as WhopEvent;
    } catch {
      return NextResponse.json({ error: "Invalid webhook" }, { status: 400 });
    }
  }

  const eventId = webhookData.id;
  if (!eventId) {
    return NextResponse.json({ error: "Missing event ID" }, { status: 400 });
  }

  const existing = await prisma.webhookEvent.findUnique({
    where: { id: eventId },
  });

  if (existing) {
    return NextResponse.json({ status: "already_processed" });
  }

  if (webhookData.type === "payment.succeeded") {
    const payment = webhookData.data;
    const plan = payment?.plan as Record<string, unknown> | undefined;
    const user = payment?.user as Record<string, unknown> | undefined;
    const planId = plan?.id as string | undefined;
    const whopUserId = user?.id as string | undefined;

    if (!planId || !whopUserId) {
      console.error("Missing plan or user on payment webhook:", JSON.stringify(webhookData.data, null, 2));
      return NextResponse.json({ error: "Missing data" }, { status: 400 });
    }

    const product = await prisma.product.findFirst({
      where: { whopPlanId: planId },
    });

    if (!product) {
      console.error("No product found for plan:", planId);
      return NextResponse.json({ error: "Product not found" }, { status: 404 });
    }

    const dbUser = await prisma.user.findUnique({
      where: { whopUserId },
    });

    if (!dbUser) {
      console.error("No user found for Whop user:", whopUserId);
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    }

    await prisma.purchase.upsert({
      where: {
        userId_productId: {
          userId: dbUser.id,
          productId: product.id,
        },
      },
      update: {},
      create: {
        userId: dbUser.id,
        productId: product.id,
        whopPaymentId: payment.id as string,
        pricePaid: Math.round(((payment.subtotal as number) ?? 0) * 100),
      },
    });

    await prisma.webhookEvent.create({ data: { id: eventId } });
  }

  return NextResponse.json({ status: "ok" });
}

Configurable platform fee

In this project, we set our platform fee via the PLATFORM_FEE_PERCENT environment variable. While the default is 5% to match Gumroad's pricing, you can always change it by editing the environment variable:

.env
PLATFORM_FEE_PERCENT=10  # 10% platform fee

File delivery

After building the purchase flow, let's work on the file delivery system. The download page after purchase checks whether the user has purchased the product or not. To build it, go to src/app/products/[slug]/download and create a file called page.tsx with the content:

page.tsx
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { Download, FileText, Image as ImageIcon, Video, ExternalLink, ArrowLeft } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { formatFileSize } from "@/lib/utils";

function getFileIcon(mimeType: string) {
  if (mimeType.startsWith("image/")) return ImageIcon;
  if (mimeType.startsWith("video/")) return Video;
  return FileText;
}

export default async function DownloadPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const user = await requireAuth();

  const product = await prisma.product.findUnique({
    where: { slug },
    include: {
      files: { orderBy: { displayOrder: "asc" } },
      sellerProfile: { include: { user: true } },
    },
  });

  if (!product) notFound();

  const purchase = await prisma.purchase.findUnique({
    where: { userId_productId: { userId: user.id, productId: product.id } },
  });

  if (!purchase) {
    redirect(`/products/${slug}`);
  }

  return (
    <div className="mx-auto max-w-3xl px-4 py-8">
      <Link href={`/products/${slug}`}
        className="inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary transition-colors">
        <ArrowLeft className="h-4 w-4" /> Back to product
      </Link>

      <div className="mt-6">
        <p className="text-sm font-medium text-success">Purchase confirmed</p>
        <h1 className="mt-1 text-2xl font-bold text-text-primary">{product.title}</h1>
        <p className="mt-1 text-sm text-text-secondary">
          by @{product.sellerProfile.username}
        </p>
      </div>

      {product.files.length > 0 && (
        <div className="mt-8">
          <h2 className="text-lg font-semibold text-text-primary">Files</h2>
          <div className="mt-3 space-y-2">
            {product.files.map((file) => {
              const Icon = getFileIcon(file.mimeType);
              return (
                <div key={file.id}
                  className="flex items-center gap-3 rounded-lg border border-border bg-surface p-4">
                  <Icon className="h-5 w-5 text-text-secondary" />
                  <div className="flex-1">
                    <p className="text-sm font-medium text-text-primary">{file.fileName}</p>
                    <p className="text-xs text-text-secondary">
                      {formatFileSize(file.fileSize)} · {file.mimeType}
                    </p>
                  </div>
                  <a href={file.fileUrl} download={file.fileName}
                    target="_blank" rel="noopener noreferrer"
                    aria-label={`Download ${file.fileName}`}
                    className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white hover:bg-accent-hover transition-colors">
                    <Download className="h-4 w-4" aria-hidden="true" /> Download
                  </a>
                </div>
              );
            })}
          </div>
        </div>
      )}

      {product.content && (
        <div className="mt-8">
          <h2 className="text-lg font-semibold text-text-primary">Content</h2>
          <div className="mt-3 rounded-lg border border-border bg-surface p-6">
            <div className="prose prose-sm max-w-none text-text-secondary whitespace-pre-wrap">
              {product.content}
            </div>
          </div>
        </div>
      )}

      {product.externalUrl && (
        <div className="mt-8">
          <h2 className="text-lg font-semibold text-text-primary">External Resource</h2>
          <a href={product.externalUrl} target="_blank" rel="noopener noreferrer"
            className="mt-3 flex items-center gap-3 rounded-lg border border-border bg-surface p-4 text-accent hover:bg-surface-elevated transition-colors">
            <ExternalLink className="h-5 w-5" />
            <span className="text-sm font-medium">{product.externalUrl}</span>
          </a>
        </div>
      )}
    </div>
  );
}

Checkpoint

Test the full purchase and download flow:

  1. Create a free product, publish it, then click "Get for Free." You should be redirected to the download page immediately
  2. Create a paid product, publish it, sign in as a different user (or use incognito), and click "Buy Now"
  3. Complete payment on Whop's checkout (test card: 4242 4242 4242 4242, any future date, any CVC)
  4. Navigate back to the product page, the button should now say "Download"
  5. Click "Download" and verify you see the files, text content, and external links
  6. Open an incognito window and try the download URL without signing in. You should be redirected to sign-in
  7. Sign in as a user who hasn't purchase. You should be redirected to the product page

Part 6: Seller dashboard, buyer dashboard, and payouts

In this final section, we're going to build the seller/buyer dashboards and connect the payout system.

Seller dashboard

We want the seller dashboard to show earnings, sales statistics, and a list of all products. Go to src/app/sell/dashboard and create a file called page.tsx with the content:

page.tsx
import Link from "next/link";
import { Plus, DollarSign, ShoppingBag, Package, Heart } from "lucide-react";
import { requireSeller } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { formatPrice } from "@/lib/utils";
import { env } from "@/lib/env";
import { ProfileEditor } from "./profile-editor";

export default async function SellerDashboardPage() {
  const { sellerProfile } = await requireSeller();

  const products = await prisma.product.findMany({
    where: { sellerProfileId: sellerProfile.id },
    orderBy: { createdAt: "desc" },
    include: {
      _count: { select: { purchases: true, likes: true } },
      purchases: { select: { pricePaid: true } },
    },
  });

  const totalSales = products.reduce(
    (sum: number, p) => sum + p._count.purchases, 0
  );
  const totalLikes = products.reduce(
    (sum: number, p) => sum + p._count.likes, 0
  );
  const totalEarnings = products.reduce(
    (sum: number, p) =>
      sum + p.purchases.reduce((s: number, pur) => s + pur.pricePaid, 0),
    0
  );
  const feePercent = env.PLATFORM_FEE_PERCENT;
  const netEarnings = Math.round(totalEarnings * ((100 - feePercent) / 100));

  return (
    <div className="mx-auto max-w-7xl px-4 py-8">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold text-text-primary">Seller Dashboard</h1>
          <p className="mt-1 text-sm text-text-secondary">
            @{sellerProfile.username}
            {sellerProfile.headline && (
              <span className="ml-2">· {sellerProfile.headline}</span>
            )}
          </p>
          <ProfileEditor
            headline={sellerProfile.headline}
            bio={sellerProfile.bio}
          />
        </div>
        <Link href="/sell/products/new"
          className="inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-semibold text-white hover:bg-accent-hover transition-colors">
          <Plus className="h-4 w-4" /> New Product
        </Link>
      </div>

      {/* Stats */}
      <div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
        <div className="rounded-xl border border-border bg-surface p-5">
          <div className="flex items-center gap-3">
            <DollarSign className="h-5 w-5 text-success" />
            <span className="text-sm text-text-secondary">Net Earnings</span>
          </div>
          <p className="mt-2 text-2xl font-bold text-text-primary">
            {formatPrice(netEarnings)}
          </p>
        </div>
        <div className="rounded-xl border border-border bg-surface p-5">
          <div className="flex items-center gap-3">
            <ShoppingBag className="h-5 w-5 text-accent" />
            <span className="text-sm text-text-secondary">Total Sales</span>
          </div>
          <p className="mt-2 text-2xl font-bold text-text-primary">{totalSales}</p>
        </div>
        <div className="rounded-xl border border-border bg-surface p-5">
          <div className="flex items-center gap-3">
            <Package className="h-5 w-5 text-warning" />
            <span className="text-sm text-text-secondary">Products</span>
          </div>
          <p className="mt-2 text-2xl font-bold text-text-primary">{products.length}</p>
        </div>
        <div className="rounded-xl border border-border bg-surface p-5">
          <div className="flex items-center gap-3">
            <Heart className="h-5 w-5 text-accent" />
            <span className="text-sm text-text-secondary">Total Likes</span>
          </div>
          <p className="mt-2 text-2xl font-bold text-text-primary">{totalLikes}</p>
        </div>
      </div>

      {/* Product list */}
      <div className="mt-8">
        <h2 className="text-lg font-semibold text-text-primary">Your Products</h2>

        {products.length === 0 ? (
          <div className="mt-6 rounded-xl border border-dashed border-border p-12 text-center">
            <Package className="mx-auto h-12 w-12 text-text-secondary/30" />
            <p className="mt-4 text-text-secondary">
              No products yet. Create your first product to start selling.
            </p>
            <Link href="/sell/products/new"
              className="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-accent-hover transition-colors">
              <Plus className="h-4 w-4" /> Create Product
            </Link>
          </div>
        ) : (
          <div className="mt-4 space-y-3">
            {products.map((product) => {
              const revenue = product.purchases.reduce(
                (s: number, p) => s + p.pricePaid, 0
              );
              return (
                <div key={product.id}
                  className="flex items-center gap-4 rounded-xl border border-border bg-surface p-4">
                  <div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-surface-elevated">
                    {product.thumbnailUrl ? (
                      <img src={product.thumbnailUrl} alt="" className="h-full w-full object-cover" />
                    ) : (
                      <div className="flex h-full items-center justify-center">
                        <Package className="h-6 w-6 text-text-secondary/30" />
                      </div>
                    )}
                  </div>

                  <div className="flex-1 min-w-0">
                    <p className="truncate text-sm font-semibold text-text-primary">{product.title}</p>
                    <p className="text-xs text-text-secondary">
                      {formatPrice(product.price)} · {product._count.purchases} sales · {formatPrice(revenue)} revenue
                    </p>
                  </div>

                  <span className={`px-3 py-1 text-xs font-medium ${
                    product.status === "PUBLISHED"
                      ? "bg-success/10 text-success"
                      : "bg-warning/10 text-warning"
                  }`}>
                    {product.status}
                  </span>

                  <div className="flex gap-2">
                    <Link href={`/sell/products/${product.id}/edit`}
                      className="rounded-lg border border-border px-3 py-2.5 text-xs font-medium text-text-secondary hover:text-text-primary transition-colors">
                      Edit
                    </Link>
                    {product.status === "PUBLISHED" && (
                      <Link href={`/products/${product.slug}`}
                        className="rounded-lg border border-border px-3 py-2.5 text-xs font-medium text-text-secondary hover:text-text-primary transition-colors">
                        View
                      </Link>
                    )}
                  </div>
                </div>
              );
            })}
          </div>
        )}
      </div>
    </div>
  );
}

Payouts

In this project, we'll redirect sellers to Whop's payout portal to withdraw earnings. To send a seller to the payout portal, we'll generate a link using Whop's account links API. You could add a "Manage Payouts" button to the seller dashboard that runs this code and redirects:

Payouts
const accountLink = await getWhop().accountLinks.create({
  company_id: sellerProfile.whopCompanyId,
  use_case: "payouts_portal",
  return_url: `${process.env.NEXT_PUBLIC_APP_URL}/sell/dashboard`,
  refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/sell/dashboard?refresh=true`,
});

// Redirect seller to accountLink.url

Seller profile editing

Also in the dashboard, we'll let sellers edit their headline and bio, so, let's build the API route for it. Go to src/app/api/sell/profile and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";

const updateProfileSchema = z.object({
  headline: z.string().max(100).optional().nullable(),
  bio: z.string().max(2000).optional().nullable(),
});

export async function PATCH(request: NextRequest) {
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const profile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!profile) {
    return NextResponse.json({ error: "Not a seller" }, { status: 403 });
  }

  const body = await request.json();
  const parsed = updateProfileSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json({ error: "Validation failed" }, { status: 400 });
  }

  const updated = await prisma.sellerProfile.update({
    where: { id: profile.id },
    data: {
      headline: parsed.data.headline ?? null,
      bio: parsed.data.bio ?? null,
    },
  });

  return NextResponse.json(updated);
}

Buyer dashboard

Now, let's move on to the buyer dashboard. We want it to show all purchased products with download links. Go to src/app/dashboard and create a file called page.tsx with the content:

page.tsx
import Link from "next/link";
import { Download, Package, ShoppingBag } from "lucide-react";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { formatPrice } from "@/lib/utils";

export default async function BuyerDashboardPage() {
  const user = await requireAuth();

  const purchases = await prisma.purchase.findMany({
    where: { userId: user.id },
    orderBy: { createdAt: "desc" },
    include: {
      product: {
        include: {
          sellerProfile: { include: { user: true } },
          _count: { select: { files: true } },
        },
      },
    },
  });

  return (
    <div className="mx-auto max-w-7xl px-4 py-8">
      <div className="flex items-center gap-3">
        <ShoppingBag className="h-6 w-6 text-accent" />
        <h1 className="text-2xl font-bold text-text-primary">My Purchases</h1>
      </div>

      {purchases.length === 0 ? (
        <div className="mt-12 text-center">
          <Package className="mx-auto h-16 w-16 text-text-secondary/20" />
          <p className="mt-4 text-lg text-text-secondary">No purchases yet.</p>
          <Link href="/products"
            className="mt-4 inline-block rounded-lg bg-accent px-6 py-2.5 text-sm font-semibold text-white hover:bg-accent-hover transition-colors">
            Browse Products
          </Link>
        </div>
      ) : (
        <div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {purchases.map((purchase) => (
            <div key={purchase.id}
              className="rounded-xl border border-border bg-surface overflow-hidden">
              <div className="aspect-[4/3] bg-surface-elevated">
                {purchase.product.thumbnailUrl ? (
                  <img src={purchase.product.thumbnailUrl} alt=""
                    className="h-full w-full object-cover" />
                ) : (
                  <div className="flex h-full items-center justify-center">
                    <Package className="h-12 w-12 text-text-secondary/20" />
                  </div>
                )}
              </div>
              <div className="p-4">
                <h3 className="font-semibold text-text-primary">
                  {purchase.product.title}
                </h3>
                <p className="mt-1 text-xs text-text-secondary">
                  by @{purchase.product.sellerProfile.username} ·{" "}
                  {formatPrice(purchase.pricePaid)} · {purchase.product._count.files} files
                </p>
                <p className="mt-0.5 text-xs text-text-secondary">
                  Purchased{" "}
                  {purchase.createdAt.toLocaleDateString("en-US", {
                    month: "short", day: "numeric", year: "numeric",
                  })}
                </p>
                <Link href={`/products/${purchase.product.slug}/download`}
                  className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white hover:bg-accent-hover transition-colors">
                  <Download className="h-4 w-4" /> Download
                </Link>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Landing page

Our landing page needs a hero with search, trending products, categories, and a seller CTA. Go to src/app and create a file called page.tsx with the content:

page.tsx
import Link from "next/link";
import { ArrowRight, Store, CreditCard, TrendingUp, Package, Search } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { ProductCard } from "@/components/product-card";
import { CATEGORIES } from "@/constants/categories";

export default async function HomePage() {
  const trendingProducts = await prisma.product.findMany({
    where: { status: "PUBLISHED" },
    orderBy: { likes: { _count: "desc" } },
    take: 8,
    include: {
      sellerProfile: { include: { user: true } },
      ratings: { select: { cookies: true } },
      _count: { select: { likes: true, files: true, ratings: true } },
    },
  });

  return (
    <div>
      <section className="relative overflow-hidden bg-gradient-to-br from-accent/10 via-background to-background">
        <div className="mx-auto max-w-7xl px-4 py-24 text-center">
          <h1 className="text-5xl font-extrabold tracking-tight text-text-primary sm:text-6xl lg:text-7xl">
            Sell what you create
          </h1>
          <p className="mx-auto mt-6 max-w-2xl text-lg text-text-secondary">
            The marketplace for digital products - templates, ebooks, design
            assets, and more. Upload your files, set a price, and start earning.
          </p>
          <form
            action="/products"
            method="GET"
            className="mx-auto mt-10 flex max-w-lg items-center border border-border bg-surface"
          >
            <Search className="ml-4 h-4 w-4 text-text-secondary" aria-hidden="true" />
            <input
              type="search"
              name="q"
              placeholder="Search products..."
              className="flex-1 bg-transparent px-3 py-3 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
            />
            <button
              type="submit"
              className="bg-accent px-5 py-3 text-sm font-semibold text-white hover:bg-accent-hover transition-colors"
            >
              Search
            </button>
          </form>

          <div className="mt-6 flex items-center justify-center gap-4">
            <Link href="/products"
              className="text-sm font-medium text-text-secondary hover:text-text-primary transition-colors">
              Browse All
            </Link>
            <Link href="/sell"
              className="inline-flex items-center gap-2 text-sm font-medium text-text-secondary hover:text-text-primary transition-colors">
              Start Selling <ArrowRight className="h-4 w-4" />
            </Link>
          </div>
        </div>
      </section>

      <section className="mx-auto max-w-7xl px-4 py-16">
        <h2 className="text-2xl font-bold text-text-primary">Trending right now</h2>
        {trendingProducts.length > 0 ? (
          <div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
            {trendingProducts.map((product) => (
              <ProductCard
                key={product.id}
                product={{
                  ...product,
                  avgRating:
                    product._count.ratings > 0
                      ? product.ratings.reduce((s, r) => s + r.cookies, 0) / product._count.ratings
                      : 0,
                }}
              />
            ))}
          </div>
        ) : (
          <div className="mt-8 rounded-xl border border-dashed border-border p-12 text-center">
            <Package className="mx-auto h-12 w-12 text-text-secondary/20" />
            <p className="mt-4 text-text-secondary">
              No products yet. Be the first to{" "}
              <Link href="/sell" className="text-accent hover:underline">
                list something
              </Link>
              .
            </p>
          </div>
        )}
      </section>

      <section className="mx-auto max-w-7xl px-4 py-16">
        <h2 className="text-2xl font-bold text-text-primary">Browse by category</h2>
        <div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {CATEGORIES.map((cat) => (
            <Link key={cat.value} href={`/products?category=${cat.value}`}
              className="flex items-center gap-4 rounded-xl border border-border bg-surface p-5 transition-all hover:-translate-y-0.5 hover:shadow-md">
              <cat.icon className="h-8 w-8 text-accent" />
              <span className="text-base font-semibold text-text-primary">{cat.label}</span>
            </Link>
          ))}
        </div>
      </section>

      <section className="mx-auto max-w-7xl px-4 py-16">
        <div className="rounded-2xl bg-gradient-to-br from-accent/10 to-accent/5 p-12 text-center">
          <h2 className="text-3xl font-bold text-text-primary">Turn your skills into income</h2>
          <p className="mx-auto mt-4 max-w-lg text-text-secondary">
            Join creators selling digital products on Shelfie. We handle payments, payouts, and compliance - you keep 95% of every sale.
          </p>
          <div className="mt-8 flex items-center justify-center gap-8 text-sm text-text-secondary">
            <div className="flex items-center gap-2">
              <Store className="h-5 w-5 text-accent" /> Free to start
            </div>
            <div className="flex items-center gap-2">
              <CreditCard className="h-5 w-5 text-accent" /> 5% platform fee
            </div>
            <div className="flex items-center gap-2">
              <TrendingUp className="h-5 w-5 text-accent" /> Instant payouts
            </div>
          </div>
          <Link href="/sell"
            className="mt-8 inline-flex items-center gap-2 rounded-lg bg-accent px-8 py-3.5 text-sm font-semibold text-white hover:bg-accent-hover transition-colors">
            Start Selling <ArrowRight className="h-4 w-4" />
          </Link>
        </div>
      </section>
    </div>
  );
}

What's next?

Our project is now complete. We build a user authentication system using Whop OAuth, seller onboarding and KYC, product creation and file uploads via UploadThing, a product publishing flow with Whop checkout configurations, a marketplace with searches and categories, a whole payment system foundation via Whop Payments Network, ratings, dashboards, and more.

Here are a few ideas you can implement to further improve your project:

  • Subscription products - recurring membership options for sellers
  • Promo codes - discount codes sellers can create
  • Rich text editor - upgrading plain text descriptions with markdown text
  • Analytics dashboard - product views, conversion rates, trends, and other analytical data for sellers
  • Limited downloads - customizable download limits for buyers

Build your own platform with Whop

Just like this Gumroad clone project, there are many other platforms you can create with Whop. Whether you're looking to create a Substack clone or a chatbot SaaS, Whop's infrastructure can help you easily build your dream projects.

Grab the entire source code of this Gumroad clone project on GitHub and check out our developer documentation to learn more about what you can do with the Whop infrastructure.