Topic Details
https://whop.com/blog/rss/
Last item retrieved
<item><title><![CDATA[How to build a StockX clone with Next.js and Whop]]></title><description><![CDATA[Build a StockX clone where users can sell and bid on items using Whop Payments Network, connected accounts infrastructure, and Next.js.]]></description><link>https://whop.com/blog/build-stockx-clone/</link><guid isPermaLink="false">698f24ec3fc12d000144e4c7</guid><category><![CDATA[Tutorials]]></category><category><![CDATA[Engineering]]></category><dc:creator><![CDATA[East]]></dc:creator><pubDate>Thu, 26 Feb 2026 23:41:02 GMT</pubDate><media:content url="https://whop.com/blog/content/images/2026/02/StockXClone.webp" medium="image"/><content:encoded><![CDATA[
<!--kg-card-begin: html-->
<div class="ai-prompt-widget">
<div class="ai-prompt-widget__header">
<span class="ai-prompt-widget__icon">✨</span>
<span class="ai-prompt-widget__title">Build this with AI</span>
</div>
<img src="https://whop.com/blog/content/images/2026/02/StockXClone.webp" alt="How to build a StockX clone with Next.js and Whop"><p class="ai-prompt-widget__description">Open the tutorial prompt in your favorite AI coding tool:</p>
<div class="ai-prompt-widget__buttons" id="ai-prompt-buttons"></div>
</div>
<!--kg-card-end: html-->
<p>Building a real-time marketplace like StockX involves integrating multi-party payment systems into the project, configuring an external user authentication system, setting up WebSockets for live pricing, and creating custom KYC workflows for sellers.</p><p>Fortunately for you, Whop solves the most challenging parts of these steps with a single SDK: OAuth authentication, connected accounts, escrow payments, KYC, and embedded chats.</p><p>This tutorial will guide you through building a platform from scratch based on a live bid/ask system, which we've named "Swaphause," a StockX clone.<br>The project has three main parts:</p><ul><li><strong>Next.js app</strong> - handles the frontend, API routes, and the matching engine</li><li><strong>Supabase (PostgreSQL + Realtime)</strong> - stores all data and pushes live price updates to every connected client</li><li><strong>Whop infrastructure</strong> - handles user authentication, payment processing, seller payouts, and buyer-seller chat</li></ul><p>You can preview the finished <a href="https://stockx-clone-zeta.vercel.app/">product demo here</a> and find the full codebase in this <a href="https://github.com/whopio/whop-tutorials">GitHub repository</a>.</p><h2 id="project-overview">Project overview</h2><p>Before we start coding, here's what you'll be building.</p><div class="kg-card kg-toggle-card" data-kg-toggle-state="close">
<div class="kg-toggle-heading">
<h4 class="kg-toggle-heading-text"><span style="white-space: pre-wrap;">Pages</span></h4>
<button class="kg-toggle-card-icon" aria-label="Expand toggle to read content">
<svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
<path class="cls-1" d="M23.25,7.311,12.53,18.03a.749.749,0,0,1-1.06,0L.75,7.311"/>
</svg>
</button>
</div>
<div class="kg-toggle-content"><ul><li value="1"><code spellcheck="false" style="white-space: pre-wrap;"><span>/</span></code><span style="white-space: pre-wrap;"> - Homepage with trending products, live stats, and category browsing</span></li><li value="2"><code spellcheck="false" style="white-space: pre-wrap;"><span>/products</span></code><span style="white-space: pre-wrap;"> - Browse all products with search, category filters, and pagination</span></li><li value="3"><code spellcheck="false" style="white-space: pre-wrap;"><span>/products/[id]</span></code><span style="white-space: pre-wrap;"> - Product detail page with bid/ask forms, order book, price history, and size selector</span></li><li value="4"><code spellcheck="false" style="white-space: pre-wrap;"><span>/dashboard</span></code><span style="white-space: pre-wrap;"> - User dashboard showing active bids, asks, trade history, and portfolio value</span></li><li value="5"><code spellcheck="false" style="white-space: pre-wrap;"><span>/trades/[id]</span></code><span style="white-space: pre-wrap;"> - Trade detail page with status tracking, payment, and embedded buyer-seller chat</span></li></ul></div>
</div><div class="kg-card kg-toggle-card" data-kg-toggle-state="close">
<div class="kg-toggle-heading">
<h4 class="kg-toggle-heading-text"><span style="white-space: pre-wrap;">Core features</span></h4>
<button class="kg-toggle-card-icon" aria-label="Expand toggle to read content">
<svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
<path class="cls-1" d="M23.25,7.311,12.53,18.03a.749.749,0,0,1-1.06,0L.75,7.311"/>
</svg>
</button>
</div>
<div class="kg-toggle-content"><ul><li value="1"><b><strong style="white-space: pre-wrap;">Authentication</strong></b><span style="white-space: pre-wrap;"> - Whop OAuth (PKCE flow) for "Sign in with Whop" - no registration forms, no password resets</span></li><li value="2"><b><strong style="white-space: pre-wrap;">Bid/Ask matching engine</strong></b><span style="white-space: pre-wrap;"> - buyers place bids, sellers place asks. When a bid meets the lowest ask, the trade executes automatically</span></li><li value="3"><b><strong style="white-space: pre-wrap;">Real-time pricing</strong></b><span style="white-space: pre-wrap;"> - Supabase Realtime pushes every new bid and ask to all connected clients instantly. No WebSocket server to manage</span></li><li value="4"><b><strong style="white-space: pre-wrap;">Escrow payments</strong></b><span style="white-space: pre-wrap;"> - Whop Payments Network handles buyer charges, seller connected accounts, KYC, and platform fee splits</span></li><li value="5"><b><strong style="white-space: pre-wrap;">Seller onboarding</strong></b><span style="white-space: pre-wrap;"> - one-click connected account creation with built-in identity verification through Whop</span></li><li value="6"><b><strong style="white-space: pre-wrap;">Buyer-seller chat</strong></b><span style="white-space: pre-wrap;"> - embedded Whop chat component creates a private DM channel for each trade automatically on match</span></li><li value="7"><b><strong style="white-space: pre-wrap;">Webhooks</strong></b><span style="white-space: pre-wrap;"> - payment events from Whop sync trade status to the database in real time</span></li><li value="8"><b><strong style="white-space: pre-wrap;">Notifications</strong></b><span style="white-space: pre-wrap;"> - in-app notification feed for trade matches, payments, and status changes</span></li><li value="9"><b><strong style="white-space: pre-wrap;">Search and browse</strong></b><span style="white-space: pre-wrap;"> - full-text search, category filtering, and custom pagination</span></li><li value="10"><b><strong style="white-space: pre-wrap;">Product verification</strong></b><span style="white-space: pre-wrap;"> - admin review flow for authenticating items before releasing seller payouts</span></li></ul></div>
</div><h2 id="part-1-architecture">Part 1: Architecture</h2><figure class="kg-card kg-image-card"><img src="https://whop.com/blog/content/images/2026/02/Architecture.webp" class="kg-image" alt="How to build a StockX clone with Next.js and Whop" loading="lazy" width="1520" height="1043" srcset="https://whop.com/blog/content/images/size/w600/2026/02/Architecture.webp 600w, https://whop.com/blog/content/images/size/w1000/2026/02/Architecture.webp 1000w, https://whop.com/blog/content/images/2026/02/Architecture.webp 1520w" sizes="(min-width: 720px) 720px"></figure><p>In this guide, we'll be building a StockX clone where buyers and sellers trade products through a bid system. During trades, buyers name their price (bid), and sellers name their (ask). When a bid meets or exceeds the lowest ask, the trade is executed.</p><p>While building this project, we're going to use several services for things like user authentication, payments, testing, and validation. Whop's OAuth and Whop Payment Network are two of the biggest players we'll use, so let's understand why we're using Whop.</p><h3 id="why-whop">Why Whop</h3><p>In marketplace projects like this, you'll face two hard infrastructure problems: user authentication and money movement:</p><h4 id="payments">Payments</h4><p>A marketplace platform like this requires the developers to integrate multiple systems that handle connected accounts, KYC compliance, escrow holds, refund processing, and other complex payment flows.</p><p>As the number of external services increases, the time spent integrating each one (and stitching them together) grows exponentially.</p><p>Luckily, the Whop Payments Network handles all of this through a single API: connected seller accounts, escrow holds, payouts, and refunds, and more.</p><p>As a cherry on the cake, you'll use Whop for easy user authentication too, allowing you to integrate a fully functional user authentication system without storing passwords or managing 2Fa yourself.</p><h4 id="authentication">Authentication</h4><p>Whop OAuth gives you the "Sign in with Whop" button that uses a standard OAuth 2.1 + PKCE flow. Users authorize your app, you get an access token, and you have a verified identity without building registration forms, email verification, or password reset flows. All authentication needs are solved with a single integration.</p><p>Other flows like bid/ask matching, real-time price feeds, product pages, search, notifications, etc. will be custom code.</p><h3 id="how-money-moves">How money moves</h3><p>The payment flow of this project follows an escrow pattern:</p><ol><li>A bid matches an ask, the trade executes</li><li>The buyer is charged via Whop (direct charge on the seller's connected account with an application fee for the platform)</li><li>Funds are held, the seller hasn't been paid yet</li><li>The seller ships the item to the platform for authentication</li><li>The platform verifies the item is legitimate</li><li><strong>If verified</strong>: the seller's payout is released via their Whop connected account</li><li><strong>If failed</strong>: the buyer is refunded through Whop, the item is returned, and the listing can be reposted</li></ol><p>The platform takes a percentage on every successful transaction via the <code>application_fee_amount</code> on the checkout configuration. The seller receives the remainder. Whop handles the fee split, the KYC, and the payout rails.</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Layer</th>
<th>Choice</th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td>Framework</td>
<td>Next.js (App Router)</td>
<td>Server components, API routes, and deployment on Vercel in one package</td>
</tr>
<tr>
<td>Auth</td>
<td>Whop OAuth</td>
<td>Already in the Whop ecosystem, standard PKCE flow, no auth infrastructure to build</td>
</tr>
<tr>
<td>Payments</td>
<td>Whop Payments Network</td>
<td>Connected accounts + escrow + KYC</td>
</tr>
<tr>
<td>Database</td>
<td>Supabase (PostgreSQL)</td>
<td>Managed Postgres built-in Realtime</td>
</tr>
<tr>
<td>Real-time</td>
<td>Supabase Realtime</td>
<td>Subscribe to database changes. When a new bid lands, every connected client sees it instantly. No WebSocket server to manage</td>
</tr>
<tr>
<td>ORM</td>
<td>Prisma</td>
<td>Type-safe database access, migration management, schema-as-documentation</td>
</tr>
<tr>
<td>Validation</td>
<td>Zod</td>
<td>Runtime validation on API routes, env variables, and webhook payloads</td>
</tr>
<tr>
<td>Deployment</td>
<td>Vercel</td>
<td>Zero-config Next.js hosting, <code>vercel.ts</code> for typed configuration</td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<h3 id="scaffold-and-deploy">Scaffold and deploy</h3><p>In this guide, we're going to follow a deploy-first workflow that gets us a live URL before we start writing marketplace code. First, let's set up three services: Next.js on Vercel, a Supabase database, and a Whop app (on Whop's sandbox):</p><h3 id="create-the-nextjs-project-and-deploy-to-vercel">Create the Next.js project and deploy to Vercel</h3><p>To create the Next.js project, go to the directory you want to develop your project in and run the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">npx create-next-app@latest stockx-clone</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">You can replace the “<code spellcheck="false" style="white-space: pre-wrap;">stockx-clone</code>” part of the command with the project name you want. For the sake of simplicity, we’ll refer to the folder as “<code spellcheck="false" style="white-space: pre-wrap;">stockx-clone</code>” in this guide.</div></div><p>At some point, it will ask you "Would you like to use the recommended Next.js defaults?," and you should select the "Yes, use recommended defaults" option. This will install the required packages.<br>Then, let's push the project into GitHub by running the commands below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">cd stockx-clone
git init
git add .
git commit -m "Initial scaffold"
gh repo create stockx-clone --private --source=. --push</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>The <code>gh repo create</code> command creates the repo on GitHub, sets the remote, and pushes in one step. If you don't have the GitHub CLI, install it from cli.github.com and run <code>gh auth login</code> first.</p><p>Now, let's create a Vercel project:</p><ol><li>Go to <a href="https://vercel.com">vercel.com</a> and sign in with your GitHub account</li><li>Click <strong>Add New > Project</strong></li><li>Import your <code>stockx-clone</code> repository from the list</li><li>Leave the default settings (Vercel auto-detects Next.js) and click <strong>Deploy</strong></li><li>The first deploy will show the default Next.js page. That's fine, we'll add environment variables next</li></ol><h3 id="set-up-supabase">Set up Supabase</h3><p>Now, let's set up Supabase by creating an account and starting a new project. In Supabase, you'll create the project in an organization. If you don't have any, follow the steps below to create an organization:</p><ol><li>Go to the Supabase dashboard and click <strong>New organization</strong></li><li>Give your organization a name, select its type, and plan</li><li>Click <strong>Create organization</strong><br>This will redirect you to the <strong>Create a new project</strong> page, where you should give your project a name, set a <strong>strong</strong> database password, select the region you want the database to be located in, and click <strong>Create new project</strong>.</li></ol><p>Once you create the project, let's grab some values you'll use later. Click the <strong>Connect</strong> button at the top of your project dashboard - it shows your Project URL, API keys, and connection strings in one place. You can also find the API keys under <strong>Settings > API Keys</strong>:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Value</th>
<th>Where to find it</th>
<th>Env var name</th>
</tr>
</thead>
<tbody>
<tr>
<td>Project URL</td>
<td>Connect dialog or Settings > API Keys</td>
<td><code>NEXT_PUBLIC_SUPABASE_URL</code></td>
</tr>
<tr>
<td>Anon public key</td>
<td>Connect dialog or Settings > API Keys (labeled <code>anon</code>)</td>
<td><code>NEXT_PUBLIC_SUPABASE_ANON_KEY</code></td>
</tr>
<tr>
<td>Service role key</td>
<td>Settings > API Keys (labeled <code>service_role</code>)</td>
<td><code>SUPABASE_SERVICE_ROLE_KEY</code></td>
</tr>
<tr>
<td>Connection string</td>
<td>Connect dialog > Connection String (Session pooler)</td>
<td><code>DATABASE_URL</code></td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p>For the connection string, replace <code>[YOUR-PASSWORD]</code> with the database password you set during project creation. Use the <strong>Session pooler</strong> connection string - it works with both IPv4 and IPv6 and is the recommended default.</p><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text"><b><strong style="white-space: pre-wrap;">Note:</strong></b> Supabase is transitioning to new-style API keys (<code spellcheck="false" style="white-space: pre-wrap;">sb_publishable_...</code> and <code spellcheck="false" style="white-space: pre-wrap;">sb_secret_...</code>). The legacy JWT-based <code spellcheck="false" style="white-space: pre-wrap;">anon</code> and <code spellcheck="false" style="white-space: pre-wrap;">service_role</code> keys still work and are what we use in this project. You'll see both key types in your dashboard - use the legacy JWT keys.</div></div><h3 id="get-whop-sandbox-keys">Get Whop sandbox keys</h3><p>In the development phase, you're going to use Whop's sandbox environment - it allows you to simulate all flows using Whop without moving real money.</p><ol><li>Create a sandbox account at <a href="https://sandbox.whop.com">sandbox.whop.com</a> (this is separate from a regular Whop account)</li><li>Go to sandbox.whop.com/dashboard/developer and create a new app</li><li>In your app settings, set the <strong>OAuth Redirect URI</strong> to <code>http://localhost:3000/api/auth/callback</code></li><li>Grab these values from your app dashboard:</li></ol>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Value</th>
<th>Env var name</th>
</tr>
</thead>
<tbody>
<tr>
<td>API Key</td>
<td><code>WHOP_API_KEY</code></td>
</tr>
<tr>
<td>App ID</td>
<td><code>WHOP_APP_ID</code></td>
</tr>
<tr>
<td>Client ID</td>
<td><code>WHOP_CLIENT_ID</code></td>
</tr>
<tr>
<td>Client Secret</td>
<td><code>WHOP_CLIENT_SECRET</code></td>
</tr>
<tr>
<td>Webhook Secret</td>
<td><code>WHOP_WEBHOOK_SECRET</code></td>
</tr>
<tr>
<td>Company ID</td>
<td><code>WHOP_COMPANY_ID</code></td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text"><b><strong style="white-space: pre-wrap;">Important:</strong></b> <code spellcheck="false" style="white-space: pre-wrap;">WHOP_API_KEY</code> must be a <b><strong style="white-space: pre-wrap;">company API key</strong></b>, not an app API key. You'll find it under your company's <b><strong style="white-space: pre-wrap;">Settings > API Keys</strong></b> in the Whop dashboard. The company API key has broader permissions (like creating connected accounts for sellers) that the app API key doesn't. Your <code spellcheck="false" style="white-space: pre-wrap;">WHOP_COMPANY_ID</code> is the <code spellcheck="false" style="white-space: pre-wrap;">biz_...</code> value from the URL when you're on your company dashboard.</div></div><p>You'll also need one more <code>env</code> var that tells the app to use sandbox:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">.env</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">WHOP_API_BASE=https://sandbox-api.whop.com</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>This single variable controls whether the entire app talks to sandbox or production Whop. OAuth, SDK calls, and webhooks will use this variable. Set it to <code>https://sandbox-api.whop.com</code> for development and preview deployments. For production, you'll set it to <code>https://api.whop.com</code> later.</p><h3 id="configure-environment-variables">Configure environment variables</h3><p>Now, let's configure the environment variables of the project. First, go to your Vercel dashboard, open the project, and go to its settings.</p><p>There, open the <strong>Environment Variables</strong> page and add each variable from Steps 2 and 3. Vercel lets you set different values per deployment context (Production, Preview, Development), you can use this to separate sandbox from production:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Context</th>
<th><code>WHOP_API_BASE</code></th>
<th>Whop keys from</th>
</tr>
</thead>
<tbody>
<tr>
<td>Production</td>
<td><code>https://api.whop.com</code></td>
<td><code>whop.com/dashboard/developer</code></td>
</tr>
<tr>
<td>Preview</td>
<td><code>https://sandbox-api.whop.com</code></td>
<td><code>sandbox.whop.com/dashboard/developer</code></td>
</tr>
<tr>
<td>Development</td>
<td><code>https://sandbox-api.whop.com</code></td>
<td><code>sandbox.whop.com/dashboard/developer</code></td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p>For each context, add the matching <code>WHOP_API_KEY</code>, <code>WHOP_APP_ID</code>, <code>WHOP_CLIENT_ID</code>, <code>WHOP_CLIENT_SECRET</code>, <code>WHOP_WEBHOOK_SECRET</code>, and <code>WHOP_COMPANY_ID</code> from the corresponding Whop dashboard.</p><p>The Supabase variables (<code>DATABASE_URL</code>, <code>NEXT_PUBLIC_SUPABASE_URL</code>, etc.) are the same across all contexts unless you want a separate test database.</p><p>Next, add two app-level variables:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Env var</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>NEXT_PUBLIC_APP_URL</code></td>
<td><code>https://your-project.vercel.app</code> (your Vercel deployment URL)</td>
</tr>
<tr>
<td><code>SESSION_SECRET</code></td>
<td>A random string, at least 32 characters</td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p>Generate a session secret with:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<h4 id="pulling-variables-locally">Pulling variables locally</h4><p>Once everything is set up in Vercel, pull the variables to your local development environment. This requires the Vercel CLI, and you can install it using these commands:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">npm i -g vercel
vercel login
vercel link</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Then pull the variables:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">vercel env pull .env.local</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>This creates a <code>.env.local</code> file with all your Development-context variables. The file is gitignored and never committed.</p><h3 id="deployment-configuration">Deployment configuration</h3><p>You'll use <code>vercel.ts</code> instead of <code>vercel.json</code> for deployment configuration - you get type safety and IDE autocomplete.</p><p>Create <code>vercel.ts</code> in your project root (next to <code>package.json</code>, not inside <code>src/</code>) with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">vercel.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">interface VercelConfig {
framework?: string;
buildCommand?: string;
outputDirectory?: string;
headers?: Array<{
source: string;
headers: Array<{ key: string; value: string }>;
}>;
}
const config: VercelConfig = {
framework: "nextjs",
buildCommand: "next build",
outputDirectory: ".next",
headers: [
{
source: "/api/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
],
},
],
};
export default config;
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">This sets the framework, adds security headers to all API routes, and gives you a place to add caching<br>rules later.</div></div><h4 id="nextjs-image-configuration">Next.js image configuration</h4><p>If your app displays remote images (like placeholder images from placehold.co or user avatars), Next.js needs to know which domains are allowed. Create <code>next.config.ts</code> in the project root (next to <code>package.json</code>) with:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">next.config.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
dangerouslyAllowSVG: true,
remotePatterns: [
{
protocol: "https",
hostname: "placehold.co",
},
],
},
};
export default nextConfig;</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p><code>dangerouslyAllowSVG</code> is needed because placehold returns SVG images, which Next.js image optimization rejects by default. Add any other image domains your app uses to <code>remotePatterns</code>.</p><h3 id="environment-variable-validation">Environment variable validation</h3><p>Lastly before deploying the project, let's validate all required environment variables exist at startup.</p><p>This will crash your app at startup when you're missing environment variables, so that you don't run into issues later down the tutorial and spend time finding the cause.</p><p>Install Zod (we'll use it throughout the project for all input validation, not just env variables):</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">npm install zod</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Next, create the <code>src/lib/</code> directories and a file in it called <code>env.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">env.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { z } from "zod";
const envSchema = z.object({
WHOP_API_KEY: z.string().trim().min(1),
WHOP_APP_ID: z.string().trim().min(1),
WHOP_CLIENT_ID: z.string().trim().min(1),
WHOP_CLIENT_SECRET: z.string().trim().min(1),
WHOP_WEBHOOK_SECRET: z.string().trim().min(1),
WHOP_COMPANY_ID: z.string().trim().min(1),
WHOP_API_BASE: z.string().trim().url().default("https://api.whop.com"),
DATABASE_URL: z.string().trim().url(),
NEXT_PUBLIC_SUPABASE_URL: z.string().trim().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().trim().min(1),
NEXT_PUBLIC_APP_URL: z.string().trim().url(),
SESSION_SECRET: z.string().trim().min(32),
SUPABASE_SERVICE_ROLE_KEY: z.string().trim().min(1),
PLATFORM_FEE_PERCENT: z.coerce.number().default(9.5),
});
export type Env = z.infer<typeof envSchema>;
let _env: Env | undefined;
export const env: Env = new Proxy({} as Env, {
get(_, prop: string) {
if (!_env) {
_env = envSchema.parse(process.env);
}
return _env[prop as keyof Env];
},
});</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>If you see a <code>ZodError</code> listing missing fields when running <code>npm run dev</code>, your <code>.env.local</code> is incomplete, go back to Step 4.</p><h3 id="deploy-the-project-to-vercel">Deploy the project to Vercel</h3><p>If you set up via the Vercel dashboard, your project is already deploying on every push to <code>main</code>. Just commit and push using the commands below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">git add .
git commit -m "Add environment config"
git push</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Now, when you open your Vercel deployment URL, you should see the default Next.js page. Check the build logs in the Vercel dashboard and see if any environment variables are missing, the build will fail with a <code>ZodError</code> listing exactly which ones. Fix those in <strong>Settings > Environment Variables</strong> and redeploy.</p><p>Once the page loads, you have a live URL connected to a real Supabase database with all environment variables in place. You're ready to write marketplace code.</p><h2 id="part-2-data-models-and-authentication">Part 2: Data models and authentication</h2><p>You have deployed the Next.js app and wired your environment variables. Now, you need two things before starting to work on the marketplace logic: a database schema that models how StockX actually works, and a secure way for users to sign in.</p><h3 id="designing-the-data-model">Designing the data model</h3><figure class="kg-card kg-image-card"><img src="https://whop.com/blog/content/images/2026/02/TradeOverview.webp" class="kg-image" alt="How to build a StockX clone with Next.js and Whop" loading="lazy" width="2000" height="1398" srcset="https://whop.com/blog/content/images/size/w600/2026/02/TradeOverview.webp 600w, https://whop.com/blog/content/images/size/w1000/2026/02/TradeOverview.webp 1000w, https://whop.com/blog/content/images/size/w1600/2026/02/TradeOverview.webp 1600w, https://whop.com/blog/content/images/2026/02/TradeOverview.webp 2157w" sizes="(min-width: 720px) 720px"></figure><p>The core logic behind StockX's data model is that every product has one canonical page, and every size of that product is its own market. There's no "create a listing" flow where five sellers each make their own page for the same sneaker.</p><p>Instead, there's one page for the Nike Dunk Low Panda, and within that page, each size (US 9, US 10, US 11) has its own bid/ask order book with its own price history.<br>This means we need a <code>Product</code> with multiple <code>ProductSize</code> records.</p><p>Bids and asks attach to a specific <code>ProductSize</code>, not to the product itself. When a bid matches an ask on the same size, we create a <code>Trade</code>, a single record that tracks the entire lifecycle from match to delivery (or refund).</p><p>A few non-obvious decisions:</p><ul><li><strong>Bids and asks are separate tables.</strong> You could model them as one <code>Order</code> table with a <code>side</code> column, but separate tables make the matching queries cleaner and let us add size-specific constraints to each side independently.</li><li><strong>Trades reference both the bid and the ask.</strong> This creates a clear audit trail, you can always trace a completed sale back to the exact bid and ask that created it.</li><li><strong>Payments are a separate table from trades.</strong> A trade can have multiple payment events (charge, refund, payout release), so we keep them in their own table linked by <code>tradeId</code>.</li><li><strong>ProductSize caches aggregate stats.</strong> Rather than computing the lowest ask and highest bid on every page load, we store <code>lowestAsk</code>, <code>highestBid</code>, <code>lastSalePrice</code>, and <code>salesCount</code> directly on the <code>ProductSize</code> record and update them when the order book changes. This keeps product listing queries fast.</li><li><strong>Notifications are stored in the database.</strong> This is a custom in-app notification feed - no external service. Each notification ties to a user and stores structured metadata as JSON.</li></ul><p>First, install Prisma and the Prisma Client:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">npm install @prisma/client
npm install -D prisma</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Initialize Prisma in your project. This creates the <code>prisma/</code> directory with a <code>schema.prisma</code> file:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">npx prisma init</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Now, let's create the Prisma schema by going into the <code>/prisma</code> folder in the project and updating the <code>schema.prisma</code> contents with:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">schema.prisma</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
// Enums
enum UserRole {
USER
SELLER
ADMIN
}
enum BidStatus {
ACTIVE
MATCHED
CANCELLED
EXPIRED
}
enum AskStatus {
ACTIVE
MATCHED
CANCELLED
EXPIRED
}
enum TradeStatus {
MATCHED
PAYMENT_PENDING
PAID
SHIPPED
AUTHENTICATING
VERIFIED
DELIVERED
FAILED
REFUNDED
}
enum PaymentStatus {
PENDING
SUCCEEDED
FAILED
REFUNDED
}
enum NotificationType {
BID_MATCHED
ASK_MATCHED
TRADE_COMPLETED
ITEM_SHIPPED
ITEM_VERIFIED
ITEM_FAILED
PRICE_ALERT
SYSTEM
}
// Models
model User {
id String @id @default(cuid())
whopId String @unique
email String
username String
displayName String?
avatarUrl String?
role UserRole @default(USER)
whopAccessToken String?
whopRefreshToken String?
connectedAccountId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bids Bid[]
asks Ask[]
buyerTrades Trade[] @relation("BuyerTrades")
sellerTrades Trade[] @relation("SellerTrades")
notifications Notification[]
}
model Product {
id String @id @default(cuid())
name String
brand String
sku String @unique
description String
images String[]
category String
retailPrice Float
releaseDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sizes ProductSize[]
}
model ProductSize {
id String @id @default(cuid())
productId String
size String
lastSalePrice Float?
lowestAsk Float?
highestBid Float?
salesCount Int @default(0)
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
bids Bid[]
asks Ask[]
trades Trade[]
@@unique([productId, size])
}
model Bid {
id String @id @default(cuid())
userId String
productSizeId String
price Float
status BidStatus @default(ACTIVE)
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
productSize ProductSize @relation(fields: [productSizeId], references: [id], onDelete: Cascade)
trade Trade?
@@index([productSizeId, status])
}
model Ask {
id String @id @default(cuid())
userId String
productSizeId String
price Float
status AskStatus @default(ACTIVE)
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
productSize ProductSize @relation(fields: [productSizeId], references: [id], onDelete: Cascade)
trade Trade?
@@index([productSizeId, status])
}
model Trade {
id String @id @default(cuid())
buyerId String
sellerId String
productSizeId String
bidId String @unique
askId String @unique
price Float
platformFee Float
chatChannelId String?
status TradeStatus @default(MATCHED)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
buyer User @relation("BuyerTrades", fields: [buyerId], references: [id], onDelete: Cascade)
seller User @relation("SellerTrades", fields: [sellerId], references: [id], onDelete: Cascade)
productSize ProductSize @relation(fields: [productSizeId], references: [id], onDelete: Cascade)
bid Bid @relation(fields: [bidId], references: [id], onDelete: Cascade)
ask Ask @relation(fields: [askId], references: [id], onDelete: Cascade)
payment Payment?
@@index([buyerId])
@@index([sellerId])
}
model Payment {
id String @id @default(cuid())
tradeId String @unique
whopPaymentId String @unique
amount Float
platformFee Float
status PaymentStatus @default(PENDING)
idempotencyKey String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
trade Trade @relation(fields: [tradeId], references: [id], onDelete: Cascade)
}
model Notification {
id String @id @default(cuid())
userId String
type NotificationType
title String
message String
read Boolean @default(false)
metadata Json?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, read])
}</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>After updating the Prisma schema, push it to your Supabase database. But first, Prisma CLI reads <code>DATABASE_URL</code> from <code>.env</code>, not <code>.env.local</code>. Since Vercel <code>env</code> pull wrote everything to <code>.env.local</code>, you need a separate <code>.env</code> file for Prisma:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">echo 'DATABASE_URL="your-supabase-connection-string"' > .env</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Copy the <code>DATABASE_URL</code> value from your <code>.env.local</code> file. This <code>.env</code> is already covered by <code>.gitignore</code>. To prevent it from being uploaded during Vercel deploy, create a <code>.vercelignore</code> file in the project root with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">.vercelignore</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">.env
.env.local</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Now push the schema using the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">npx prisma db push</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<h3 id="enable-realtime">Enable Realtime</h3><p>Before moving on, let's go to Supabase and enable Realtime for the <code>Bid</code> and <code>Ask</code> tables. In Supabase, in the Database page of your project, go to Replication, and toggle Realtime on for both tables.</p><p>This tells Supabase to broadcast row changes (inserts, updates, deletes) over its Realtime channels. We'll subscribe to these changes on the frontend in Part 3. Without this, the client-side subscriptions we build later will silently receive nothing.</p><h3 id="whop-oauth">Whop OAuth</h3><p>Users sign in with their Whop account via OAuth 2.1. Before writing code, create an app in the Whop developer dashboard.</p><p>For development, use the <strong>sandbox dashboard</strong> at <code>sandbox.whop.com/dashboard/developer</code>, this gives you test credentials that won't process real payments. For production, use the regular <code>whop.com</code> dashboard. You'll need:</p><ul><li>A <strong>Client ID</strong> and <strong>Client Secret</strong> (these should already be in your Vercel env vars from Part 1 - sandbox keys for dev, production keys for prod)</li><li>A <strong>Redirect URI</strong> set to <code>https://your-domain.com/api/auth/callback</code> (or <code>http://localhost:3000/api/auth/callback</code> for local development)<br>Notice that the OAuth routes below use <code>env.WHOP_API_BASE</code> instead of hardcoding <code>https://api.whop.com</code>. This env var switches the entire app between sandbox (<code>https://sandbox-api.whop.com</code>) and production (<code>https://api.whop.com</code>), allowing you to easily switch between Whop environments without having to edit all routes individually.</li></ul><p>The flow works in two steps: a login route that redirects the user to Whop, and a callback route that exchanges the authorization code for tokens and creates the session.</p><p>Before writing the routes, you need a Prisma client singleton and the session management dependency. Install <code>iron-session</code> using the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">npm install iron-session</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Now, let's create the Prisma client singleton. This file reuses one PrismaClient instance across hot reloads in development. It also appends <code>connection_limit=1</code> to the database URL.</p><p>Without this, each Vercel serverless function opens its own connection pool, which quickly exhausts Supabase's session pooler limit and causes 500 errors. Go to <code>src/lib</code> and create a file called <code>prisma.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">prisma.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function buildDatasourceUrl(): string {
const url = process.env.DATABASE_URL!;
if (url.includes("connection_limit")) return url;
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}connection_limit=1`;
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
datasources: { db: { url: buildDatasourceUrl() } },
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Time to create the auth routes. In Next.js App Router, each API route lives in its own folder with a <code>route.ts</code> file. Let's go to the <code>src/app/api/auth</code> folder and create two folders called <code>login</code> and <code>callback</code>.</p><p>Then, open the <code>src/app/api/auth/login</code> folder and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">route.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from "next/server";
import { env } from "@/lib/env";
function base64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export async function GET() {
const codeVerifierBytes = new Uint8Array(32);
crypto.getRandomValues(codeVerifierBytes);
const codeVerifier = base64url(codeVerifierBytes.buffer);
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(codeVerifier)
);
const codeChallenge = base64url(digest);
const stateBytes = new Uint8Array(16);
crypto.getRandomValues(stateBytes);
const state = Array.from(stateBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const nonceBytes = new Uint8Array(16);
crypto.getRandomValues(nonceBytes);
const nonce = Array.from(nonceBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const redirectUri = `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`;
const authUrl = new URL(`${env.WHOP_API_BASE}/oauth/authorize`);
authUrl.searchParams.set("client_id", env.WHOP_CLIENT_ID);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("scope", "openid profile email");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("nonce", nonce);
const cookieValue = JSON.stringify({ codeVerifier, state });
const response = NextResponse.redirect(authUrl.toString());
response.cookies.set("oauth_pkce", cookieValue, {
httpOnly: true,
secure: env.NEXT_PUBLIC_APP_URL.startsWith("https"),
sameSite: "lax",
path: "/",
maxAge: 600, // 10 minutes
});
return response;
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>When the user authorizes a Whop login, they get redirected them back to your callback route with an authorization code.</p><p>Now, you need a callback route that handles the redirect from Whop by exchanging the authorization code for tokens, fetching the user's profile, and creating or updating their account in the database.</p><p>Note that it imports <code>sessionOptions</code> from <code>@/lib/auth</code>, which we'll create next.<br>To create the callback route, go to <code>src/app/api/auth/callback</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">route.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from "next/server";
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import { type SessionData, sessionOptions } from "@/lib/auth";
import { env } from "@/lib/env";
interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
}
interface UserInfoResponse {
sub: string;
email?: string;
email_verified?: boolean;
preferred_username?: string;
name?: string;
picture?: string;
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code || !state) {
return NextResponse.redirect(
`${env.NEXT_PUBLIC_APP_URL}/?error=missing_params`
);
}
const pkceCookie = request.cookies.get("oauth_pkce");
if (!pkceCookie?.value) {
return NextResponse.redirect(
`${env.NEXT_PUBLIC_APP_URL}/?error=missing_pkce`
);
}
let storedState: string;
let codeVerifier: string;
try {
const parsed = JSON.parse(pkceCookie.value) as {
state: string;
codeVerifier: string;
};
storedState = parsed.state;
codeVerifier = parsed.codeVerifier;
} catch {
return NextResponse.redirect(
`${env.NEXT_PUBLIC_APP_URL}/?error=invalid_pkce`
);
}
if (state !== storedState) {
return NextResponse.redirect(
`${env.NEXT_PUBLIC_APP_URL}/?error=state_mismatch`
);
}
const redirectUri = `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`;
const tokenRes = await fetch(`${env.WHOP_API_BASE}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: env.WHOP_CLIENT_ID,
client_secret: env.WHOP_CLIENT_SECRET,
code_verifier: codeVerifier,
}),
});
if (!tokenRes.ok) {
const errBody = await tokenRes.text().catch(() => "no body");
console.error("Token exchange failed:", tokenRes.status, errBody);
return NextResponse.redirect(
`${env.NEXT_PUBLIC_APP_URL}/?error=token_exchange_failed&status=${tokenRes.status}&detail=${encodeURIComponent(errBody.slice(0, 200))}`
);
}
const tokenData = (await tokenRes.json()) as TokenResponse;
const userInfoRes = await fetch(`${env.WHOP_API_BASE}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
if (!userInfoRes.ok) {
const errBody = await userInfoRes.text().catch(() => "no body");
console.error("Userinfo failed:", userInfoRes.status, errBody);
return NextResponse.redirect(
`${env.NEXT_PUBLIC_APP_URL}/?error=userinfo_failed&status=${userInfoRes.status}&detail=${encodeURIComponent(errBody.slice(0, 200))}`
);
}
const userInfo = (await userInfoRes.json()) as UserInfoResponse;
const user = await prisma.user.upsert({
where: { whopId: userInfo.sub },
update: {
email: userInfo.email ?? undefined,
username: userInfo.preferred_username ?? undefined,
displayName: userInfo.name ?? undefined,
avatarUrl: userInfo.picture ?? undefined,
whopAccessToken: tokenData.access_token,
whopRefreshToken: tokenData.refresh_token,
},
create: {
whopId: userInfo.sub,
email: userInfo.email ?? "",
username: userInfo.preferred_username ?? userInfo.sub,
displayName: userInfo.name,
avatarUrl: userInfo.picture,
whopAccessToken: tokenData.access_token,
whopRefreshToken: tokenData.refresh_token,
},
});
const cookieStore = await cookies();
const session = await getIronSession<SessionData>(
cookieStore,
sessionOptions
);
session.userId = user.id;
session.whopId = user.whopId;
session.accessToken = tokenData.access_token;
await session.save();
const response = NextResponse.redirect(env.NEXT_PUBLIC_APP_URL);
response.cookies.delete("oauth_pkce");
return response;
}</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<h3 id="session-management">Session Management</h3><p>We use <code>iron-session</code> for stateless and encrypted sessions stored in an <code>httpOnly</code> cookie named <code>stockx_session</code>. The session holds the user's internal <code>userId</code>, their <code>whopId</code>, and their access token for making API calls on their behalf.</p><p>Access tokens expire after one hour and when a token refresh is needed, we use the stored refresh token from the database to get a new pair.</p><p>You imported <code>sessionOptions</code> in the callback route, so let's actually create the auth file. Go <code>src/lib</code> and create a file called <code>auth.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">auth.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { getIronSession, type SessionOptions } from "iron-session";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import type { User } from "@prisma/client";
export interface SessionData {
userId: string;
whopId: string;
accessToken: string;
}
export const sessionOptions: SessionOptions = {
cookieName: "stockx_session",
password: process.env.SESSION_SECRET!,
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
maxAge: 60 * 60 * 24 * 7, // 7 days
},
};
export async function getSession() {
const cookieStore = await cookies();
return getIronSession<SessionData>(cookieStore, sessionOptions);
}
export async function getCurrentUser(): Promise<User | null> {
const session = await getSession();
if (!session.userId) {
return null;
}
const user = await prisma.user.findUnique({
where: { id: session.userId },
});
return user;
}
export async function requireAuth(): Promise<User> {
const user = await getCurrentUser();
if (!user) {
throw new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
return user;
}</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Every API route and server component that needs the current user calls <code>getCurrentUser()</code>. Routes that require authentication use <code>requireAuth()</code>, which throws a 401 if no session exists. You use a single file and a single place to add token refresh logic later, and one place to check if your code breaks.</p><h2 id="part-3-bidask-engine-and-real-time-pricing">Part 3: Bid/ask engine and real-time pricing</h2><p>So far, you've deployed the Next.js app to Vercel, set up a Supabase database, and built the Whop OAuth so users can sign in. Now, it's time to build one of the main parts of your project, the bid/ask engine.</p><p>Every product in the project has its own order book (open bids with buy orders and open asks with sell orders). When a bid meets or exceeds the lowest ask, it executes at the bid price.</p><h3 id="api-routes-for-bids-and-asks">API routes for bids and asks</h3><p>The bid and ask API routes follow the same pattern. Let's break them down:<br>POST <code>/api/bids</code> - Place a new bid:</p><ul><li>Authenticate the request via <code>requireAuth()</code></li><li>Validate the request body with a Zod schema: <code>productSizeId</code> (string), <code>price</code> (positive number), <code>expiresAt</code> (ISO date string, optional, must be in the future)</li><li>Confirm the <code>ProductSize</code> exists</li><li>Create the <code>Bid</code> record with status <code>ACTIVE</code></li><li>Run the matching engine - if a match is found, the engine creates the trade within a transaction</li><li>Return the created bid (and the trade, if matched)</li><li>Touches: <code>Bid</code>, <code>Ask</code>, <code>Trade</code>, <code>ProductSize</code><br><strong>POST <code>/api/asks</code></strong> - Place a new ask:</li><li>Same pattern as bids, but for the sell side</li><li>Touches: <code>Ask</code>, <code>Bid</code>, <code>Trade</code>, <code>ProductSize</code><br>Both routes require a valid session (no anonymous bids), validate prices with Zod against a min/max range defined in <code>/src/constants/index.ts</code>, and apply a simple in-memory rate limiter for every user ID to prevent spams. Bids and asks can include an expires, optionally.</li></ul><p>At timestamp, a cron job marks expired entries in intervals, but the matching engine also checks expiration at match time so an expired bid never matches even if the cron hasn't run yet.</p><h4 id="checkout-redirect-on-match">Checkout redirect on match</h4><p>When the bid API returns <code>{ matched: true, trade: { id } }</code>, the <code>BidForm</code> component doesn't just clear the form - it immediately redirects the buyer to Whop checkout.</p><p>The component calls <code>POST /api/trades/{tradeId}/checkout</code> to get a <code>checkoutUrl</code>, then navigates to it with <code>window.location.href</code>. This applies to both "Place Bid" (when the bid price meets or exceeds an existing ask) and "Buy Now" (which places a bid at the lowest ask price, guaranteeing an immediate match). The checkout flow is covered in part four.</p><h3 id="shared-constants">Shared Constants</h3><p>Before building the matching engine, let's create a shared constants file. It will define the platform fee, price limits, product categories, and pagination defaults used across the entire project.</p><p>First, go to <code>src/constants</code> (create the folder if you don't have it) and create a file in it called <code>index.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">index.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">export const PLATFORM_FEE_PERCENT = 9.5;
export const MIN_BID_PRICE = 1;
export const MAX_BID_PRICE = 100_000;
export const BID_EXPIRY_DAYS = 30;
export const CATEGORIES = [
"Sneakers",
"Streetwear",
"Electronics",
"Collectibles",
"Accessories",
"Trading Cards",
] as const;
export type Category = (typeof CATEGORIES)[number];
export const ORDER_STATUSES: Record<string, string> = {
MATCHED: "Matched",
PAYMENT_PENDING: "Payment Pending",
PAID: "Paid",
SHIPPED: "Shipped",
AUTHENTICATING: "Authenticating",
VERIFIED: "Verified",
DELIVERED: "Delivered",
FAILED: "Authentication Failed",
REFUNDED: "Refunded",
};
export const ITEMS_PER_PAGE = 24;</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>The <code>PLATFORM_FEE_PERCENT</code> at 9.5% is the platform's cut of every trade made in the app. You can change this rate to your liking. The matching engine, checkout configuration, and trade records all reference this constant.</p><h3 id="building-the-matching-engine">Building the matching engine</h3><p>The matching engine is the core of the marketplace project you're working on. When a new bid or ask is created by users, the matching engine checks if it can be immediately matched across the order book.</p><p>If it can, it will create a <code>Trade</code> and update both the bid and the ask statuses, calculate the platform fee, update the cached stats on <code>ProductSize</code>, and sends notifications to both users.</p><p>The engine has a double-checking pattern. It first searches <strong>outside</strong> the transaction to find a potential match, then re-fetches both records inside the transaction to confirm they're still <code>ACTIVE</code> before proceeding. This prevents conditions where two concurrent requests try to match against the same bid or ask.</p><p>Now, let's go to the <code>src/lib</code> folder and create a file called <code>matching-engine.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">matching-engine.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { BidStatus, AskStatus, TradeStatus, NotificationType } from "@prisma/client";
import type { Prisma, Trade } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { PLATFORM_FEE_PERCENT } from "@/constants";
import { createDmChannel, sendSystemMessage } from "@/services/chat";
type TransactionClient = Prisma.TransactionClient;
export async function matchBid(bidId: string) {
const bid = await prisma.bid.findUnique({
where: { id: bidId },
});
if (!bid || bid.status !== BidStatus.ACTIVE) {
return null;
}
const lowestAsk = await prisma.ask.findFirst({
where: {
productSizeId: bid.productSizeId,
status: AskStatus.ACTIVE,
price: { lte: bid.price },
},
orderBy: { price: "asc" },
});
if (!lowestAsk) {
return null;
}
const trade = await prisma.$transaction(async (tx: TransactionClient) => {
const freshBid = await tx.bid.findUnique({ where: { id: bidId } });
const freshAsk = await tx.ask.findUnique({ where: { id: lowestAsk.id } });
if (
!freshBid ||
freshBid.status !== BidStatus.ACTIVE ||
!freshAsk ||
freshAsk.status !== AskStatus.ACTIVE
) {
return null;
}
const tradePrice = freshAsk.price;
const platformFee = Number(
(tradePrice * (PLATFORM_FEE_PERCENT / 100)).toFixed(2)
);
await tx.bid.update({
where: { id: freshBid.id },
data: { status: BidStatus.MATCHED },
});
await tx.ask.update({
where: { id: freshAsk.id },
data: { status: AskStatus.MATCHED },
});
const newTrade = await tx.trade.create({
data: {
buyerId: freshBid.userId,
sellerId: freshAsk.userId,
productSizeId: freshBid.productSizeId,
bidId: freshBid.id,
askId: freshAsk.id,
price: tradePrice,
platformFee,
status: TradeStatus.MATCHED,
},
});
await updateProductSizeStats(freshBid.productSizeId, tx);
await tx.notification.createMany({
data: [
{
userId: freshBid.userId,
type: NotificationType.BID_MATCHED,
title: "Bid matched!",
message: `Your bid of $${freshBid.price.toFixed(2)} was matched at $${tradePrice.toFixed(2)}.`,
metadata: { tradeId: newTrade.id },
},
{
userId: freshAsk.userId,
type: NotificationType.ASK_MATCHED,
title: "Ask matched!",
message: `Your ask of $${freshAsk.price.toFixed(2)} was matched. Prepare to ship your item.`,
metadata: { tradeId: newTrade.id },
},
],
});
return newTrade;
});
if (trade) {
await setupTradeChat(trade);
}
return trade;
}
export async function matchAsk(askId: string) {
const ask = await prisma.ask.findUnique({
where: { id: askId },
});
if (!ask || ask.status !== AskStatus.ACTIVE) {
return null;
}
const highestBid = await prisma.bid.findFirst({
where: {
productSizeId: ask.productSizeId,
status: BidStatus.ACTIVE,
price: { gte: ask.price },
},
orderBy: { price: "desc" },
});
if (!highestBid) {
return null;
}
const trade = await prisma.$transaction(async (tx: TransactionClient) => {
const freshAsk = await tx.ask.findUnique({ where: { id: askId } });
const freshBid = await tx.bid.findUnique({ where: { id: highestBid.id } });
if (
!freshAsk ||
freshAsk.status !== AskStatus.ACTIVE ||
!freshBid ||
freshBid.status !== BidStatus.ACTIVE
) {
return null;
}
const tradePrice = freshAsk.price;
const platformFee = Number(
(tradePrice * (PLATFORM_FEE_PERCENT / 100)).toFixed(2)
);
await tx.bid.update({
where: { id: freshBid.id },
data: { status: BidStatus.MATCHED },
});
await tx.ask.update({
where: { id: freshAsk.id },
data: { status: AskStatus.MATCHED },
});
const newTrade = await tx.trade.create({
data: {
buyerId: freshBid.userId,
sellerId: freshAsk.userId,
productSizeId: freshAsk.productSizeId,
bidId: freshBid.id,
askId: freshAsk.id,
price: tradePrice,
platformFee,
status: TradeStatus.MATCHED,
},
});
await updateProductSizeStats(freshAsk.productSizeId, tx);
await tx.notification.createMany({
data: [
{
userId: freshBid.userId,
type: NotificationType.BID_MATCHED,
title: "Bid matched!",
message: `Your bid of $${freshBid.price.toFixed(2)} was matched at $${tradePrice.toFixed(2)}.`,
metadata: { tradeId: newTrade.id },
},
{
userId: freshAsk.userId,
type: NotificationType.ASK_MATCHED,
title: "Ask matched!",
message: `Your ask of $${freshAsk.price.toFixed(2)} was matched. Prepare to ship your item.`,
metadata: { tradeId: newTrade.id },
},
],
});
return newTrade;
});
if (trade) {
await setupTradeChat(trade);
}
return trade;
}
async function setupTradeChat(trade: Trade) {
try {
const [buyer, seller, productSize] = await Promise.all([
prisma.user.findUnique({
where: { id: trade.buyerId },
select: { whopId: true },
}),
prisma.user.findUnique({
where: { id: trade.sellerId },
select: { whopId: true },
}),
prisma.productSize.findUnique({
where: { id: trade.productSizeId },
include: { product: { select: { name: true } } },
}),
]);
if (!buyer?.whopId || !seller?.whopId || !productSize) return;
if (buyer.whopId === seller.whopId) {
console.log("setupTradeChat: skipping DM for self-match trade");
return;
}
const channelName = `Trade: ${productSize.product.name} Size ${productSize.size}`;
const channelId = await createDmChannel(
buyer.whopId,
seller.whopId,
channelName
);
await prisma.trade.update({
where: { id: trade.id },
data: { chatChannelId: channelId },
});
await sendSystemMessage(
channelId,
`Trade matched at $${trade.price.toFixed(2)}! Use this chat to coordinate shipping details.`
);
} catch (error: unknown) {
console.error("Failed to set up trade chat:", error);
}
}
async function updateProductSizeStats(
productSizeId: string,
tx: TransactionClient
) {
const lowestActiveAsk = await tx.ask.findFirst({
where: { productSizeId, status: AskStatus.ACTIVE },
orderBy: { price: "asc" },
select: { price: true },
});
const highestActiveBid = await tx.bid.findFirst({
where: { productSizeId, status: BidStatus.ACTIVE },
orderBy: { price: "desc" },
select: { price: true },
});
const lastTrade = await tx.trade.findFirst({
where: { productSizeId, status: TradeStatus.DELIVERED },
orderBy: { createdAt: "desc" },
select: { price: true },
});
await tx.productSize.update({
where: { id: productSizeId },
data: {
lowestAsk: lowestActiveAsk?.price ?? null,
highestBid: highestActiveBid?.price ?? null,
lastSalePrice: lastTrade?.price ?? undefined,
},
});
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<h3 id="real-time-order-book-updates">Real-time order book updates</h3><p>In Part 2 you enabled Realtime on the <code>Bid</code> and <code>Ask</code> tables on Supabase. Now, when the matching engine inserts a new bid or updates an ask's status to <code>MATCHED</code>, Supabase sends that change to every client that's subscribed to that table.</p><p>The frontend listens for any changes to bids or asks on selected sizes of items. New bid, matched ask, cancellations, and other actions update the order book in real-time.<br>To wire this up, you need two things: a Supabase client that runs in the browser, and a React hook that subsribes the changes for selected item sizes.<br>First, let's install the Supabase client library using the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">npm install @supabase/supabase-js
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Then, go to <code>src/services</code> (create the folder if you don't have it) and create a file called <code>supabase.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">supabase.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { createClient, type SupabaseClient } from "@supabase/supabase-js";
let browserClient: SupabaseClient | null = null;
export function createBrowserClient(): SupabaseClient {
if (browserClient) return browserClient;
browserClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
return browserClient;
}
export function createServerClient(): SupabaseClient {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>The browser client is a single instance that's reused since it runs in the browser with the anonymous key. The server client creates a new instance each time since it uses the service role key for access.</p><h3 id="the-userealtimebids-hook">The <code>useRealtimeBids</code> hook</h3><p>Now, let's create the hook that ties the Realtime subscription to React. Go to <code>src/hooks</code> and create a file called <code>useRealtimeBids.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">useRealtimeBids.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">"use client";
import { useEffect, useState, useCallback } from "react";
import { createBrowserClient } from "@/services/supabase";
interface Bid {
id: string;
userId: string;
productSizeId: string;
price: number;
status: string;
expiresAt: string | null;
createdAt: string;
}
interface Ask {
id: string;
userId: string;
productSizeId: string;
price: number;
status: string;
expiresAt: string | null;
createdAt: string;
}
interface UseRealtimeBidsReturn {
bids: Bid[];
asks: Ask[];
isLoading: boolean;
error: string | null;
}
export function useRealtimeBids(productSizeId: string): UseRealtimeBidsReturn {
const [bids, setBids] = useState<Bid[]>([]);
const [asks, setAsks] = useState<Ask[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchBids = useCallback(async () => {
try {
const res = await fetch(
`/api/bids?productSizeId=${encodeURIComponent(productSizeId)}`
);
if (!res.ok) throw new Error("Failed to fetch bids");
const data = await res.json();
setBids(
(data.bids ?? data ?? []).sort(
(a: Bid, b: Bid) => b.price - a.price
)
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch bids");
}
}, [productSizeId]);
const fetchAsks = useCallback(async () => {
try {
const res = await fetch(
`/api/asks?productSizeId=${encodeURIComponent(productSizeId)}`
);
if (!res.ok) throw new Error("Failed to fetch asks");
const data = await res.json();
setAsks(
(data.asks ?? data ?? []).sort(
(a: Ask, b: Ask) => a.price - b.price
)
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch asks");
}
}, [productSizeId]);
useEffect(() => {
setIsLoading(true);
setError(null);
Promise.all([fetchBids(), fetchAsks()]).finally(() => setIsLoading(false));
const supabase = createBrowserClient();
const channel = supabase
.channel(`orderbook-${productSizeId}`)
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "Bid",
filter: `productSizeId=eq.${productSizeId}`,
},
() => {
fetchBids();
}
)
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "Ask",
filter: `productSizeId=eq.${productSizeId}`,
},
() => {
fetchAsks();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [productSizeId, fetchBids, fetchAsks]);
return { bids, asks, isLoading, error };
}</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>The hook loads all active bids and asks for the selected size when the element first renders in the user's end. After that, it listens to changes like new bids, matching asks, or order cancellations.</p><p>Bids are sorted highest-first, asks lowest-first. When the user navigates away, the connection is cleaned up automatically.</p><p>At this point you should have:</p><ul><li>Constants file at <code>/src/constants/index.ts</code> with platform fee, price boundaries, categories, and pagination defaults</li><li>Bid and ask API routes with Zod validation, auth checks, and rate limiting</li><li>Matching engine in <code>/src/lib/matching-engine.ts</code> that atomically pairs bids with asks</li><li>Double-check pattern preventing race conditions on concurrent matches</li><li>Platform fee calculated and stored on every trade</li><li>ProductSize stats updated automatically after each match</li><li>Notifications created within the matching transaction</li><li>Buy Now and Sell Now working through the same matching logic</li><li>BidForm auto-redirects to Whop checkout when a bid matches immediately (calls <code>POST /api/trades/{id}/checkout</code> and navigates to the <code>checkoutUrl</code>)</li><li>Supabase client at <code>/src/services/supabase.ts</code> with browser singleton and server factory</li><li>Supabase Realtime broadcasting bid/ask table changes</li><li><code>useRealtimeBids</code> hook at <code>/src/hooks/useRealtimeBids.ts</code> providing live order book data to the frontend</li><li>A product page where the bid/ask spread updates in real time</li></ul><h2 id="part-4-payments-escrow-and-webhooks">Part 4: Payments, escrow, and webhooks</h2><figure class="kg-card kg-image-card"><img src="https://whop.com/blog/content/images/2026/02/TradeSteps.webp" class="kg-image" alt="How to build a StockX clone with Next.js and Whop" loading="lazy" width="1792" height="1262" srcset="https://whop.com/blog/content/images/size/w600/2026/02/TradeSteps.webp 600w, https://whop.com/blog/content/images/size/w1000/2026/02/TradeSteps.webp 1000w, https://whop.com/blog/content/images/size/w1600/2026/02/TradeSteps.webp 1600w, https://whop.com/blog/content/images/2026/02/TradeSteps.webp 1792w" sizes="(min-width: 720px) 720px"></figure><p>We talked about how the money flows in the project in part one, now, let's build it. By the end of this section, you'll have:</p><ul><li>Buyer charges</li><li>Seller payouts</li><li>Escrow hold pattern</li><li>Webhook handlers</li></ul><p>All of these flows will use Whop Payments Network.</p><h3 id="why-use-whop-payments-network">Why use Whop Payments Network?</h3><p>A marketplace project like this with multiple sellers needs connected accounts. Each seller gets their own identity with the payments service, and their payouts. Your app sits in the middle, takes a platform cut, and orchestrates the flow.</p><p>Using Whop Payments Network for payments services keeps everything in one ecosystem, plus you use it for user authentication as well.</p><p>When it comes to charges, you're going to use direct charges - the charge is created on seller's account, making it the merchant of record. The platform specifies an <code>application_fee_amount</code> that gets routes to your project automatically.</p><h3 id="seller-onboarding">Seller onboarding</h3><p>Before your sellers can start receiving payouts, they need a connected account with a completed KYC and a payout method file. The onboarding flow you'll use in the project follows these steps:</p><ol><li>The seller signs up on your platform via Whop OAuth</li><li>When they navigate to "Start selling," the platform creates a connected account for them via the Whop API</li><li>The seller is redirected to Whop-hosted onboarding where they verify their identity, provide business information, and add a bank account or other payout method</li><li>Whop sends a callback/webhook when onboarding completes</li><li>The platform stores the seller's connected account status and <code>company_id</code><br>Requirements for the seller onboarding flow you're going to build are:<ol><li>Track connected account status per user (<code>PENDING</code>, <code>ACTIVE</code>, <code>SUSPENDED</code>)</li><li>Store the seller's Whop <code>company_id</code> (you'll need this for every charge)</li><li>Handle the KYC completion callback and update the seller's status</li><li>Gate all selling actions (creating asks) behind <code>ACTIVE</code> connected account status</li><li>Show onboarding progress clearly to the seller</li></ol></li></ol><p>The seller onboarding API route (<code>POST /api/sellers/onboard</code>) creates a child company under your parent company via <code>whopsdk.companies.create()</code>, passing <code>env.WHOP_COMPANY_ID</code> as the <code>parent_company_id</code>. This env var is the <code>biz_...</code> value from your company dashboard URL.</p><p>The <code>WHOP_API_KEY</code> must be a <strong>company API key</strong> (found in company Settings > API Keys), not an app API key - only company keys have the <code>company:create_child</code> permission needed for creating connected accounts.</p><h4 id="gating-the-sell-ui">Gating the sell UI</h4><p>To enforce the onboarding requirement in the frontend, create a <code>useCurrentUser</code> hook at <code>src/hooks/useCurrentUser.ts</code> that fetches the current user from <code>/api/auth/me</code> (which already returns <code>role</code> and <code>connectedAccountId</code>):</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">useCurrentUser.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">"use client";
import { useState, useEffect } from "react";
interface CurrentUser {
id: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
role: string;
connectedAccountId: string | null;
}
export function useCurrentUser() {
const [user, setUser] = useState<CurrentUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch("/api/auth/me")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.user) setUser(data.user);
})
.catch(() => {})
.finally(() => setIsLoading(false));
}, []);
return { user, isLoading };
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Use this hook in two places:</p><ol><li><strong><code>AskForm</code> component</strong>, before rendering the ask form, check <code>user.connectedAccountId</code>. If the user isn't logged in, show a "Sign in to sell" prompt. If they're logged in but haven't onboarded, show a "Become a Seller" button that calls <code>POST /api/sellers/onboard</code> and redirects to the Whop KYC page.</li><li><strong>Dashboard Selling tab</strong>, same check. If the user hasn't completed seller onboarding, show an onboarding prompt instead of the active asks table.<br>This way, the onboarding flow is surfaced everywhere a user tries to sell - they're never left wondering why they can't place an ask.</li></ol><p>The payment orchestration lives in two service files. First, let's go to <code>src/services</code> and create a file called <code>whop.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">whop.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { whopsdk } from "@/lib/whop";
import { env } from "@/lib/env";
interface TradeForCheckout {
id: string;
price: number;
platformFee: number;
buyerId: string;
sellerId: string;
seller: {
whopId: string;
connectedAccountId?: string | null;
};
}
interface CheckoutResult {
checkoutUrl: string;
checkoutId: string;
}
export async function createCheckoutForTrade(
trade: TradeForCheckout
): Promise<CheckoutResult> {
if (!trade.seller.connectedAccountId) {
throw new Error("Seller does not have a connected Whop account");
}
const checkoutConfig = await whopsdk.checkoutConfigurations.create({
redirect_url: `${env.NEXT_PUBLIC_APP_URL}/api/trades/${trade.id}/payment-callback`,
plan: {
company_id: trade.seller.connectedAccountId,
currency: "usd",
initial_price: trade.price,
plan_type: "one_time",
application_fee_amount: trade.platformFee,
},
metadata: {
tradeId: trade.id,
buyerId: trade.buyerId,
sellerId: trade.sellerId,
},
});
if (!checkoutConfig || !checkoutConfig.id) {
throw new Error("Failed to create checkout session");
}
return {
checkoutUrl: checkoutConfig.purchase_url as string,
checkoutId: checkoutConfig.id,
};
}
export async function getPaymentStatus(paymentId: string) {
const payment = await whopsdk.payments.retrieve(paymentId);
return payment;
}
export async function refundPayment(paymentId: string) {
const refund = await whopsdk.payments.refund(paymentId);
return refund;
}
export async function createTransfer(
amount: number,
originCompanyId: string,
destinationCompanyId: string,
metadata: Record<string, string>
) {
const transfer = await whopsdk.transfers.create({
amount,
currency: "usd",
origin_id: originCompanyId,
destination_id: destinationCompanyId,
metadata,
});
return transfer;
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Now create the payment orchestration service that uses these wrappers. Go to <code>src/services</code> and create a file called <code>payments.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">payments.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { TradeStatus, PaymentStatus, NotificationType } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { createCheckoutForTrade, refundPayment } from "@/services/whop";
export async function initiatePayment(tradeId: string) {
const trade = await prisma.trade.findUnique({
where: { id: tradeId },
include: {
seller: true,
productSize: { include: { product: true } },
},
});
if (!trade) {
throw new Error("Trade not found");
}
if (trade.status !== TradeStatus.MATCHED) {
throw new Error(`Trade is in ${trade.status} state, expected MATCHED`);
}
const checkout = await createCheckoutForTrade({
id: trade.id,
price: trade.price,
platformFee: trade.platformFee,
buyerId: trade.buyerId,
sellerId: trade.sellerId,
seller: {
whopId: trade.seller.whopId,
connectedAccountId: trade.seller.connectedAccountId,
},
});
await prisma.trade.update({
where: { id: trade.id },
data: { status: TradeStatus.PAYMENT_PENDING },
});
return checkout;
}
export async function processRefund(tradeId: string) {
const trade = await prisma.trade.findUnique({
where: { id: tradeId },
include: { payment: true, ask: true, productSize: { include: { product: true } } },
});
if (!trade || !trade.payment) {
throw new Error("Trade or payment not found");
}
if (trade.status !== TradeStatus.FAILED) {
throw new Error(`Trade is in ${trade.status} state, expected FAILED`);
}
await refundPayment(trade.payment.whopPaymentId);
await prisma.$transaction(async (tx) => {
await tx.payment.update({
where: { id: trade.payment!.id },
data: { status: PaymentStatus.REFUNDED },
});
await tx.trade.update({
where: { id: trade.id },
data: { status: TradeStatus.REFUNDED },
});
if (trade.ask) {
await tx.ask.update({
where: { id: trade.ask.id },
data: { status: "ACTIVE" },
});
}
await tx.notification.createMany({
data: [
{
userId: trade.buyerId,
type: NotificationType.ITEM_FAILED,
title: "Refund processed",
message: `Your payment of $${trade.price.toFixed(2)} for ${trade.productSize.product.name} has been refunded.`,
metadata: { tradeId: trade.id },
},
{
userId: trade.sellerId,
type: NotificationType.ITEM_FAILED,
title: "Item relisted",
message: `Your ask for ${trade.productSize.product.name} has been relisted after authentication failure.`,
metadata: { tradeId: trade.id },
},
],
});
});
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>The <code>initiatePayment</code> function creates the Whop checkout on the seller's connected account and transitions the trade to <code>PAYMENT_PENDING</code>. <code>processRefund</code> handles the reverse - refunding the buyer, reopening the seller's ask, and notifying both parties.<br>For managing these flows, you're going to need a dashboard, and we're going to cover that in part six.</p><h3 id="payment-on-match">Payment on match</h3><p>When your matching engine matches a bid and an ask, it creates a trade and the payment flow gets activated - following the steps below:</p><ol><li>The matching engine creates a <code>Trade</code> record with status <code>MATCHED</code></li><li>The platform creates a checkout configuration on the seller's connected account using <code>client.checkoutConfigurations.create()</code>, specifying the trade amount and the platform's <code>application_fee_amount</code></li><li>Whop generates a checkout link</li><li>The buyer is directed to complete payment through the Whop-hosted checkout</li><li>On successful payment, Whop fires a <code>payment.succeeded</code> webhook</li></ol><p>Notice the <code>redirect_url</code> parameter in our checkout configuration - this tells Whop where to send the buyer after they complete (or cancel) payment.</p><p>Whop appends query parameters to this URL including <code>payment_id</code> and <code>checkout_status</code>, which our callback route uses to verify the payment server-side. Without <code>redirect_url</code>, the buyer ends up on a generic Whop page instead of back in your app.</p><p>The <code>BidForm</code> component handles this automatically - when a bid matches immediately (either through "Place Bid" at a matching price or "Buy Now"), the component detects <code>{ matched: true, trade: { id } }</code> in the API response, calls <code>POST /api/trades/{tradeId}/checkout</code> to get the checkout URL, and redirects the buyer to Whop's payment page.</p><p>Buyers can also reach checkout from the trade detail page (we'll cover that later down the article) if they navigate away before paying.</p><p>In this tutorial, we're redirecting the buyers to a Whop hosted checkout page, but if you'd prefer to keep the buyer on your site, Whop also offers an <a href="https://docs.whop.com/payments/checkout-embed">embedded checkout</a> component.</p><h3 id="the-escrow-pattern">The escrow pattern</h3><p>One thing we should clear up is that payments <strong>does not mean</strong> payout. When a buyer pays, the funds are held instead of directly being transferred to sellers.</p><p>This is an escrow pattern and it protects buyers from receiving misrepresented or wrong items.</p><p>The trade moves through these statuses after payment:</p><ol><li><strong>PAID</strong> - Buyer has been charged. Funds held via Whop</li><li><strong>SHIPPED</strong> - Seller has shipped the item to the platform for authentication. Seller provides tracking number</li><li><strong>AUTHENTICATING</strong> - Item received by the platform. Admin review in progress</li><li><strong>VERIFIED</strong> - Item passes authentication. Payout released to seller<br>Each status transition is an event. Each event triggers notifications (which we'll look at in part six) and potentially a financial action. The webhook handler and admin actions taken from your platform moderators drive these transitions.</li></ol><h3 id="payout-release">Payout release</h3><p>When an admin of your platform marks an item as verified, the platform releases the payout to the seller.</p><p>Since you're using direct charges, the funds are already linked to the seller's connected account. The payout goes to whatever method the seller selected during KYC.</p><h3 id="refund-on-authentication-failure">Refund on Authentication Failure</h3><p>If the item fails authentication, the project follows the a reverse flow:</p><ol><li>Admin marks the item as <code>FAILED</code></li><li>Trade status moves to <code>FAILED</code></li><li>Buyer is refunded via Whop (full refund of the original charge)</li><li>Item is returned to the seller</li><li>The seller's original ask can be reposted</li><li>Both buyer and seller receive notifications explaining the outcome<br>The refund is processed through Whop's API against the original charge. Because we stored the <code>whopPaymentId</code> on the trade's Payment record, we have the reference we need.</li></ol><h3 id="webhook-handler">Webhook handler</h3><p>After the buyer completes the payment, Whop sends an POST message to your webhook endpoint with the result. Your webhook handler then verifies the signature, updates the database, and notifies both parties in-app.</p><p>Before creating the handler file, you need the Whop SDK wrapper. It uses lazy initialization (same Proxy pattern as <code>env.ts</code>) so it doesn't crash during builds.<br>To install the Whop SDK, run the command below in your terminal:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">npm install @whop/sdk
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Then, go to <code>src/lib</code> and create a file called <code>whop.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">whop.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { Whop } from "@whop/sdk";
import { env } from "@/lib/env";
let _whopsdk: Whop | undefined;
export function getWhopSDK(): Whop {
if (!_whopsdk) {
_whopsdk = new Whop({
appID: env.WHOP_APP_ID,
apiKey: env.WHOP_API_KEY,
webhookKey: btoa(env.WHOP_WEBHOOK_SECRET),
baseURL: `${env.WHOP_API_BASE}/api/v1`,
});
}
return _whopsdk;
}
export const whopsdk = new Proxy({} as Whop, {
get(_, prop) {
const sdk = getWhopSDK();
const value = sdk[prop as keyof Whop];
if (typeof value === "function") {
return value.bind(sdk);
}
return value;
},
});
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>The <code>baseURL</code> is built from <code>WHOP_API_BASE</code>. The same env var that controls sandbox vs production for OAuth (which we've covered in part two).<br>In development, this points to <code>https://sandbox-api.whop.com/api/v1</code> so that you can simulate Whop flows without moving real money. In production, you'll change it to <code>https://api.whop.com/api/v1</code>.</p><p>The webhook handler uses <code>waitUntil</code> from <code>@vercel/functions</code> to process events asynchronously after returning 200. You can install it using the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">npm install @vercel/functions
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Now, let's go to <code>src/app/api/webhooks/whop</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">route.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { NextRequest } from "next/server";
import {
PaymentStatus,
TradeStatus,
BidStatus,
AskStatus,
NotificationType,
} from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { whopsdk } from "@/lib/whop";
import { prisma } from "@/lib/prisma";
import { sendSystemMessage } from "@/services/chat";
export async function POST(request: NextRequest) {
try {
const bodyText = await request.text();
const headers = Object.fromEntries(request.headers);
let webhookData: { type: string; data: Record<string, unknown> };
try {
webhookData = (await whopsdk.webhooks.unwrap(bodyText, {
headers,
})) as unknown as {
type: string;
data: Record<string, unknown>;
};
} catch {
return new Response("Invalid webhook signature", { status: 401 });
}
waitUntil(processWebhook(webhookData));
return new Response("OK", { status: 200 });
} catch (error: unknown) {
console.error("Webhook handler error:", error);
return new Response("OK", { status: 200 });
}
}
async function processWebhook(webhookData: {
type: string;
data: Record<string, unknown>;
}) {
try {
const paymentId = webhookData.data.id as string | undefined;
if (!paymentId) return;
// Idempotency check - skip if already processed
const existingPayment = await prisma.payment.findFirst({
where: { whopPaymentId: paymentId },
});
if (existingPayment) return;
const tradeId = webhookData.data.metadata
? ((webhookData.data.metadata as Record<string, unknown>).tradeId as string | undefined)
: undefined;
switch (webhookData.type) {
case "payment.succeeded": {
if (!tradeId) return;
const trade = await prisma.trade.findUnique({
where: { id: tradeId },
});
if (!trade) return;
await prisma.$transaction(async (tx) => {
await tx.payment.create({
data: {
tradeId: trade.id,
whopPaymentId: paymentId,
amount: trade.price,
platformFee: trade.platformFee,
status: PaymentStatus.SUCCEEDED,
idempotencyKey: `payment_succeeded_${paymentId}`,
},
});
await tx.trade.update({
where: { id: trade.id },
data: { status: TradeStatus.PAID },
});
await tx.notification.createMany({
data: [
{
userId: trade.buyerId,
type: NotificationType.TRADE_COMPLETED,
title: "Payment confirmed",
message: `Your payment of $${trade.price.toFixed(2)} has been confirmed.`,
metadata: { tradeId: trade.id },
},
{
userId: trade.sellerId,
type: NotificationType.ITEM_SHIPPED,
title: "New sale - ship your item",
message: `A buyer has paid $${trade.price.toFixed(2)}. Please ship your item for authentication.`,
metadata: { tradeId: trade.id },
},
],
});
});
if (trade.chatChannelId) {
await sendSystemMessage(
trade.chatChannelId,
"Payment confirmed! Seller, please ship your item for authentication."
);
}
break;
}
case "payment.failed": {
if (!tradeId) return;
const trade = await prisma.trade.findUnique({
where: { id: tradeId },
include: { bid: true, ask: true },
});
if (!trade) return;
await prisma.$transaction(async (tx) => {
await tx.payment.create({
data: {
tradeId: trade.id,
whopPaymentId: paymentId,
amount: trade.price,
platformFee: trade.platformFee,
status: PaymentStatus.FAILED,
idempotencyKey: `payment_failed_${paymentId}`,
},
});
await tx.trade.update({
where: { id: trade.id },
data: { status: TradeStatus.FAILED },
});
if (trade.bid) {
await tx.bid.update({
where: { id: trade.bid.id },
data: { status: BidStatus.ACTIVE },
});
}
if (trade.ask) {
await tx.ask.update({
where: { id: trade.ask.id },
data: { status: AskStatus.ACTIVE },
});
}
await tx.notification.create({
data: {
userId: trade.buyerId,
type: NotificationType.ITEM_FAILED,
title: "Payment failed",
message: "Your payment could not be processed. Your bid has been reopened.",
metadata: { tradeId: trade.id },
},
});
});
if (trade.chatChannelId) {
await sendSystemMessage(
trade.chatChannelId,
"Payment failed. The bid and ask have been reopened."
);
}
break;
}
}
} catch (error: unknown) {
console.error("Webhook processing error:", error);
}
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Key patterns in this handler:</p><ul><li><strong>Signature verification first</strong> - <code>whopsdk.webhooks.unwrap()</code> verifies the request came from Whop. If it fails, returns 401 and stops</li><li><strong>Return 200 immediately</strong> - Whop retries webhooks that don't get a 200. We return right away and use <code>waitUntil</code> from <code>@vercel/functions</code> to process in the background</li><li><strong>Idempotency</strong> - Webhooks can arrive more than once. Before processing, we check if a Payment with this <code>whopPaymentId</code> already exists. If so, skip. The unique constraint on <code>whopPaymentId</code> in the database protects against race conditions too</li><li><strong>Single transaction</strong> - Payment record, trade status update, and notifications all happen inside one Prisma <code>$transaction</code>. Everything succeeds or nothing does</li><li><strong>Bid/ask reopening on failure</strong> - A failed payment kills the trade but reopens the original bid and ask so the matching engine can find new counterparties</li></ul><h3 id="payment-verification-callback">Payment verification callback</h3><p>Webhooks are the primary mechanism for learning about payment outcomes, but they're not the only one.</p><p>After the buyer completes payment on Whop's checkout page, Whop redirects them back to the <code>redirect_url</code> we set on the checkout configuration, with <code>payment_id</code> and <code>checkout_status</code> as query parameters. We use this redirect to verify the payment server-side as a fallback.</p><p>Create the callback route by going to the <code>src/app/api/trades/[id]/payment-callback</code> folder and creating a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">route.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from "next/server";
import {
PaymentStatus,
TradeStatus,
NotificationType,
} from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { env } from "@/lib/env";
import { getPaymentStatus } from "@/services/whop";
import { sendSystemMessage } from "@/services/chat";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: tradeId } = await params;
const paymentId = request.nextUrl.searchParams.get("payment_id");
const checkoutStatus = request.nextUrl.searchParams.get("checkout_status");
const dashboardUrl = `${env.NEXT_PUBLIC_APP_URL}/dashboard`;
if (!tradeId || !paymentId) {
return NextResponse.redirect(`${dashboardUrl}?payment=error`);
}
try {
const trade = await prisma.trade.findUnique({
where: { id: tradeId },
});
if (!trade) {
return NextResponse.redirect(`${dashboardUrl}?payment=error`);
}
if (trade.status === TradeStatus.PAID) {
return NextResponse.redirect(
`${dashboardUrl}?payment=success&tradeId=${tradeId}`
);
}
if (checkoutStatus !== "success") {
return NextResponse.redirect(
`${dashboardUrl}?payment=failed&tradeId=${tradeId}`
);
}
const payment = await getPaymentStatus(paymentId);
const whopPayment = payment as {
status?: string;
substatus?: string;
};
const isPaid =
whopPayment.status === "paid" ||
whopPayment.substatus === "succeeded";
if (isPaid) {
const existingPayment = await prisma.payment.findFirst({
where: { whopPaymentId: paymentId },
});
if (!existingPayment) {
await prisma.$transaction(async (tx) => {
await tx.payment.create({
data: {
tradeId: trade.id,
whopPaymentId: paymentId,
amount: trade.price,
platformFee: trade.platformFee,
status: PaymentStatus.SUCCEEDED,
idempotencyKey: `payment_callback_${paymentId}`,
},
});
await tx.trade.update({
where: { id: trade.id },
data: { status: TradeStatus.PAID },
});
await tx.notification.createMany({
data: [
{
userId: trade.buyerId,
type: NotificationType.TRADE_COMPLETED,
title: "Payment confirmed",
message: `Your payment of $${trade.price.toFixed(2)} has been confirmed.`,
metadata: { tradeId: trade.id },
},
{
userId: trade.sellerId,
type: NotificationType.ITEM_SHIPPED,
title: "New sale - ship your item",
message: `A buyer has paid $${trade.price.toFixed(2)}. Please ship your item for authentication.`,
metadata: { tradeId: trade.id },
},
],
});
});
}
if (trade.chatChannelId) {
await sendSystemMessage(
trade.chatChannelId,
"Payment confirmed! Seller, please ship your item for authentication."
);
}
return NextResponse.redirect(
`${dashboardUrl}?payment=success&tradeId=${tradeId}`
);
}
return NextResponse.redirect(
`${dashboardUrl}?payment=pending&tradeId=${tradeId}`
);
} catch (error: unknown) {
console.error("Payment callback error:", error);
return NextResponse.redirect(
`${dashboardUrl}?payment=error&tradeId=${tradeId}`
);
}
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>This route does the same work as the webhook handler - create a Payment record, update the trade to PAID, notify both parties - but it's triggered by the buyer's browser redirect instead of a server-to-server webhook.</p><p>The idempotency check ensures that if both the webhook and the callback fire, only the first one creates the Payment record. In production, webhooks usually arrive first; in sandbox, this callback is the primary path.</p><h3 id="trade-details-page">Trade details page</h3><p>Every trade needs a detail page where the buyer can see the status, product info, parties involved, and - if the trade is waiting for payment - a "Pay Now" button. This page is also the destination when a user clicks a notification.</p><p>First, create a GET endpoint that returns a single trade with all its related data. Go to <code>src/app/api/trades/[id]</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">route.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await requireAuth();
const { id } = await params;
const trade = await prisma.trade.findUnique({
where: { id },
include: {
productSize: {
include: {
product: {
select: {
id: true,
name: true,
brand: true,
images: true,
sku: true,
},
},
},
},
buyer: { select: { id: true, username: true, displayName: true } },
seller: { select: { id: true, username: true, displayName: true } },
payment: true,
},
});
if (!trade) {
return NextResponse.json(
{ error: "Trade not found" },
{ status: 404 }
);
}
if (trade.buyerId !== user.id && trade.sellerId !== user.id) {
return NextResponse.json(
{ error: "Not authorized to view this trade" },
{ status: 403 }
);
}
return NextResponse.json({ trade });
} catch (error: unknown) {
if (error instanceof Response) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
console.error("Failed to fetch trade:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>The endpoint is auth-gated - only the buyer or seller of the trade can view it.<br>Then create the trade detail page at <code>src/app/trades/[id]/page.tsx</code>. This is a client component that:</p><ul><li>Fetches the trade from <code>GET /api/trades/{id}</code> on mount</li><li>Displays the product image, name, brand, size, and SKU</li><li>Shows a status badge using the <code>ORDER_STATUSES</code> map from <code>/src/constants/index.ts</code></li><li>For trades in <code>MATCHED</code> status (awaiting payment), shows a prominent "Pay Now" button that calls <code>POST /api/trades/{id}/checkout</code> and redirects to Whop checkout</li><li>Displays trade details in a grid: price, platform fee, the user's role (buyer/seller), and trade date</li><li>Shows both parties (buyer and seller names)</li><li>If a payment exists, shows payment status, amount, Whop payment ID, and date</li><li>Links to the product page for quick navigation back</li></ul><p>This page serves two purposes: it's the landing page when a user clicks a trade-related notification (bid matched, payment confirmed, item shipped, etc.), and it's the place where a buyer who didn't complete checkout immediately can return to pay.</p><p>At this point you should have:</p><ul><li>Seller onboarding flow with connected account creation and KYC</li><li>Checkout configuration creation on bid/ask match using Direct Charges</li><li>BidForm auto-redirect to Whop checkout when a bid matches immediately</li><li>Payment redirect back to your app via <code>redirect_url</code></li><li>Payment verification callback as a webhook fallback</li><li>Escrow pattern: payment held until authentication passes</li><li>Payout release after successful verification</li><li>Refund flow for failed authentication</li><li>Webhook handler at <code>/api/webhooks/whop/route.ts</code> with signature verification</li><li>Idempotency checks on all webhook and callback event processing</li><li>Notifications sent on payment success and failure</li><li>Trade detail page at <code>/trades/[id]</code> with "Pay Now" for pending trades</li><li>Trade GET endpoint at <code>/api/trades/[id]</code> with auth gating</li></ul><h2 id="part-5-product-authentication-product-pages-and-market-data">Part 5: Product authentication, product pages, and market data</h2><p>So far, you have a matching engine that links bids with asks and a payments system that charges buyers and holds funds in escrow. One of the most critical parts of moving money between buyers and sellers is the item authentication, where you verify the item's legitimacy before releasing funds.</p><p>This authentication flow is what makes StockX work - instead of a trust-based system, buyers have their backs covered by the platform. This project replicates this with an admin review flow.</p><p>If you don't need the authentication for your marketplace, you can skip this section and use a trust-based system instead where payment release happens automatically after seller ships the item. The escrow flow from Part 4 still works, you'd just remove the <code>AUTHENTICATING</code> step and move directly from <code>SHIPPED</code> to <code>DELIVERED</code>.</p><h3 id="the-status-state-machine">The status state machine</h3><p>Every trade follows a linear path with one decision point:</p><ul><li><strong>MATCHED -> PAID</strong>: Triggered by the <code>payment.succeeded</code> webhook (Part 4)</li><li><strong>PAID -> SHIPPED</strong>: Triggered when the seller submits a tracking number</li><li><strong>SHIPPED -> AUTHENTICATING</strong>: Triggered when the platform confirms receipt of the item</li><li><strong>AUTHENTICATING -> VERIFIED</strong>: Admin marks the item as passing authentication</li><li><strong>AUTHENTICATING -> FAILED</strong>: Admin marks the item as failing authentication</li><li><strong>VERIFIED -> DELIVERED</strong>: Shipping confirmation shows the item was delivered to the buyer</li><li><strong>FAILED -> REFUNDED</strong>: Buyer refund is processed, item returned to seller</li></ul><h3 id="what-happens-on-failure">What happens on failure</h3><p>If an items fails the authentication these steps occur:</p><ul><li>The trade status moves to <code>FAILED</code></li><li>The buyer receives a full refund via Whop (against the original charge)</li><li>The seller is notified that authentication failed, with the reason</li><li>The item is flagged for return shipping to the seller</li><li>Once the seller receives the item back, they can optionally relist it (create a new ask)</li><li>The original bid is also reopened, the buyer still wants the product, just not a counterfeit one</li></ul><p>The failure reason matters. "Wrong size sent" is different from "Counterfeit item." Your admin panel should capture this and display it to the seller.</p><h3 id="admin-review-process">Admin review process</h3><p>The admin control flow should be handled by the admin panel, protected behind an admin role check at the <code>admin/authentication</code>. It requires a list of trades with the <code>AUTHENTICATING</code> status (oldest to newest) and, in addition, should have easy-to-use approve/reject buttons.</p><p>In the event of a rejection, admins must provide an explanation. An audit trail is generated for all actions taken by admins.</p><h3 id="centralized-product-pages">Centralized product pages</h3><p>This project will utilise centralised product pages. On sites such as eBay or Etsy, each product, for example the Nike Jordan 1 Royal, has individual pages linked to sellers, and each has different images and prices. On StockX, each product has a single page, and sellers' asks and buyers' bids are aggregated on a single canonical page.</p><p>This gives the platform control over the product catalogue. Instead of listing a product, sellers create an ask on an existing product page. If the product is not available on the platform, admins can add it to the catalogue. This centralisation enables the bid and ask market model to function.</p><p>Each product page needs:</p><ul><li>Product images (platform-controlled, not seller-uploaded)</li><li>Product name, brand, SKU, colorway, release date</li><li>Description and specifications</li><li>Retail price (for reference - the market price will be different)</li><li>Size picker that switches the entire page's market context</li><li>Order book showing current bids and asks for the selected size</li><li>Price history chart for the selected size</li><li>Bid and Ask action buttons</li></ul><p>Let's create the product page route. Go to <code>src/app/products/[id]</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">page.tsx</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">export const dynamic = "force-dynamic";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { ProductDetail } from "@/components/ProductDetail";
async function getProduct(id: string) {
const product = await prisma.product.findUnique({
where: { id },
include: {
sizes: {
orderBy: { size: "asc" },
include: {
trades: {
select: { price: true, createdAt: true },
orderBy: { createdAt: "asc" },
take: 100,
},
},
},
},
});
return product;
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
if (!product) {
notFound();
}
const allTrades = product.sizes.flatMap((s) =>
s.trades.map((t) => ({
price: t.price,
createdAt: t.createdAt.toISOString(),
}))
);
const totalSales = product.sizes.reduce((sum, s) => sum + s.salesCount, 0);
const avgPrice =
allTrades.length > 0
? allTrades.reduce((sum, t) => sum + t.price, 0) / allTrades.length
: null;
const lastSale =
allTrades.length > 0 ? allTrades[allTrades.length - 1].price : null;
const premiumDiscount =
lastSale !== null
? (((lastSale - product.retailPrice) / product.retailPrice) * 100).toFixed(0)
: null;
const serializedSizes = product.sizes.map((s) => ({
id: s.id,
size: s.size,
lowestAsk: s.lowestAsk,
highestBid: s.highestBid,
lastSalePrice: s.lastSalePrice,
salesCount: s.salesCount,
}));
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<ProductDetail
product={{
id: product.id,
name: product.name,
brand: product.brand,
sku: product.sku,
description: product.description,
images: product.images,
retailPrice: product.retailPrice,
category: product.category,
}}
sizes={serializedSizes}
trades={allTrades}
marketSummary={{
lastSale,
avgPrice,
totalSales,
premiumDiscount,
}}
/>
</div>
);
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<h3 id="the-sizesku-picker">The size/SKU picker</h3><p>Sizes or product types on the platform have their own independent marketplaces. A size 10 Air Jordan 1 Royal may be sold for $180, while a size 13 may be sold for $220. The size and product type selector controls transitions between these marketplaces. When a user selects a size or type:</p><ul><li>The lowest ask and highest bid update to reflect that size's order book</li><li>The price history chart redraws with that size's trade history</li><li>The bid and ask forms pre-fill with the selected size</li><li>The "last sale" price updates<br>Requirements for the size picker are:</li><li>Grid layout showing all available sizes</li><li>Each size tile displays the lowest ask price for that size (or "No Asks" if none exist)</li><li>Visual highlight on the selected size</li><li>Sizes with no market activity should still be selectable (users should be able to place the first bid or ask)</li><li>URL state: selecting a size updates the URL query parameter (<code>?size=10</code>) so the page is shareable and bookmarkable</li><li>Default to the most popular size (highest trade volume) when no size is specified</li></ul><h3 id="real-time-market-data">Real-time market data</h3><p>Markets without live price feeds can feel dull. If a user browsing a product page doesn't see the data when someone else places a bid on the same product, the experience StockX promises is ruined.</p><p>That's why we used Supabase Realtime in part one – it allows us to use real-time database changes without having to develop our own WebSocket infrastructure.</p><h3 id="live-price-ticker">Live price ticker</h3><p>Products and their sizes or types are updated in real time with their latest sale prices, and all users viewing the products can see the new prices without having to refresh the page whenever a transaction is completed on the platform.</p><p>This is achieved by Supabase Realtime tracking the <code>Trade</code> table and filtering by product.<br>Requirements for the live ticker:</p><ul><li>Subscribe to new trades for the currently viewed product/size</li><li>Update the "Last Sale" price immediately when a new trade is inserted</li><li>Show a brief visual indicator (green/red flash) when the price changes</li><li>Unsubscribe when the user navigates away (cleanup on unmount)</li><li>Handle connection drops by reconnecting and fetching the latest price</li></ul><p>The <code>useRealtimeBids</code> hook we created in part three powers this.</p><h3 id="price-history-chart">Price history chart</h3><p>The chart on product pages lists prices that change over time. This is one of StockX's most recognisable features and presents product pages to users with an experience similar to a stock chart.</p><p>Data points the chart needs:</p><ul><li><strong>Last sale price</strong>: the most recent trade price (real-time)</li><li><strong>Average sale price (30-day)</strong>: rolling average over the past 30 days</li><li><strong>Total sales volume</strong>: number of trades for this product/size, shown as context</li><li><strong>Highest sale</strong>: the peak price this product/size has ever traded at</li><li><strong>Lowest sale</strong>: the floor price</li><li><strong>Premium/discount vs. retail</strong>: current market price as a percentage above or below the original retail price (e.g., "+42% above retail")<br>Charts should also support timeframe options such as 1 day, 7 days, 30 days, 90 days, 1 year, and all time. Each timeframe selection reloads the price history and refreshes the chart.<br>Requirements for the price chart:</li><li>Line chart with trade price on the Y axis and date on the X axis</li><li>Time range selector (<code>1D</code>, <code>7D</code>, <code>30D</code>, <code>90D</code>, <code>1Y</code>, <code>ALL</code>)</li><li>Hover tooltip showing exact price and date for each data point</li><li>Volume bars along the bottom (optional but adds context)</li><li>Summary stats displayed alongside the chart (last sale, average, high, low, premium/discount)</li><li>Responsive: works on mobile screens without losing readability</li></ul><h2 id="part-6-search-notifications-and-deployment">Part 6: Search, notifications, and deployment</h2><figure class="kg-card kg-image-card"><img src="https://whop.com/blog/content/images/2026/02/Notifications.webp" class="kg-image" alt="How to build a StockX clone with Next.js and Whop" loading="lazy" width="2000" height="881" srcset="https://whop.com/blog/content/images/size/w600/2026/02/Notifications.webp 600w, https://whop.com/blog/content/images/size/w1000/2026/02/Notifications.webp 1000w, https://whop.com/blog/content/images/size/w1600/2026/02/Notifications.webp 1600w, https://whop.com/blog/content/images/2026/02/Notifications.webp 2294w" sizes="(min-width: 720px) 720px"></figure><p>So far, we have covered market logic, bid and ask matching, payment flow, escrow logic, product verification, product pages, and live market data.</p><p>This final section will address the features that will enable you to use the project on a large scale: search, notifications, and user dashboards.</p><h3 id="full-text-search">Full-text search</h3><p>Markets hosting hundreds or even thousands of products require powerful search capabilities. Users visiting the site with the intention to purchase will have specific product names in mind and should be able to find them easily.</p><p>We will avoid using external systems by leveraging PostgreSQL's built-in text search feature via Prisma. The search indexes product name, brand, and SKU using <code>tsvector</code> columns, and queries use <code>tsquery</code> with ranking.</p><p>Requirements for search:</p><ul><li>Search across product name, brand, and SKU simultaneously</li><li>Debounced input: don't fire a query on every keystroke, wait 300ms after the user stops typing</li><li>Autocomplete suggestions: show a dropdown of matching products as the user types, with product images and current lowest ask price</li><li>Highlighted matches: bold the matching portion of each result so the user can scan quickly</li><li>Empty state: show trending products when the search box is empty or has no results</li><li>Keyboard navigation: arrow keys to move through suggestions, Enter to select</li><li>Mobile-friendly: full-screen search overlay on small screens</li></ul><p>Create the search component at <code>src/components/SearchBar.tsx</code>. This component manages filter state locally, debounces the search input (300ms delay after the user stops typing), and calls an <code>onChange</code> callback with the current filters.</p><p>It renders a search input, dropdowns for category/brand/size, min/max price fields, and removable filter pills for active filters. The <code>CATEGORIES</code> constant from <code>/src/constants/index.ts</code> populates the category dropdown; brand and size options are passed as props from the parent page.</p><h3 id="filter-system">Filter system</h3><p>Users can find products using the search system you have developed, but they also need filtering systems to find the specific products and types they want.</p><p>Available filters:</p><ul><li><strong>Category</strong>: Sneakers, Streetwear, Electronics, Collectibles (or whatever categories your catalog uses)</li><li><strong>Brand</strong>: Nike, Adidas, New Balance, etc. dynamically populated from your product catalog</li><li><strong>Size</strong>: Varies by category, shoe sizes for sneakers, clothing sizes for streetwear</li><li><strong>Price range</strong>: Min/max slider or input fields, filtering by lowest ask price<br>Requirements for the filter system:</li><li>URL-persisted filter state: every filter combination maps to a URL query string (<code>?category=sneakers&brand=nike&minPrice=100</code>). Users can bookmark filtered views and share links</li><li>Combinable filters: all filters work together (brand + size + price range narrows results progressively)</li><li>Result count update: show how many products match the current filter combination, updating as filters change</li><li>Clear filters: one-click reset to remove all active filters</li><li>Filter pills: show active filters as removable chips/pills above the results grid</li><li>Responsive sidebar: filters in a collapsible sidebar on desktop, a bottom sheet or modal on mobile</li></ul><h3 id="trending-and-most-popular">Trending and most popular</h3><p>Home and discover pages require systems to bring attention-grabbing products to users. This increases engagement while also allowing new users to discover other content offered by your platform.</p><p>The trending algorithm tracks new activity signals and determines a score:</p><ul><li><strong>Trade volume (24h)</strong>: products that are actively selling rank higher</li><li><strong>Bid count (24h)</strong>: products with many active bids have demand even if they haven't traded recently</li><li><strong>Price movement</strong>: products with significant price changes (up or down) are interesting, a 15% price jump in a day is newsworthy<br>You can weight these however makes sense for your catalog. A simple starting point: <code>score = (trades_24h * 3) + (bids_24h * 1) + (abs(price_change_pct) * 2)</code>. Adjust the multipliers based on what produces good results with your data.<br>Requirements:</li><li>Trending section on the homepage showing the top 8-12 products by trending score</li><li>"Most Popular" as a sort option on browse/search pages</li><li>Recalculate trending scores periodically (every 15 minutes via a cron job, or on-demand with caching)</li><li>Show the signal: "12 sales today" or "+8% this week" alongside trending products so users understand why they're featured</li></ul><h3 id="custom-pagination">Custom pagination</h3><p>Due to the nature of the platform, product lists can be very long, and most likely will be.</p><p>While other marketplace sites solve this problem through methods such as infinite scroll, there are drawbacks, the biggest being performance. Instead of such solutions, you will use a page-based solution in this project.</p><p>Requirements for pagination:</p><ul><li>Page size: configurable, default 40 products per page</li><li>Total count: display "Showing 41-80 of 1,247 products" so users know the scale</li><li>Next/previous navigation with page numbers</li><li>URL-persisted page state: <code>?page=3</code> or <code>?cursor=abc123</code> in the URL</li><li>Preserve filter state across pages, changing pages shouldn't reset active filters<br>Create the pagination component at <code>src/components/Pagination.tsx</code>. It takes <code>currentPage</code>, <code>totalPages</code>, and an <code>onPageChange</code> callback. The component renders first/previous/next/last buttons with SVG icons, page numbers with ellipsis for large ranges (showing at most 7 page buttons), and disables navigation buttons at the boundaries. It returns <code>null</code> when <code>totalPages</code> is 1 or less.</li></ul><h3 id="in-app-notification-system">In-app notification system</h3><p>Users should receive notifications when an interaction requiring their attention occurs (such as a bid matching or a payment completing).</p><p>Let's set up this system. We will deliver notifications to users within the application without using any external services. A notification button in the navigation bar should display the user's notification count and, when clicked, open a menu listing the notifications.</p><p>Notification triggers:</p><ul><li><strong>Bid matched</strong>: your bid found a seller at your price or lower - "Your bid for Air Jordan 15 (Size 10) was matched at $285"</li><li><strong>Ask matched</strong>: your ask found a buyer - "Your Air Jordan 15 (Size 10) sold for $290. Ship it for authentication."</li><li><strong>Payment confirmed</strong>: charge went through - "Payment of $285 confirmed for your purchase"</li><li><strong>Payment failed</strong>: charge failed - "Payment failed. Your bid has been reopened."</li><li><strong>Item shipped</strong>: seller provided tracking - "Your item has shipped. Tracking: [number]"</li><li><strong>Authentication complete</strong>: item was verified - "Your Jordan 4 Bred passed authentication and is on its way"</li><li><strong>Authentication failed</strong>: item failed verification - "Authentication failed. A refund has been initiated."</li><li><strong>Price alert</strong>: a product on your watchlist hit a target price - "Air Jordan 15 (Size 10) dropped below your $250 alert"<br>Requirements for the notification system:</li><li><code>Notification</code> table in the database (already in the Prisma schema) with fields: userId, type, message, metadata (JSON), read status, createdAt</li><li>Bell icon in the nav bar with a badge showing the unread count</li><li>Dropdown panel showing the most recent notifications, grouped by read/unread</li><li>Mark individual notifications as read (on click)</li><li>"Mark all as read" button</li><li>Notification detail: clicking a notification navigates to the relevant page (trade detail, product page, dashboard)</li><li>Polling or Supabase Realtime subscription to check for new notifications without page refresh</li><li>Notification preferences: let users choose which notification types they want (future enhancement - start with all enabled)</li></ul><p>Create the notification hook at <code>src/hooks/useNotifications.ts</code>. This hook fetches notifications from <code>/api/notifications</code>, subscribes to Supabase Realtime on the <code>Notification</code> table filtered by <code>userId</code> for live updates, and exposes <code>markAsRead</code> (patches a single notification) and <code>markAllAsRead</code> (patches all unread). It returns <code>{ notifications, unreadCount, markAsRead, markAllAsRead, isLoading }</code>.</p><p>Then create the notification button component in the <code>src/components/NotificationBell.tsx</code> location. It should use a <code>useNotifications</code> hook and have a bell icon with a notification count badge. Clicking a notification should mark it as read and - if the notification's <code>metadata</code> contains a <code>tradeId</code> - navigate to <code>/trades/{tradeId}</code> (the trade detail page from part four).</p><p>This gives users a direct path from "Your bid was matched" to the trade page where they can pay or track status. Use Next.js <code>useRouter</code> for the navigation and close the dropdown on click.</p><h3 id="user-dashboards">User dashboards</h3><p>User dashboards should provide users with information such as active orders, history, and product positions. Naturally, these dashboards should vary depending on the user type (seller, buyer, or both).</p><p>The buyer dashboard shows:</p><ul><li><strong>Active bids</strong>: all open bids with current status, product info, and a cancel option</li><li><strong>Purchase history</strong>: completed trades with dates, prices, and authentication status</li><li><strong>Items awaiting delivery</strong>: trades that have been verified and are in transit</li><li><strong>Portfolio value</strong>: the total market value of items the buyer owns, based on current lowest ask prices<br>The seller dashboard shows:</li><li><strong>Active asks</strong>: all open asks with current status, product info, and ability to update price or cancel</li><li><strong>Sales history</strong>: completed trades with dates, sale price, fees, and net earnings</li><li><strong>Items to ship</strong>: trades in <code>PAID</code> status that need the seller to ship for authentication - this is action-required, so make it prominent</li><li><strong>Earnings summary</strong>: total earnings, pending payouts, completed payouts</li></ul><h3 id="order-history">Order history</h3><p>A filterable, sortable table showing every trade the user has been involved in:</p><ul><li>Trade date, product, size, price, role (buyer/seller), status</li><li>Filters by status (active, completed, failed), role, date range</li><li>Sortable by date, price, or status</li><li>Expandable rows showing trade detail (tracking number, authentication result, payment info)</li></ul><p>Create the dashboard page at <code>src/app/dashboard/page.tsx</code>. This is a client component with four tabs - Overview, Buying, Selling, and Portfolio. It uses a <code>usePortfolio</code> hook (at <code>src/hooks/usePortfolio.ts</code>) that loads the user's active bids, asks, completed trades, payment totals, and portfolio value from <code>/api/user/portfolio</code>.</p><p>The Overview tab shows summary cards, the Buying tab shows active bids and purchase history tables, the Selling tab gates behind seller onboarding - if the user hasn't completed Whop KYC, it shows a "Become a Seller" button that triggers <code>POST /api/sellers/onboard</code> and redirects to the Whop-hosted verification flow, otherwise it shows active asks.</p><p>The Portfolio tab shows each owned item with purchase price, current market price, and gain/loss percentage. The same onboarding gate applies to the <code>AskForm</code> component on product pages - both use the <code>useCurrentUser</code> hook (from part four) to check <code>connectedAccountId</code>.</p><h3 id="deployment-and-production-readiness">Deployment and production readiness</h3><p>Your app has been running on Vercel since part one, so let's take a look at some important factors to consider before you move the project to production:</p><ul><li>You should use the Whop API (<code>https://api.whop.com</code>) for the <code>WHOP_API_BASE</code> environment variable on production. For preview and development, you used Whop sandbox (<code>https://sandbox-api.whop.com</code>)</li><li>Make sure <code>WHOP_API_KEY</code> is a <strong>company API key</strong> in all environments, and <code>WHOP_COMPANY_ID</code> is set correctly</li><li>Your webhook signature verification is active and tested (in part four), rate limiting applies to all API routes, and each has Zod validation.</li><li>There are no secrets in <code>NEXT_PUBLIC_</code> environment variables</li><li>CORS and CSRF configurations are set up</li><li>Sanitized input for storage and render</li></ul><h2 id="part-7-buyer-seller-chat-with-whop-embedded-components">Part 7: Buyer-seller chat with Whop embedded components</h2><figure class="kg-card kg-image-card"><img src="https://whop.com/blog/content/images/2026/02/TradeDetails.webp" class="kg-image" alt="How to build a StockX clone with Next.js and Whop" loading="lazy" width="2000" height="604" srcset="https://whop.com/blog/content/images/size/w600/2026/02/TradeDetails.webp 600w, https://whop.com/blog/content/images/size/w1000/2026/02/TradeDetails.webp 1000w, https://whop.com/blog/content/images/size/w1600/2026/02/TradeDetails.webp 1600w, https://whop.com/blog/content/images/2026/02/TradeDetails.webp 2107w" sizes="(min-width: 720px) 720px"></figure><p>So far, the project has authentication, bid/ask matching, payments, product pages, search, and notifications. One thing is missing: buyers and sellers can't talk to each other.</p><p>We'll add chat to every trade using Whop's embedded chat components - drop-in UI that handles messaging, presence, and real-time updates without building any chat infrastructure.</p><h3 id="why-whop-embedded-chat">Why Whop embedded chat?</h3><p>Building a chat system from scratch means: message storage, WebSocket connections, delivery guarantees, typing indicators, read receipts, media uploads, moderation. That's a project on its own.</p><p>Whop provides:</p><ul><li>A <strong>pre-built chat UI</strong> that drops into any React app</li><li><strong>Real-time messaging</strong> over WebSockets (handled for you)</li><li><strong>DM channels</strong> scoped to your company</li><li><strong>Short-lived access tokens</strong> so your server controls who can chat</li><li><strong>No OAuth scope changes</strong> - access tokens bypass OAuth entirely</li></ul><p>The architecture is simple: your server creates DM channels and tokens via the Whop API, and the client renders the chat UI using those tokens.</p><h3 id="what-were-building">What we're building</h3><ul><li>Auto-create a DM channel between buyer and seller when a trade matches</li><li>Show the chat side-by-side with trade details (stacked on mobile)</li><li>Send automated system messages on trade status changes (payment confirmed, payment failed)</li><li>Add a chat icon in the navbar linking to the dashboard</li></ul><h3 id="step-1-install-packages">Step 1: Install packages</h3><p>You need to install two packages: the React wrapper components and the underlying vanilla JS runtime:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Terminal</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-bash">npm install @whop/embedded-components-react-js@0.0.13-beta.4 @whop/embedded-components-vanilla-js@0.0.13-beta.4
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">The chat components are in the <code spellcheck="false" style="white-space: pre-wrap;">0.0.13-beta</code> release. The stable <code spellcheck="false" style="white-space: pre-wrap;">0.0.12</code> only has payouts components.</div></div><h3 id="step-2-whop-dashboard-permissions">Step 2: Whop dashboard permissions</h3><p>Your <strong>company API key</strong> needs these permissions for chat to work:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Permission</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>chat:read</code></td>
<td>Read chat messages</td>
</tr>
<tr>
<td><code>chat:message:create</code></td>
<td>Send messages (system messages)</td>
</tr>
<tr>
<td><code>dms:read</code></td>
<td>List and retrieve DM channels</td>
</tr>
<tr>
<td><code>dms:message:manage</code></td>
<td>Manage messages in DM channels</td>
</tr>
<tr>
<td><code>dms:channel:manage</code></td>
<td>Create and manage DM channels</td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p>Go to your company dashboard, Settings, API Keys and enable these on the same API key you're using for <code>WHOP_API_KEY</code>.</p><h3 id="step-3-environment-variable">Step 3: Environment variable</h3><p>The embedded chat component needs to know whether to connect to Whop's sandbox or production environment. Add this to your Vercel environment variables:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Variable</th>
<th>Value</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>NEXT_PUBLIC_WHOP_ENVIRONMENT</code></td>
<td><code>sandbox</code> (dev/preview) or <code>production</code> (prod)</td>
<td>Tells the embedded chat which Whop environment to connect to</td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p>This is a <code>NEXT_PUBLIC_</code> variable because the chat component runs in the browser and needs to know the environment at runtime.</p><h3 id="step-4-what-was-already-set-up">Step 4: What was already set up</h3><p>Several chat integration points were already built into earlier parts of this tutorial:</p><ul><li><strong>Part 2</strong>: The <code>chatChannelId</code> field on the Trade model stores the Whop DM channel ID</li><li><strong>Part 3</strong>: The matching engine's <code>setupTradeChat</code> function auto-creates a DM channel when a trade matches and sends an initial system message</li><li><strong>Part 4</strong>: The webhook handler and payment callback both send system messages to the trade's chat channel on payment success/failure</li></ul><p>The rest of this part covers the new files needed to complete the chat feature.</p><h3 id="step-5-chat-service">Step 5: Chat service</h3><p>Go to <code>src/services</code> and create a file called <code>chat.ts</code> with the content below, including three functions that call the Whop API directly. These endpoints aren't in <code>@whop/sdk@0.0.27</code>, so we use <code>fetch</code>.</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">chat.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { env } from "@/lib/env";
export async function createAccessToken(oauthToken: string): Promise<string> {
const res = await fetch(`${env.WHOP_API_BASE}/api/v1/access_tokens`, {
method: "POST",
headers: {
Authorization: `Bearer ${oauthToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to create access token: ${res.status} ${text}`);
}
const data = (await res.json()) as { token: string };
return data.token;
}
export async function createDmChannel(
buyerWhopId: string,
sellerWhopId: string,
tradeName: string
): Promise<string> {
const res = await fetch(`${env.WHOP_API_BASE}/api/v1/dm_channels`, {
method: "POST",
headers: {
Authorization: `Bearer ${env.WHOP_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
with_user_ids: [buyerWhopId, sellerWhopId],
company_id: env.WHOP_COMPANY_ID,
custom_name: tradeName,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to create DM channel: ${res.status} ${text}`);
}
const data = (await res.json()) as { id: string };
return data.id;
}
export async function sendSystemMessage(
channelId: string,
content: string
): Promise<void> {
const res = await fetch(`${env.WHOP_API_BASE}/api/v1/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${env.WHOP_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ channel_id: channelId, content }),
});
if (!res.ok) {
const text = await res.text();
console.error(`Failed to send system message: ${res.status} ${text}`);
}
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<h3 id="step-6-token-endpoint">Step 6: Token endpoint</h3><p>The embedded chat component needs a token to authenticate. Let's create a simple endpoint that returns one for the logged-in user. Go to <code>src/app/api/token</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">route.ts</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
import { createAccessToken } from "@/services/chat";
export async function GET() {
try {
const session = await getSession();
if (!session.accessToken) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
const token = await createAccessToken(session.accessToken);
return NextResponse.json({ token });
} catch (error: unknown) {
console.error("Token creation error:", error);
return NextResponse.json(
{ error: "Failed to create token" },
{ status: 500 }
);
}
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<h3 id="step-7-tradechat-component">Step 7: TradeChat component</h3><p>Now, let's create the React component that renders the embedded chat. Go to <code>src/components</code> and create a file called <code>TradeChat.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">TradeChat.tsx</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">"use client";
import { useMemo } from "react";
import {
ChatElement,
ChatSession,
Elements,
} from "@whop/embedded-components-react-js";
import { loadWhopElements } from "@whop/embedded-components-vanilla-js";
import type { ChatElementOptions } from "@whop/embedded-components-vanilla-js/types";
const whopEnvironment =
(process.env.NEXT_PUBLIC_WHOP_ENVIRONMENT as "sandbox" | "production") ||
"production";
const elements = loadWhopElements({ environment: whopEnvironment });
async function getToken({ abortSignal }: { abortSignal: AbortSignal }) {
const response = await fetch("/api/token", { signal: abortSignal });
const data = await response.json();
return data.token;
}
interface TradeChatProps {
channelId: string | null;
}
export function TradeChat({ channelId }: TradeChatProps) {
const chatOptions: ChatElementOptions = useMemo(() => {
return { channelId: channelId ?? "" };
}, [channelId]);
if (!channelId) {
return (
<div className="flex items-center justify-center h-64 text-gray-500 text-sm">
Chat will be available once the trade is matched.
</div>
);
}
return (
<Elements elements={elements}>
<ChatSession token={getToken}>
<ChatElement
options={chatOptions}
style={{ height: "500px", width: "100%" }}
/>
</ChatSession>
</Elements>
);
}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Key points:</p><ul><li><code><strong>loadWhopElements({ environment })</strong></code> runs once at module level - it loads the Whop JS runtime and connects to the correct environment (sandbox or production) based on <code>NEXT_PUBLIC_WHOP_ENVIRONMENT</code></li><li><code><strong>getToken({ abortSignal })</strong></code> fetches from our <code>/api/token</code> endpoint. The <code>ChatSession</code> calls this automatically and refreshes before expiry. The <code>abortSignal</code> param lets the component cancel in-flight requests on unmount</li><li><strong><code>Elements</code></strong> provides the Whop runtime context to child components</li><li><strong><code>ChatSession</code></strong> manages authentication state</li><li><strong><code>ChatElement</code></strong> renders the actual chat UI for a specific channel</li></ul><h3 id="step-8-trade-detail-pageside-by-side-layout">Step 8: Trade detail page - side-by-side layout</h3><p>Update the <code>src/app/trades/[id]/page.tsx</code> file to show the chat alongside trade details.</p><p>Add the import at the top:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">page.tsx</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">import { TradeChat } from "@/components/TradeChat";
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>Add <code>chatChannelId</code> to the Trade interface:</p><pre><code class="language-typescript">interface Trade {
id: string;
price: number;
platformFee: number;
chatChannelId: string | null;
// ... rest is unchanged
}
</code></pre><p><strong>Change the layout</strong> from a single column to a responsive grid. The key changes:</p><ul><li>Add an <code>isParticipant</code> check before the return:</li></ul>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">page.tsx</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">const isParticipant = currentUserId === trade.buyerId || currentUserId === trade.sellerId;</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<ul><li>Replace the <code>max-w-3xl</code> container with a responsive width:</li></ul>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">page.tsx</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript"><div className={`mx-auto ${isParticipant && trade.chatChannelId ? "max-w-6xl" : "max-w-3xl"}`}></code></pre>
</div>
</div>
<!--kg-card-end: html-->
<ul><li>Wrap the trade card and chat panel in a responsive grid:</li></ul>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">page.tsx</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript"><div className={`grid gap-6 ${isParticipant && trade.chatChannelId ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1"}`}>
{/* Existing trade card */}
<div className="card p-6 space-y-6">
{/* ... all existing trade details unchanged ... */}
</div>
{/* Chat Panel */}
{isParticipant && trade.chatChannelId && (
<div className="card p-4">
<h2 className="text-sm font-medium text-gray-400 mb-3">Trade Chat</h2>
<TradeChat channelId={trade.chatChannelId} />
</div>
)}
</div>
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<p>On desktop (<code>lg</code> breakpoint), the trade details and chat sit side-by-side. On mobile, the chat stacks below.</p><h3 id="step-9-navbar-chat-icon">Step 9: Navbar chat icon</h3><p>Add a chat bubble icon to the navbar that links to the dashboard (where users can find their trade chats).<br>Go to <code>src/components</code> and update the <code>Navbar.tsx</code> file to add a chat icon before the <code>NotificationBell</code>:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
<div class="ucb-header">
<span class="ucb-title">Navbar.tsx</span>
<button class="ucb-copy" onclick="
const code = this.closest('.ucb-box').querySelector('code').innerText;
navigator.clipboard.writeText(code);
const originalText = this.innerText;
this.innerText = 'Copied!';
setTimeout(() => this.innerText = originalText, 2000);
">Copy</button>
</div>
<div class="ucb-content">
<pre class="ucb-pre"><code class="language-typescript">{user && (
<Link
href="/dashboard"
className="p-2 rounded-lg hover:bg-gray-800 transition-colors"
title="Trade Chats"
>
<svg
className="w-5 h-5 text-gray-400"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"
/>
</svg>
</Link>
)}
{user && <NotificationBell userId={user.id} />}
</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<h3 id="step-10-test-it">Step 10: Test it</h3><p>Walk through the complete flow:</p><ol><li><strong>Sign in</strong> with two different accounts (or use the same account for a self-match test)</li><li><strong>Place an ask</strong> at $50 for any product/size</li><li><strong>Place a bid</strong> at $50 for the same product/size - the matching engine creates a trade</li><li><strong>Navigate to</strong> <code>/trades/{tradeId}</code> - you should see the trade details on the left and a chat panel on the right</li><li><strong>Send a message</strong> in the chat - it appears in real-time</li><li><strong>Complete payment</strong> via the "Pay Now" button - after payment, a system message appears: "Payment confirmed! Seller, please ship your item for authentication."</li><li><strong>Check the navbar</strong> - the chat bubble icon appears next to the notification bell</li></ol><h2 id="wrapping-up-the-project">Wrapping up the project</h2><p>Over the course of this tutorial, we built a full StockX-style marketplace from scratch:</p><ol><li><strong>Architecture</strong> (Part 1): chose the stack, set up the project, deployed to Vercel</li><li><strong>Data model and auth</strong> (Part 2): designed the Prisma schema, implemented Whop OAuth</li><li><strong>Matching engine</strong> (Part 3): built the bid/ask system that automatically matches trades</li><li><strong>Payments and escrow</strong> (Part 4): integrated Whop Payments Network infrastructure with Direct Charges, built the webhook handler</li><li><strong>Products and market data</strong> (Part 5): authentication flow, centralized product pages, real-time pricing</li><li><strong>Search, notifications, and deployment</strong> (Part 6): full-text search, in-app notifications, dashboards, production hardening</li><li><strong>Buyer-seller chat</strong> (Part 7): embedded Whop chat components, DM channels, system messages on trade events</li></ol><p>At this point you should have:</p><ul><li>Search component at <code>src/components/SearchBar.tsx</code> with debounced input, filters, and removable filter pills, Full-text search with autocomplete, filters, and URL-persisted state</li><li>Trending/most popular algorithm for product discovery, pagination component at <code>src/components/Pagination.tsx</code> with cursor-based custom pagination</li><li>Notification hook at <code>src/hooks/useNotifications.ts</code> with Supabase Realtime subscription, notification bell component at <code>src/components/NotificationBell.tsx</code> with unread count badge, dropdown, and click-to-navigate to trade detail pages</li><li>Dashboard page at <code>src/app/dashboard/page.tsx</code> with Overview, Buying, Selling, and Portfolio tabs, and ortfolio hook at <code>src/hooks/usePortfolio.ts</code> for fetching user trading data</li><li>Production environment variable separation (production vs. preview)</li><li>Security hardening: rate limiting, Zod validation, webhook verification, CORS, CSRF. Performance optimizations: ISR, edge caching, connection pooling</li><li>Chat service at <code>src/services/chat.ts</code> with access token, DM channel, and system message functions, and token endpoint at <code>src/app/api/token/route.ts</code> for embedded chat authentication</li><li>TradeChat component at <code>src/components/TradeChat.tsx</code> with Whop embedded chat UI, and auto-created DM channels on trade match via matching engine</li><li>System messages on payment success/failure in webhook handler and payment callback. Chat icon in navbar linking to dashboard</li><li>A complete, deployed, production-ready StockX clone</li></ul><h3 id="where-to-go-from-here">Where to go from here</h3><ul><li><strong>Different product categories</strong>: the architecture supports any product type - sneakers, trading cards, electronics, vintage clothing. Add categories and adjust the authentication criteria</li><li><strong>Analytics dashboard</strong>: trade volume over time, most-traded products, price movement trends, platform revenue</li><li><strong>Mobile app</strong>: the API routes are already RESTful - a React Native or Flutter app could consume them directly</li><li><strong>Seller ratings</strong>: track authentication pass rates per seller, display trust scores, restrict sellers with high failure rates</li><li><strong>Price alerts via email/SMS</strong>: extend the notification system with external delivery channels for high-priority alerts</li></ul><h2 id="start-building-your-own-stockx-clone-with-whop">Start building your own StockX clone with Whop</h2><p>That's everything you need to build a fully functional StockX clone using Whop infrastructure. If you haven’t already, go to <a href="https://whop.com/">Whop.com</a>, create an account, and build your business.</p><div class="kg-card kg-button-card kg-align-left"><a href="https://whop.com/new/" class="kg-btn kg-btn-accent">Create a whop</a></div><p>If you want to learn more about the Whop Payments Network and the Whop infrastructure, visit our documentation.</p><div class="kg-card kg-button-card kg-align-left"><a href="https://dev.whop.com/" class="kg-btn kg-btn-accent">Go to the Whop documentation</a></div>]]></content:encoded></item>
These legal disclaimers are here because this hub is run by Google as a service. If you don't want to agree to these terms you can use a different hub or even run your own. The PubSubHubbub protocol is decentralized and free.
©2022 Google - Terms of Service - Privacy Policy