Topic Details

https://whop.com/blog/rss/

Last successful fetch
Thu, 05 Feb 2026 16:37:38 +0000
Last ping
Thu, 05 Feb 2026 16:37:37 +0000
Last fetch error
Tue, 02 Dec 2025 22:26:32 +0000 (HTTP 504)
Aggregate statistics
0 fetch request(s) per second to whop.com, 0% errors, based on latest 300 seconds

Last item retrieved

Content received
Thu, 05 Feb 2026 16:37:38 +0000
<item><title><![CDATA[How to build a Patreon clone]]></title><description><![CDATA[Build a Patreon clone using Next.js and the Whop API. This 14 step tutorial will help you start from scratch, and have a fully functioning Patreon clone with authentication, subscription tiers, content gating, webhooks, and creator payouts.]]></description><link>https://whop.com/blog/build-patreon-clone/</link><guid isPermaLink="false">69726161c5effc0001d71117</guid><category><![CDATA[Tutorials]]></category><category><![CDATA[Engineering]]></category><dc:creator><![CDATA[Doğukan Karakaş]]></dc:creator><pubDate>Wed, 04 Feb 2026 00:41:38 GMT</pubDate><media:content url="https://whop.com/blog/content/images/2026/01/PatreonClone.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">&#x2728;</span>
    <span class="ai-prompt-widget__title">Build this with AI</span>
  </div>
  <img src="https://whop.com/blog/content/images/2026/01/PatreonClone.webp" alt="How to build a Patreon clone"><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 Patreon clone is now easier than ever with Whop&apos;s payment infrastructure, Next.js, and Whop&apos;s sandbox playground. This tutorial will walk you through building a fully functional Patreon clone with user authentication, subscription tiers, gated content, creator payouts, and more, then deploy it to Vercel.</p><p>The project you&apos;re going to build has three main parts:</p><ul><li><strong>Next.js app</strong> - handles the frontend and API routes</li><li><strong>PostgreSQL database</strong> - stores users, creators, tiers, posts, and subscriptions</li><li><strong>Whop infrastructure</strong> - handles user authentication, payments, and creator payouts</li></ul><h2 id="project-overview">Project overview</h2><p>Before we jump right in and start coding, let&apos;s take a look at what we&apos;re building. By the end of this tutorial, you&apos;ll have a fully functional creator platform with these pages and features:</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 creator discovery and subscription feed</span></li><li value="2"><code spellcheck="false" style="white-space: pre-wrap;"><span>/signin</span></code><span style="white-space: pre-wrap;"> - Whop OAuth login</span></li><li value="3"><code spellcheck="false" style="white-space: pre-wrap;"><span>/dashboard</span></code><span style="white-space: pre-wrap;"> - User dashboard showing subscriptions</span></li><li value="4"><code spellcheck="false" style="white-space: pre-wrap;"><span>/subscriptions</span></code><span style="white-space: pre-wrap;"> - User&apos;s active subscriptions</span></li><li value="5"><code spellcheck="false" style="white-space: pre-wrap;"><span>/creator/register</span></code><span style="white-space: pre-wrap;"> - Creator registration form</span></li><li value="6"><code spellcheck="false" style="white-space: pre-wrap;"><span>/creator/dashboard</span></code><span style="white-space: pre-wrap;"> - Creator dashboard overview</span></li><li value="7"><code spellcheck="false" style="white-space: pre-wrap;"><span>/creator/tiers</span></code><span style="white-space: pre-wrap;"> - Manage subscription tiers</span></li><li value="8"><code spellcheck="false" style="white-space: pre-wrap;"><span>/creator/posts</span></code><span style="white-space: pre-wrap;"> - Manage content posts</span></li><li value="9"><code spellcheck="false" style="white-space: pre-wrap;"><span>/creator/payouts</span></code><span style="white-space: pre-wrap;"> - View earnings and payout history</span></li><li value="10"><code spellcheck="false" style="white-space: pre-wrap;"><span>/creator/[username]</span></code><span style="white-space: pre-wrap;"> - Public creator profile with tiers and content</span></li><li value="11"><code spellcheck="false" style="white-space: pre-wrap;"><span>/subscribe/[username]/[tierId]</span></code><span style="white-space: pre-wrap;"> - Checkout flow for subscribing to a tier</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 for login and session management</span></li><li value="2"><b><strong style="white-space: pre-wrap;">Creator registration -</strong></b><span style="white-space: pre-wrap;"> Users can become creators with usernames and bios</span></li><li value="3"><b><strong style="white-space: pre-wrap;">Subscription tiers -</strong></b><span style="white-space: pre-wrap;"> Creators can set up multiple pricing tiers (synced with Whop)</span></li><li value="4"><b><strong style="white-space: pre-wrap;">Content posting -</strong></b><span style="white-space: pre-wrap;"> Creators publish posts locked to specific tiers</span></li><li value="5"><b><strong style="white-space: pre-wrap;">Checkout and payments -</strong></b><span style="white-space: pre-wrap;"> Whop handles subscription and charges</span></li><li value="6"><b><strong style="white-space: pre-wrap;">Webhooks -</strong></b><span style="white-space: pre-wrap;"> Payment events sync subscriptions to database</span></li><li value="7"><b><strong style="white-space: pre-wrap;">Content gating -</strong></b><span style="white-space: pre-wrap;"> Posts only visible to subscribers at the right tier</span></li><li value="8"><b><strong style="white-space: pre-wrap;">Payouts -</strong></b><span style="white-space: pre-wrap;"> Creators withdraw funds via the hosted Whop payout portal</span></li></ul></div>
        </div><h2 id="step-1-setting-up-the-project">Step 1: Setting up the project</h2><h3 id="make-sure-you-have-all-the-prerequisites">Make sure you have all the prerequisites</h3><p>Before we start, let&#x2019;s make sure we have our prerequisites.</p><ul><li>Node.js for runtime for Next.js</li><li>npm for package management</li><li>PostgreSQL for database</li><li>Git for version control</li></ul><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;">Check</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"><p><span style="white-space: pre-wrap;">Run this to check if you&apos;re ready:</span></p><p><code spellcheck="false" style="white-space: pre-wrap;"><span>node --version &amp;&amp; npm --version &amp;&amp; psql --version &amp;&amp; git --version</span></code></p><p><span style="white-space: pre-wrap;">The usual response you&#x2019;ll expect is something like v18.19.0, which means you have the tool installed in your system - if you get a response like command not found or &apos;tool&apos; is not recognized, that means the tool isn&#x2019;t installed yet (or isn&#x2019;t available in your PATH), and you&#x2019;ll need to install that tool from its official website or via terminal commands.</span></p></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;">Install</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"><p><span style="white-space: pre-wrap;">If you see version numbers for all four, skip ahead to </span><b><strong style="white-space: pre-wrap;">Create your database</strong></b><span style="white-space: pre-wrap;">. If any are missing, install them below.</span></p><p><b><strong style="white-space: pre-wrap;">Node.js &amp; npm</strong></b><span style="white-space: pre-wrap;"> &#x2014; Download the LTS installer at </span><a href="https://nodejs.org"><span style="white-space: pre-wrap;">nodejs.org</span></a><span style="white-space: pre-wrap;">. Run it, click through the defaults. npm is included automatically.</span></p><p><b><strong style="white-space: pre-wrap;">PostgreSQL</strong></b><span style="white-space: pre-wrap;"> &#x2014; Download the installer at </span><a href="https://postgresql.org/download"><span style="white-space: pre-wrap;">postgresql.org/download</span></a><span style="white-space: pre-wrap;">. Run it, keep the default port (5432), and set a password for the </span><code spellcheck="false" style="white-space: pre-wrap;"><span>postgres</span></code><span style="white-space: pre-wrap;"> user &#x2014; </span><b><strong style="white-space: pre-wrap;">write this password down</strong></b><span style="white-space: pre-wrap;">, you&apos;ll need it shortly.</span></p><p><b><strong style="white-space: pre-wrap;">Git</strong></b><span style="white-space: pre-wrap;"> &#x2014; Download at </span><a href="https://git-scm.com"><span style="white-space: pre-wrap;">git-scm.com</span></a><span style="white-space: pre-wrap;">. Run the installer with default settings.</span></p><p><b><strong style="white-space: pre-wrap;">Prefer installing via the command line?</strong></b></p><p><b><strong style="white-space: pre-wrap;">Mac</strong></b><span style="white-space: pre-wrap;"> (requires </span><a href="https://brew.sh"><span style="white-space: pre-wrap;">Homebrew</span></a><span style="white-space: pre-wrap;">):</span></p><p><code spellcheck="false" style="white-space: pre-wrap;"><span>brew install node postgresql@16 git</span></code><br><code spellcheck="false" style="white-space: pre-wrap;"><span>brew services start postgresql@16</span></code></p><p><b><strong style="white-space: pre-wrap;">Linux</strong></b><span style="white-space: pre-wrap;"> (Ubuntu/Debian):</span></p><p><code spellcheck="false" style="white-space: pre-wrap;"><span>sudo apt update &amp;&amp; sudo apt install nodejs npm postgresql git</span></code><br><code spellcheck="false" style="white-space: pre-wrap;"><span>sudo systemctl start postgresql</span></code></p><p><b><strong style="white-space: pre-wrap;">Windows</strong></b><span style="white-space: pre-wrap;"> (requires </span><a href="https://learn.microsoft.com/en-us/windows/package-manager/"><span style="white-space: pre-wrap;">winget</span></a><span style="white-space: pre-wrap;">):</span></p><p><code spellcheck="false" style="white-space: pre-wrap;"><span>winget install OpenJS.NodeJS.LTS PostgreSQL.PostgreSQL Git.Git</span></code></p></div>
        </div><h3 id="create-your-database">Create your database</h3><p>First, let&#x2019;s create a PostgreSQL user and database for the project. To do so, use the commands below in your terminal:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">psql -U postgres -d postgres -c &quot;CREATE USER patreon_user WITH ENCRYPTED PASSWORD &apos;yourpassword&apos;;&quot;
psql -U postgres -d postgres -c &quot;CREATE DATABASE patreon_clone OWNER patreon_user;&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>If you get a peer authentication failed error, which is common on Linux, use the commands below instead:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">sudo -u postgres psql -d postgres -c &quot;CREATE USER patreon_user WITH ENCRYPTED PASSWORD &apos;yourpassword&apos;;&quot;
sudo -u postgres psql -d postgres -c &quot;CREATE DATABASE patreon_clone OWNER patreon_user;&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">Make sure to replace <code spellcheck="false" style="white-space: pre-wrap;">yourpassword</code> in the commands with a password of your choice and note it down - you&#x2019;ll need it later.</div></div><h3 id="setting-up-the-nextjs-project">Setting up the Next.js project</h3><p>Now that you&#x2019;ve made sure you have all the prerequisites in your system, it&#x2019;s time to set up the Next.js project. You can do this by running the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx create-next-app@latest patreon-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 &#x201C;patreon-clone&#x201D; part of the command with the project name you want. For the sake of simplicity, we&#x2019;ll refer to the folder as &#x201C;patreon-clone&#x201D; in this guide.</div></div><p>This will create a project with templates and install dependencies. After you run the command, if you see a question to install <code>create-next-app</code>, type <code>y</code> and press Enter - and when asked about Next.js defaults, select the Yes, use recommended defaults option.</p><p>This will include dependencies like TypeScript, ESLint, and Tailwind CSS, which you&#x2019;re going to need for this project. Once you&#x2019;re done with the questions, you&#x2019;ll see a folder called <code>patreon-clone</code> in the folder you ran the command.</p><h3 id="install-dependencies">Install dependencies</h3><p>Now, either enter the folder using your terminal with the command below or open a new terminal in the folder manually:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">cd patreon-clone</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Once you&#x2019;re in the folder, you need to install some packages to use Whop&#x2019;s payment APIs, database access, and authentication. You can do this by running the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install @whop/sdk iron-session @prisma/client@5 zod</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>You&#x2019;re also going to need to install some development tools:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install -D prisma@5</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">We&apos;re using Prisma 5 in this tutorial. Prisma 7 was recently released with breaking changes to how database connections work. Prisma 5 is stable, well-documented, and does everything we need.</div></div><h3 id="initialize-prisma">Initialize Prisma</h3><p>Prisma is a tool that lets your code interact with your database using TypeScript. You&#x2019;ve installed Prisma in the previous section, and now you should initialize it using the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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>If you see a prompt asking to install create-prisma, type <code>Y</code> and press Enter. If you don&apos;t see a prompt, that&apos;s fine, Prisma is already installed from the previous step.</p><p>This will create your environment file (<code>.env</code>), prisma configuration file (<a href="http://prisma.config.ts"><u>prisma.config.ts</u></a>), and the prisma schema (<code>schema.prisma</code>) under a new prisma directory.</p><p>The created environment file will store your database connection string, so let&#x2019;s create the <code>.env</code> file in the same folder in your project root.</p><p>After you create it, you&#x2019;re going to need to add your variables to it, and one of them is your <code>SESSION_SECRET</code> for encrypting user sessions. This has to be a random string longer than 32 characters, and you can generate it by running the command below on a terminal regardless of your operating system, as long as you have <a href="http://node.js"><u>Node.js</u></a> (which you should have by now):</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">node -e &quot;console.log(require(&apos;crypto&apos;).randomBytes(32).toString(&apos;hex&apos;))&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Once you get your random string, keep it safe and don&#x2019;t share it anywhere.</p><p>Now, let&#x2019;s open the .env file and add the following content:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">DATABASE_URL=&quot;postgresql://patreon_user:yourpassword@localhost:5432/patreon_clone?schema=public&quot;

SESSION_SECRET=&quot;your-generated-secret-here&quot;
AUTH_URL=&quot;http://localhost:3000&quot;

WHOP_SANDBOX=&quot;true&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Notice the <code>WHOP_SANDBOX=&quot;true&quot;</code> part in the <code>.env</code> snippet above. This means you&apos;re going to use Whop&apos;s sandbox to test your development build. The sandbox allows you to perform actions like payments without transferring real money.</p><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">Since you&apos;re using the Whop sandbox, you must create an account at <a href="https://sandbox.whop.com/" rel="noreferrer">Sandbox.Whop.com</a>, not <a href="https://whop.com/" rel="noreferrer">Whop.com</a></div></div><p>You&#x2019;re also going to add your Whop credentials for OAuth and payments later. These include your app ID (for OAuth), API key, and company ID. We&#x2019;ll set those up in the fourth step.</p><p>Once you&#x2019;re done, save your local environment file and let&#x2019;s check if everything is installed correctly. Running the command below will start the dev server:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm run dev</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>And you should see a result like:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">html</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-html">&gt; patreon-clone@0.1.0 dev
&gt; next dev
&#x25B2; Next.js 16.1.4 (Turbopack)
- Local:         http://localhost:3000
- Network:       http://you</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Now, let&#x2019;s check for three things:</p><ol><li>You&#x2019;re not seeing any errors in the terminal</li><li>The http://localhost:3000 page loads without any crashes and displays the Next.js welcome page</li><li>You can stop the server by hitting CTRL + C in the terminal</li></ol><p>If everything is good so far, great job - it&#x2019;s time to set up your database now.</p><h2 id="step-2-setting-up-the-database">Step 2: Setting up the database</h2><p>In this step, you&#x2019;ll connect Prisma to your database, define the database schema, run the migration to create your database tables, and verify everything works with Prisma Studio.</p><p>First, let&#x2019;s set up a single shared Prisma client so that you don&#x2019;t create new databases with actions like function calls or Next.js hot reloads. To do this, create a folder called <code>lib</code> in your project root (same folder where your <code>.env</code> files are), and a file called <code>prisma.ts</code> inside the lib folder. Then, add the code below to your <code>prisma.ts</code> file:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { PrismaClient } from &apos;@prisma/client&apos;

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === &apos;development&apos; ? [&apos;error&apos;, &apos;warn&apos;] : [&apos;error&apos;],
  })

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

export default prisma</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Now, let&#x2019;s define your database schema by editing the schema.prisma file inside the prisma folder in your project. This will allow us to set up how your data is structured and relate to each other.</p><p>As an example, we&#x2019;ll have five models in our database:</p>
<!--kg-card-begin: html-->
<table>
  <thead>
    <tr>
      <th>Model</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>User</td>
      <td>Everyone who signs up using Whop. Stores their Whop ID, email, and profile information</td>
    </tr>
    <tr>
      <td>Creator</td>
      <td>Extra information for users who become creators. Links to their Whop account for payments</td>
    </tr>
    <tr>
      <td>Tier</td>
      <td>Subscription tiers of creators. Links to Whop plans</td>
    </tr>
    <tr>
      <td>Post</td>
      <td>Content from creators</td>
    </tr>
    <tr>
      <td>Subscription</td>
      <td>Connects subscriber to the creator&#x2019;s tier</td>
    </tr>
  </tbody>
</table>

<!--kg-card-end: html-->
<p>Open the <code>schema.prisma</code> file with your preferred text editor, and paste the code snippet below:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">generator client {
  provider = &quot;prisma-client-js&quot;
}

datasource db {
  provider = &quot;postgresql&quot;
  url      = env(&quot;DATABASE_URL&quot;)
}

model User {
  id            String    @id @default(cuid())
  whopUserId    String    @unique
  whopUsername  String
  email         String    @unique
  name          String?
  avatarUrl     String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  creator       Creator?
  subscriptions Subscription[]
}

model Creator {
  id              String   @id @default(cuid())
  userId          String   @unique
  user            User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  
  whopCompanyId   String?  @unique
  whopOnboarded   Boolean  @default(false)
  
  username        String   @unique
  displayName     String
  bio             String?
  
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  tiers           Tier[]
  posts           Post[]
  subscriptions   Subscription[]
}

model Tier {
  id            String   @id @default(cuid())
  creatorId     String
  creator       Creator  @relation(fields: [creatorId], references: [id], onDelete: Cascade)
  
  whopPlanId    String?  @unique
  
  name          String
  description   String?
  priceInCents  Int
  
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  posts         Post[]   @relation(&quot;TierPosts&quot;)
  subscriptions Subscription[]
}

model Post {
  id            String   @id @default(cuid())
  creatorId     String
  creator       Creator  @relation(fields: [creatorId], references: [id], onDelete: Cascade)
  
  title         String
  content       String
  published     Boolean  @default(false)
  
  minimumTierId String?
  minimumTier   Tier?    @relation(&quot;TierPosts&quot;, fields: [minimumTierId], references: [id])
  
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt
}

model Subscription {
  id                String             @id @default(cuid())
  userId            String
  user              User               @relation(fields: [userId], references: [id], onDelete: Cascade)
  
  creatorId         String
  creator           Creator            @relation(fields: [creatorId], references: [id], onDelete: Cascade)
  
  tierId            String
  tier              Tier               @relation(fields: [tierId], references: [id], onDelete: Cascade)
  
  whopMembershipId  String?            @unique
  status            SubscriptionStatus @default(ACTIVE)
  
  createdAt         DateTime           @default(now())
  updatedAt         DateTime           @updatedAt

  @@unique([userId, creatorId])
}

enum SubscriptionStatus {
  ACTIVE
  CANCELED
  PAST_DUE
  EXPIRED
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>After pasting, it&#x2019;s time for your first database migration. This will basically create the database tables based on the schema file you edited just now. Open a terminal in your root folder and run the command:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx prisma migrate dev --name init</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>If you get an error saying the <code>dotenv/config module cannot be found</code>, don&#x2019;t worry. Since you&#x2019;re not going to use dotenv for this project, you can just delete the prisma.config.ts file in your root folder and re-run the migration command.</p><p>If you see the message &#x201C;Your database is now in sync with your schema.&#x201D; in the results - you just migrated your schema to your database.</p><p>Now, let&#x2019;s test if everything we did works so far, and you&#x2019;re going to use Prisma Studio for that. Run the command below to open up Prisma Studio in your browser:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx prisma studio</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>The things you want to see in Prisma Studio are:</p><ul><li>All 5 models (Creator, Post, Subscription, Tier, and User)</li><li>Fields of models (like id, creatorId, creator, title, and more for the Post model)</li></ul><h2 id="step-3-authentication-with-whop-oauth">Step 3: Authentication with Whop OAuth</h2><p>For the authentication system of your project, you&#x2019;ll use Whop&#x2019;s OAuth with PKCE. This lets users sign in with their Whop accounts. This eliminates the password storage responsibility for you and you can get access to their Whop user ID for later payment functions.</p><h3 id="create-the-session-configuration">Create the session configuration</h3><p>First, let&#x2019;s create a file called <code>session.ts</code> inside the <code>lib</code> folder for the session management system. This keeps users logged in securely.</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">session.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { SessionOptions } from &apos;iron-session&apos;

export interface SessionData {
  userId?: string
  whopUserId?: string
  isLoggedIn: boolean
}

export const sessionOptions: SessionOptions = {
  password: process.env.SESSION_SECRET!,
  cookieName: &apos;patreon-clone-session&apos;,
  cookieOptions: {
    secure: process.env.NODE_ENV === &apos;production&apos;,
    httpOnly: true,
    sameSite: &apos;lax&apos;,
    maxAge: 60 * 60 * 24 * 7, // 1 week
  },
}

export const defaultSession: SessionData = {
  isLoggedIn: false,
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-oauth-configuration">Create OAuth configuration</h3><p>Now, you should create a helper function called <code>oauth.ts</code> in the <code>lib</code> folder so that your authentication flow can generate PKCE codes, create authorization URLs, and code exchange.</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { cookies } from &apos;next/headers&apos;
import { getIronSession } from &apos;iron-session&apos;
import { NextResponse } from &apos;next/server&apos;
import { sessionOptions, SessionData, defaultSession } from &apos;@/lib/session&apos;
import { prisma } from &apos;@/lib/prisma&apos;

export async function getSession() {
  const cookieStore = await cookies()
  const session = await getIronSession&lt;SessionData&gt;(cookieStore, sessionOptions)
  
  if (!session.isLoggedIn) {
    return defaultSession
  }
  
  return session
}

export async function getCurrentUser() {
  const session = await getSession()
  
  if (!session.isLoggedIn || !session.userId) {
    return null
  }
  
  return prisma.user.findUnique({
    where: { id: session.userId },
  })
}

export async function requireAuth() {
  const user = await getCurrentUser()
  
  if (!user) {
    return { user: null, error: NextResponse.json({ error: &apos;Not authenticated&apos; }, { status: 401 }) }
  }
  
  return { user, error: null }
}

export async function requireCreator() {
  const { user, error } = await requireAuth()
  
  if (error) {
    return { user: null, creator: null, error }
  }
  
  const creator = await prisma.creator.findUnique({
    where: { userId: user!.id },
  })
  
  if (!creator) {
    return { user, creator: null, error: NextResponse.json({ error: &apos;Creator account not found&apos; }, { status: 404 }) }
  }
  
  return { user, creator, error: null }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-login-route">Create login route</h3><p>The login route starts the authorization flow by generating PKCE values and redirecting them to Whop with cookies. For the login route, create a file in <code>app/api/auth/login</code> 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &apos;next/server&apos;
import { cookies } from &apos;next/headers&apos;
import { generatePKCE, generateState, buildAuthorizeUrl } from &apos;@/lib/oauth&apos;

export async function GET() {
  const { codeVerifier, codeChallenge } = generatePKCE()
  const state = generateState()
  
  const clientId = process.env.WHOP_APP_ID!
  const redirectUri = `${process.env.AUTH_URL}/api/auth/callback`

  const cookieStore = await cookies()
  cookieStore.set(&apos;oauth_code_verifier&apos;, codeVerifier, {
    httpOnly: true,
    secure: process.env.NODE_ENV === &apos;production&apos;,
    sameSite: &apos;lax&apos;,
    maxAge: 60 * 10, // 10 minutes
    path: &apos;/&apos;,
  })
  cookieStore.set(&apos;oauth_state&apos;, state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === &apos;production&apos;,
    sameSite: &apos;lax&apos;,
    maxAge: 60 * 10,
    path: &apos;/&apos;,
  })

  const authorizeUrl = buildAuthorizeUrl({
    clientId,
    redirectUri,
    codeChallenge,
    state,
  })

  return NextResponse.redirect(authorizeUrl)
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="configure-the-callback-route">Configure the callback route</h3><p>After users log in with Whop, you should exchange codes, get the user information, and create a user in your database. To do so, create a file in <code>app/api/auth/callback</code> 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &apos;next/server&apos;
import { cookies } from &apos;next/headers&apos;
import { getIronSession } from &apos;iron-session&apos;
import { exchangeCodeForTokens, fetchUserInfo } from &apos;@/lib/oauth&apos;
import { sessionOptions, SessionData } from &apos;@/lib/session&apos;
import { prisma } from &apos;@/lib/prisma&apos;

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const code = searchParams.get(&apos;code&apos;)
  const state = searchParams.get(&apos;state&apos;)
  const error = searchParams.get(&apos;error&apos;)

  if (error) {
    return NextResponse.redirect(
      new URL(`/signin?error=${error}`, process.env.AUTH_URL)
    )
  }

  if (!code || !state) {
    return NextResponse.redirect(
      new URL(&apos;/signin?error=missing_params&apos;, process.env.AUTH_URL)
    )
  }

  const cookieStore = await cookies()
  const storedState = cookieStore.get(&apos;oauth_state&apos;)?.value
  const codeVerifier = cookieStore.get(&apos;oauth_code_verifier&apos;)?.value

  if (!storedState || state !== storedState) {
    return NextResponse.redirect(
      new URL(&apos;/signin?error=invalid_state&apos;, process.env.AUTH_URL)
    )
  }

  if (!codeVerifier) {
    return NextResponse.redirect(
      new URL(&apos;/signin?error=missing_verifier&apos;, process.env.AUTH_URL)
    )
  }

  try {
    const tokens = await exchangeCodeForTokens({
      code,
      codeVerifier,
      clientId: process.env.WHOP_APP_ID!,
      redirectUri: `${process.env.AUTH_URL}/api/auth/callback`,
    })

    const userInfo = await fetchUserInfo(tokens.access_token)

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

    const response = NextResponse.redirect(
      new URL(&apos;/dashboard&apos;, process.env.AUTH_URL)
    )
    
    const session = await getIronSession&lt;SessionData&gt;(cookieStore, sessionOptions)
    session.userId = user.id
    session.whopUserId = user.whopUserId
    session.isLoggedIn = true
    await session.save()

    cookieStore.delete(&apos;oauth_code_verifier&apos;)
    cookieStore.delete(&apos;oauth_state&apos;)

    return response
  } catch (error) {
    console.error(&apos;OAuth callback error:&apos;, error)
    return NextResponse.redirect(
      new URL(&apos;/signin?error=auth_failed&apos;, process.env.AUTH_URL)
    )
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="logout-route">Logout route</h3><p>To handle logouts, you should create a file in <code>app/api/auth/logout</code> 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &apos;next/server&apos;
import { cookies } from &apos;next/headers&apos;
import { getIronSession } from &apos;iron-session&apos;
import { sessionOptions, SessionData } from &apos;@/lib/session&apos;

export async function POST() {
  const cookieStore = await cookies()
  const session = await getIronSession&lt;SessionData&gt;(cookieStore, sessionOptions)
  
  session.destroy()
  
  return NextResponse.redirect(new URL(&apos;/&apos;, process.env.AUTH_URL))
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="give-user-data-to-frontend">Give user data to frontend</h3><p>This will return the user&#x2019;s data to your frontend after they log in. You should create a file in <code>app/api/auth/me</code> 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &apos;next/server&apos;
import { cookies } from &apos;next/headers&apos;
import { getIronSession } from &apos;iron-session&apos;
import { sessionOptions, SessionData } from &apos;@/lib/session&apos;
import { prisma } from &apos;@/lib/prisma&apos;

export async function GET() {
  const cookieStore = await cookies()
  const session = await getIronSession&lt;SessionData&gt;(cookieStore, sessionOptions)

  if (!session.isLoggedIn || !session.userId) {
    return NextResponse.json({ user: null }, { status: 401 })
  }

  const user = await prisma.user.findUnique({
    where: { id: session.userId },
    select: {
      id: true,
      email: true,
      name: true,
      whopUsername: true,
      avatarUrl: true,
    },
  })

  return NextResponse.json({ user })
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-authentication-helper">Create the authentication helper</h3><p>Now, let&#x2019;s create a helper file called <code>auth.ts</code> with the content below in the <code>lib</code> folder that checks users&#x2019; cookies to see if they&#x2019;re logged in, and if they are, look up the account in your database to identify the user.</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { cookies } from &apos;next/headers&apos;
import { getIronSession } from &apos;iron-session&apos;
import { sessionOptions, SessionData, defaultSession } from &apos;@/lib/session&apos;
import { prisma } from &apos;@/lib/prisma&apos;

export async function getSession() {
  const cookieStore = await cookies()
  const session = await getIronSession&lt;SessionData&gt;(cookieStore, sessionOptions)
  
  if (!session.isLoggedIn) {
    return defaultSession
  }
  
  return session
}

export async function getCurrentUser() {
  const session = await getSession()
  
  if (!session.isLoggedIn || !session.userId) {
    return null
  }
  
  return prisma.user.findUnique({
    where: { id: session.userId },
  })
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h2 id="create-the-rate-limiting-helper">Create the rate limiting helper</h2><p>To protect your API routes from abuse, let&apos;s create a simple rate limiter. This rate limiter allows 20 requests per minute per identifier (usually the user ID or IP address). If someone exceeds the limit, they&apos;ll get a 429 &quot;Too Many Requests&quot; error.</p><p>Go to the <code>lib</code> folder and create a file called <code>ratelimit.ts</code> with the content:</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> This in-memory rate limiter works well for development and simple deployments.<br><br>For production applications with serverless functions (like Vercel), consider using Upstash Redis or a similar persistent store, since in-memory state doesn&apos;t persist across serverless function invocations.</div></div>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">ratelimit.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &apos;next/server&apos;

const rateLimit = new Map&lt;string, { count: number; lastReset: number }&gt;()

const WINDOW_MS = 60 * 1000 // 1 minute
const MAX_REQUESTS = 20 // 20 requests per minute

export function checkRateLimit(identifier: string) {
  const now = Date.now()
  const record = rateLimit.get(identifier)

  if (!record || now - record.lastReset &gt; WINDOW_MS) {
    rateLimit.set(identifier, { count: 1, lastReset: now })
    return { success: true, error: null }
  }

  if (record.count &gt;= MAX_REQUESTS) {
    return {
      success: false,
      error: NextResponse.json(
        { error: &apos;Too many requests. Please try again later.&apos; },
        { status: 429 }
      ),
    }
  }

  record.count++
  return { success: true, error: null }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="add-protection-to-your-authentication">Add protection to your authentication</h3><p>Your project should block access to private pages and redirect users to the sign in page if they try to access them. You can do this by creating the <code>middleware.ts</code> file in your project root with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">middleware.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &apos;next/server&apos;
import type { NextRequest } from &apos;next/server&apos;
import { getIronSession } from &apos;iron-session&apos;
import { sessionOptions, SessionData } from &apos;@/lib/session&apos;

export async function middleware(request: NextRequest) {
  const response = NextResponse.next()
  
  const session = await getIronSession&lt;SessionData&gt;(
    request.cookies, as any,
    sessionOptions
  )

  const isProtected = 
    request.nextUrl.pathname.startsWith(&apos;/dashboard&apos;) ||
    request.nextUrl.pathname.startsWith(&apos;/creator&apos;)

  if (isProtected &amp;&amp; !session.isLoggedIn) {
    return NextResponse.redirect(new URL(&apos;/signin&apos;, request.url))
  }

  return response
}

export const config = {
  matcher: [&apos;/dashboard/:path*&apos;, &apos;/creator/:path*&apos;],
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-sign-in-page">Create the sign-in page</h3><p>Lastly, let&#x2019;s create the sign in page with a file called <code>page.tsx</code> inside the <code>app/signin</code> folder 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import Link from &apos;next/link&apos;
import { useSearchParams } from &apos;next/navigation&apos;
import { Suspense } from &apos;react&apos;

function SignInContent() {
  const searchParams = useSearchParams()
  const error = searchParams.get(&apos;error&apos;)

  return (
    &lt;main className=&quot;min-h-screen flex items-center justify-center p-8&quot;&gt;
      &lt;div className=&quot;w-full max-w-md space-y-6&quot;&gt;
        &lt;div className=&quot;text-center&quot;&gt;
          &lt;h1 className=&quot;text-2xl font-bold text-gray-900&quot;&gt;Welcome back&lt;/h1&gt;
          &lt;p className=&quot;text-gray-600 mt-2&quot;&gt;Sign in to access your account&lt;/p&gt;
        &lt;/div&gt;

        {error &amp;&amp; (
          &lt;div className=&quot;p-3 bg-red-100 border border-red-300 rounded-lg text-red-700 text-sm&quot;&gt;
            Authentication failed. Please try again.
          &lt;/div&gt;
        )}

        &lt;a
          href=&quot;/api/auth/login&quot;
          className=&quot;flex items-center justify-center gap-2 w-full p-3 bg-green-500 text-white rounded-lg hover:bg-green-600 transition&quot;
        &gt;
          Sign in with Whop
        &lt;/a&gt;

        &lt;p className=&quot;text-center text-sm text-gray-500&quot;&gt;
          Don&apos;t have a Whop account?{&apos; &apos;}
          &lt;Link href=&quot;https://whop.com&quot; className=&quot;text-green-500 hover:underline&quot; target=&quot;_blank&quot;&gt;
            Create one for free
          &lt;/Link&gt;
        &lt;/p&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  )
}

export default function SignInPage() {
  return (
    &lt;Suspense fallback={
      &lt;main className=&quot;min-h-screen flex items-center justify-center p-8&quot;&gt;
        &lt;div className=&quot;w-full max-w-md space-y-6&quot;&gt;
          &lt;div className=&quot;text-center&quot;&gt;
            &lt;h1 className=&quot;text-2xl font-bold text-gray-900&quot;&gt;Welcome back&lt;/h1&gt;
            &lt;p className=&quot;text-gray-600 mt-2&quot;&gt;Sign in to access your account&lt;/p&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/main&gt;
    }&gt;
      &lt;SignInContent /&gt;
    &lt;/Suspense&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="update-your-environment-variables">Update your environment variables</h3><p>Now, for your app to properly authenticate both you and your customers, you need to update your environment variables and add the <code>WHOP_APP_ID</code> value to it:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">WHOP_APP_ID=&quot;app_xxxxxxxxxxxxx&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>You&#x2019;ll get your Whop app ID in the next step, Whop SDK setup.</p><h2 id="step-4-whop-sdk-setup">Step 4: Whop SDK setup</h2><p>The Whop SDK handles your user sign ups, payments, platform fees, payouts, and vendor accounts. In this step, you&#x2019;ll create a Whop app, configure your OAuth, and connect your whop to the project.</p><h3 id="create-a-whop-sandbox-account-and-get-your-company-id">Create a Whop sandbox account and get your company ID</h3><p>In the next steps, you&apos;ll want to test your checkouts and other systems using Whop infastructure. To easily do the tests without real payment processing, you&apos;ll use Whop&apos;s sandbox playground, and you have to create an account:</p><ol><li>Go to <a href="https://sandbox.whop.com/" rel="noreferrer">Sandbox.Whop.com</a> and create an account</li><li>Create a new business using the <strong>New business</strong> button (<strong>+</strong> icon) on the left sidebar</li><li>Once you&apos;re in the business dashboard, copy your company ID (starting with <code>biz_</code>) in your URL</li></ol><figure class="kg-card kg-image-card"><a href="https://whop.com/blog/content/images/2026/01/DashboardBizId-1.webp"><img src="https://whop.com/blog/content/images/2026/01/DashboardBizId-1.webp" class="kg-image" alt="How to build a Patreon clone" loading="lazy" width="2000" height="548" srcset="https://whop.com/blog/content/images/size/w600/2026/01/DashboardBizId-1.webp 600w, https://whop.com/blog/content/images/size/w1000/2026/01/DashboardBizId-1.webp 1000w, https://whop.com/blog/content/images/size/w1600/2026/01/DashboardBizId-1.webp 1600w, https://whop.com/blog/content/images/size/w2400/2026/01/DashboardBizId-1.webp 2400w" sizes="(min-width: 720px) 720px"></a></figure><h3 id="getting-your-company-api-key">Getting your company API key</h3><p>To get your company API key, go to the Developer page of your Whop dashboard (in <a href="https://sandbox.whop.com/" rel="noreferrer">Sandbox.Whop.com</a>) and click the <strong>Create</strong> button next to the Company API Keys section. This will display a popup where you can give your API key a name and adjust its permissions.</p><p>Once you create the API key, you can copy it.</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;">Which permissions should I give to the API key?</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"><span style="white-space: pre-wrap;">company:create_child</span></li><li value="2"><span style="white-space: pre-wrap;">company:basic:read</span></li><li value="3"><span style="white-space: pre-wrap;">checkout_configuration:create</span></li><li value="4"><span style="white-space: pre-wrap;">checkout_configuration:basic:read</span></li><li value="5"><span style="white-space: pre-wrap;">plan:create</span></li><li value="6"><span style="white-space: pre-wrap;">plan:basic:read</span></li><li value="7"><span style="white-space: pre-wrap;">plan:update</span></li><li value="8"><span style="white-space: pre-wrap;">access_pass:create</span></li><li value="9"><span style="white-space: pre-wrap;">access_pass:basic:read</span></li><li value="10"><span style="white-space: pre-wrap;">access_pass:update</span></li><li value="11"><span style="white-space: pre-wrap;">payment:basic:read</span></li><li value="12"><span style="white-space: pre-wrap;">member:basic:read</span></li><li value="13"><span style="white-space: pre-wrap;">member:email:read</span></li><li value="14"><span style="white-space: pre-wrap;">webhook_receive:payments</span></li><li value="15"><span style="white-space: pre-wrap;">webhook_receive:memberships</span></li><li value="16"><span style="white-space: pre-wrap;">payout:destination:read</span></li></ul></div>
        </div><h3 id="getting-your-whop-app-id">Getting your Whop app ID</h3><p>Next, let&#x2019;s create a Whop app. To do this, go to your Whop dashboard and open the Developer page of it. There, you&#x2019;ll see a section called Apps with a <strong>Create app</strong> button next to it.</p><p>Clicking the button will display a popup, asking you to give your app a name. After you create the app, you can see the app ID in the ID field of the apps table, and it starts with <code>app_</code>. Copy and save that one as well.</p><figure class="kg-card kg-image-card"><a href="https://whop.com/blog/content/images/2026/01/DevApps.webp"><img src="https://whop.com/blog/content/images/2026/01/DevApps.webp" class="kg-image" alt="How to build a Patreon clone" loading="lazy" width="2000" height="405" srcset="https://whop.com/blog/content/images/size/w600/2026/01/DevApps.webp 600w, https://whop.com/blog/content/images/size/w1000/2026/01/DevApps.webp 1000w, https://whop.com/blog/content/images/size/w1600/2026/01/DevApps.webp 1600w, https://whop.com/blog/content/images/size/w2400/2026/01/DevApps.webp 2400w" sizes="(min-width: 720px) 720px"></a></figure><p>Now, to configure the OAuth to redirect your users back to your app once they sign in with Whop, you should:</p><ol><li>Click on the app you just created</li><li>Go to its OAuth tab and click the <strong>Create redirect URL</strong> button</li><li>Enter <code>http://localhost:3000/api/auth/callback</code> and click <strong>Create</strong></li></ol><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">The callback URL is for local development, you&#x2019;re going to change it to the production URL later.</div></div><figure class="kg-card kg-video-card kg-width-regular" data-kg-thumbnail="https://whop.com/blog/content/media/2026/01/AppOAuth_thumb.jpg" data-kg-custom-thumbnail>
            <div class="kg-video-container">
                <video src="https://whop.com/blog/content/media/2026/01/AppOAuth.mp4" poster="https://img.spacergif.org/v1/1920x1080/0a/spacer.png" width="1920" height="1080" loop autoplay muted playsinline preload="metadata" style="background: transparent url(&apos;https://whop.com/blog/content/media/2026/01/AppOAuth_thumb.jpg&apos;) 50% 50% / cover no-repeat;"></video>
                <div class="kg-video-overlay">
                    <button class="kg-video-large-play-icon" aria-label="Play video">
                        <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                            <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"/>
                        </svg>
                    </button>
                </div>
                <div class="kg-video-player-container kg-video-hide">
                    <div class="kg-video-player">
                        <button class="kg-video-play-icon" aria-label="Play video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"/>
                            </svg>
                        </button>
                        <button class="kg-video-pause-icon kg-video-hide" aria-label="Pause video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <rect x="3" y="1" width="7" height="22" rx="1.5" ry="1.5"/>
                                <rect x="14" y="1" width="7" height="22" rx="1.5" ry="1.5"/>
                            </svg>
                        </button>
                        <span class="kg-video-current-time">0:00</span>
                        <div class="kg-video-time">
                            /<span class="kg-video-duration">0:14</span>
                        </div>
                        <input type="range" class="kg-video-seek-slider" max="100" value="0">
                        <button class="kg-video-playback-rate" aria-label="Adjust playback speed">1&#xD7;</button>
                        <button class="kg-video-unmute-icon" aria-label="Unmute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <path d="M15.189 2.021a9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h1.794a.249.249 0 0 1 .221.133 9.73 9.73 0 0 0 7.924 4.85h.06a1 1 0 0 0 1-1V3.02a1 1 0 0 0-1.06-.998Z"/>
                            </svg>
                        </button>
                        <button class="kg-video-mute-icon kg-video-hide" aria-label="Mute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <path d="M16.177 4.3a.248.248 0 0 0 .073-.176v-1.1a1 1 0 0 0-1.061-1 9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h.114a.251.251 0 0 0 .177-.073ZM23.707 1.706A1 1 0 0 0 22.293.292l-22 22a1 1 0 0 0 0 1.414l.009.009a1 1 0 0 0 1.405-.009l6.63-6.631A.251.251 0 0 1 8.515 17a.245.245 0 0 1 .177.075 10.081 10.081 0 0 0 6.5 2.92 1 1 0 0 0 1.061-1V9.266a.247.247 0 0 1 .073-.176Z"/>
                            </svg>
                        </button>
                        <input type="range" class="kg-video-volume-slider" max="100" value="100">
                    </div>
                </div>
            </div>
            
        </figure><p>Now, it&#x2019;s time to update your environment table with the IDs you just got.</p><h3 id="update-your-environment-variables-1">Update your environment variables</h3><p>Your current environment variable should look like:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">DATABASE_URL=&quot;your-database-url&quot;

SESSION_SECRET=&quot;your-generated-secret-here&quot;
AUTH_URL=&quot;http://localhost:3000&quot;

WHOP_SANDBOX=&quot;true&quot;

WHOP_APP_ID=&quot;app_xxxxxxxxxxxxx&quot;
WHOP_API_KEY=&quot;apik_xxxxxxxxxxxxx&quot;
WHOP_COMPANY_ID=&quot;biz_xxxxxxxxxxxxx&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="initialize-the-whop-sdk">Initialize the Whop SDK</h3><p>The Whop SDK is how your project talks to the Whop API. Instead of initializing it every time, let&#x2019;s create a single client that can be imported easily. Go to the <code>lib</code> folder of your project 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Whop from &quot;@whop/sdk&quot;

const isSandbox = process.env.WHOP_SANDBOX === &apos;true&apos;

export const whop = new Whop({
  appID: process.env.WHOP_APP_ID,
  apiKey: process.env.WHOP_API_KEY,
  ...(isSandbox &amp;&amp; { baseURL: &quot;https://sandbox-api.whop.com/api/v1&quot; }),
})</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="test-the-authentication">Test the authentication</h3><p>Now that you&#x2019;re all done and have some test pages:</p><ol><li>Go to <code>http://localhost:3000/signin</code></li><li>Click <strong>Sign in with Whop</strong></li><li>You&apos;ll be redirected to Whop&apos;s sandbox login screen, use your <strong>sandbox account</strong> to log in</li><li>After authorizing, you&apos;ll be redirected to <code>/dashboard</code>, which will show a 404 (expected)</li><li>Open Prisma Studio and check the User table</li></ol><h2 id="step-5-creator-registration-flow">Step 5: Creator registration flow</h2><p>In this step, you&#x2019;ll let your users sign up to your project as creators. When a user signs up as a creator, they&#x2019;ll automatically get a connected account to your Whop company so they can process payments and payouts.</p><h3 id="creating-the-creator-registration-route">Creating the creator registration route</h3><p>Let&#x2019;s go to the <code>app/api folder</code> and create two new folders, <code>creator/register</code>. In the register folder, create a file called <code>route.ts</code> with the contents:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &apos;next/server&apos;
import { z } from &apos;zod&apos;
import { requireAuth } from &apos;@/lib/auth&apos;
import { checkRateLimit } from &apos;@/lib/ratelimit&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import { whop } from &apos;@/lib/whop&apos;

const registerSchema = z.object({
  username: z
    .string()
    .min(1, &apos;Username is required&apos;)
    .max(30, &apos;Username must be 30 characters or less&apos;)
    .regex(/^[a-z0-9_]+$/, &apos;Username can only contain lowercase letters, numbers, and underscores&apos;),
  displayName: z
    .string()
    .min(1, &apos;Display name is required&apos;)
    .max(50, &apos;Display name must be 50 characters or less&apos;),
  bio: z
    .string()
    .max(500, &apos;Bio must be 500 characters or less&apos;)
    .optional(),
})

export async function POST(request: NextRequest) {
  const { user, error: authError } = await requireAuth()
  if (authError) return authError

  const { error: rateLimitError } = checkRateLimit(user.id)
  if (rateLimitError) return rateLimitError

  const existingCreator = await prisma.creator.findUnique({
    where: { userId: user.id },
  })

  if (existingCreator) {
    return NextResponse.json(
      { error: &apos;You are already registered as a creator&apos; },
      { status: 400 }
    )
  }

  const body = await request.json()
  const parsed = registerSchema.safeParse(body)

  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0].message },
      { status: 400 }
    )
  }

  const { username, displayName, bio } = parsed.data

  const usernameTaken = await prisma.creator.findUnique({
    where: { username },
  })

  if (usernameTaken) {
    return NextResponse.json(
      { error: &apos;Username is already taken&apos; },
      { status: 400 }
    )
  }

  try {

    const whopCompany = await whop.companies.create({
      email: user.email,
      parent_company_id: process.env.WHOP_COMPANY_ID!,
      title: displayName,
      metadata: {
        platform_user_id: user.id,
        platform_username: username,
      },
    })

    const creator = await prisma.creator.create({
      data: {
        userId: user.id,
        username,
        displayName,
        bio: bio || null,
        whopCompanyId: whopCompany.id,
      },
    })

    return NextResponse.json({ creator })
  } catch (error) {
    console.error(&apos;Creator registration error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to register as creator&apos; },
      { status: 500 }
    )
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>This does three things: confirms the form data, creates a connected Whop account, and saves the creator to your database.</p><h3 id="creating-the-registration-form">Creating the registration form</h3><p>Create a file called <code>page.tsx</code> in <code>app/creator/register</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &apos;next/server&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import { whop } from &apos;@/lib/whop&apos;

export async function POST(request: NextRequest) {
  const user = await getCurrentUser()
  
  if (!user) {
    return NextResponse.json(
      { error: &apos;Not authenticated&apos; },
      { status: 401 }
    )
  }

  const existingCreator = await prisma.creator.findUnique({
    where: { userId: user.id },
  })

  if (existingCreator) {
    return NextResponse.json(
      { error: &apos;You are already registered as a creator&apos; },
      { status: 400 }
    )
  }

  const body = await request.json()
  const { username, displayName, bio } = body

  if (!username || !/^[a-z0-9_]+$/.test(username)) {
    return NextResponse.json(
      { error: &apos;Username can only contain lowercase letters, numbers, and underscores&apos; },
      { status: 400 }
    )
  }

  const usernameTaken = await prisma.creator.findUnique({
    where: { username },
  })

  if (usernameTaken) {
    return NextResponse.json(
      { error: &apos;Username is already taken&apos; },
      { status: 400 }
    )
  }

  try {
    const whopCompany = await whop.companies.create({
      email: user.email,
      parent_company_id: process.env.WHOP_COMPANY_ID!,
      title: displayName,
      metadata: {
        platform_user_id: user.id,
        platform_username: username,
      },
    })

    const creator = await prisma.creator.create({
      data: {
        userId: user.id,
        username,
        displayName,
        bio: bio || null,
        whopCompanyId: whopCompany.id,
      },
    })

    return NextResponse.json({ creator })
  } catch (error) {
    console.error(&apos;Creator registration error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to register as creator&apos; },
      { status: 500 }
    )
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">Whop requires HTTPS URLs for returns and since localhost uses HTTP, the code above uses a placeholder URL for local development.<br><br>After completing the KYC verification on Whop, you should <b><strong style="white-space: pre-wrap;">manually</strong></b> go back to <code spellcheck="false" style="white-space: pre-wrap;">http://localhost:3000/creator/dashboard</code>.</div></div><h3 id="create-the-onboarding-api-route">Create the onboarding API route</h3><p>Creators on your platform have to complete KYC (identity verification) before they can start receiving payouts. To allow this, create a file called <code>route.ts</code> in <code>app/api/creator/onboarding</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &apos;next/server&apos;
import { requireCreator } from &apos;@/lib/auth&apos;
import { whop } from &apos;@/lib/whop&apos;

export async function POST() {
  const { creator, error } = await requireCreator()
  if (error) return error

  if (!creator.whopCompanyId) {
    return NextResponse.json(
      { error: &apos;Creator account not set up for payments&apos; },
      { status: 400 }
    )
  }

  try {
    const baseUrl = process.env.AUTH_URL || &apos;http://localhost:3000&apos;
    const useHttps = baseUrl.startsWith(&apos;https://&apos;)

    const accountLink = await whop.accountLinks.create({
      company_id: creator.whopCompanyId,
      use_case: &apos;account_onboarding&apos;,
      return_url: useHttps
        ? `${baseUrl}/creator/dashboard?onboarding=complete`
        : &apos;https://example.com/onboarding-complete&apos;,
      refresh_url: useHttps
        ? `${baseUrl}/creator/dashboard?onboarding=refresh`
        : &apos;https://example.com/onboarding-refresh&apos;,
    })

    return NextResponse.json({ url: accountLink.url })
  } catch (error) {
    console.error(&apos;Onboarding link error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to generate onboarding link&apos; },
      { status: 500 }
    )
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-a-basic-creator-dashboard">Create a basic creator dashboard</h3><p>Now, you need to create a special dashboard for creators so that you can direct them to complete the payout setup, track stats like subscribers, manage their subscription tiers, and create subscriber-only content.</p><p>To create this dashboard page, create a file called <code>page.tsx</code> in <code>app/creator/dashboard</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &apos;next/navigation&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import Link from &apos;next/link&apos;
import OnboardingButton from &apos;./OnboardingButton&apos;

export default async function CreatorDashboard() {
  const user = await getCurrentUser()
  
  if (!user) {
    redirect(&apos;/signin&apos;)
  }

  const creator = await prisma.creator.findUnique({
    where: { userId: user.id },
    include: {
      tiers: true,
      _count: {
        select: { subscriptions: true },
      },
    },
  })

  if (!creator) {
    redirect(&apos;/creator/register&apos;)
  }

  return (
    &lt;main className=&quot;min-h-screen p-8 max-w-4xl mx-auto&quot;&gt;
      &lt;div className=&quot;flex justify-between items-start mb-8&quot;&gt;
        &lt;div&gt;
          &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;Creator Dashboard&lt;/h1&gt;
          &lt;p className=&quot;text-gray-600&quot;&gt;@{creator.username}&lt;/p&gt;
        &lt;/div&gt;
        &lt;Link
          href={`/creator/${creator.username}`}
          className=&quot;text-sm text-blue-600 hover:underline&quot;
        &gt;
          View public profile &#x2192;
        &lt;/Link&gt;
      &lt;/div&gt;

      {!creator.whopOnboarded &amp;&amp; (
        &lt;div className=&quot;p-4 mb-6 bg-yellow-50 border border-yellow-200 rounded-lg&quot;&gt;
          &lt;h3 className=&quot;font-medium mb-1&quot;&gt;Complete your account setup&lt;/h3&gt;
          &lt;p className=&quot;text-sm text-gray-600 mb-3&quot;&gt;
            Verify your identity to start receiving payouts from your subscribers.
          &lt;/p&gt;
          &lt;OnboardingButton /&gt;
        &lt;/div&gt;
      )}

      &lt;div className=&quot;grid grid-cols-1 md:grid-cols-3 gap-4 mb-8&quot;&gt;
        &lt;div className=&quot;p-4 bg-gray-50 rounded-lg&quot;&gt;
          &lt;p className=&quot;text-sm text-gray-600&quot;&gt;Subscribers&lt;/p&gt;
          &lt;p className=&quot;text-2xl font-bold&quot;&gt;{creator._count.subscriptions}&lt;/p&gt;
        &lt;/div&gt;
        &lt;div className=&quot;p-4 bg-gray-50 rounded-lg&quot;&gt;
          &lt;p className=&quot;text-sm text-gray-600&quot;&gt;Tiers&lt;/p&gt;
          &lt;p className=&quot;text-2xl font-bold&quot;&gt;{creator.tiers.length}&lt;/p&gt;
        &lt;/div&gt;
        &lt;div className=&quot;p-4 bg-gray-50 rounded-lg&quot;&gt;
          &lt;p className=&quot;text-sm text-gray-600&quot;&gt;Status&lt;/p&gt;
          &lt;p className=&quot;text-2xl font-bold&quot;&gt;
            {creator.whopOnboarded ? &apos;&#x2713; Active&apos; : &apos;Setup needed&apos;}
          &lt;/p&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div className=&quot;space-y-4&quot;&gt;
        &lt;Link
          href=&quot;/creator/tiers&quot;
          className=&quot;block p-4 border rounded-lg hover:bg-gray-50 transition&quot;
        &gt;
          &lt;h3 className=&quot;font-medium&quot;&gt;Manage tiers&lt;/h3&gt;
          &lt;p className=&quot;text-sm text-gray-600&quot;&gt;Create and edit subscription tiers&lt;/p&gt;
        &lt;/Link&gt;

        &lt;Link
          href=&quot;/creator/posts&quot;
          className=&quot;block p-4 border rounded-lg hover:bg-gray-50 transition&quot;
        &gt;
          &lt;h3 className=&quot;font-medium&quot;&gt;Create content&lt;/h3&gt;
          &lt;p className=&quot;text-sm text-gray-600&quot;&gt;Post exclusive content for your subscribers&lt;/p&gt;
        &lt;/Link&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="creating-the-onboarding-button">Creating the onboarding button</h3><p>Your creators will have to complete their onboarding with Whop&#x2019;s verification page before they can start getting payouts. To do this, create a file called <code>OnboardingButton.tsx</code> in <code>app/creator/dashboard</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">OnboardingButton.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import { useState } from &apos;react&apos;

export default function OnboardingButton() {
  const [loading, setLoading] = useState(false)

  async function handleClick() {
    setLoading(true)

    try {
      const response = await fetch(&apos;/api/creator/onboarding&apos;, {
        method: &apos;POST&apos;,
      })

      const data = await response.json()

      if (data.url) {
        window.location.href = data.url
      }
    } catch (error) {
      console.error(&apos;Failed to start onboarding:&apos;, error)
    } finally {
      setLoading(false)
    }
  }

  return (
    &lt;button
      onClick={handleClick}
      disabled={loading}
      className=&quot;px-4 py-2 bg-black text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50 transition&quot;
    &gt;
      {loading ? &apos;Loading...&apos; : &apos;Complete verification&apos;}
    &lt;/button&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="handling-onboarding-completion">Handling onboarding completion</h3><p>When creators finish KYC on Whop&apos;s portal, they get redirected back to <code>/creator/dashboard?onboarding=complete</code>. You need to detect this query parameter and update the database to mark them as onboarded.</p><p>First, create the API route that updates the database. Go to <code>app/api/creator/onboarding/complete</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &apos;next/server&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import { prisma } from &apos;@/lib/prisma&apos;

export async function POST() {
  const user = await getCurrentUser()

  if (!user) {
    return NextResponse.json(
      { error: &apos;Not authenticated&apos; },
      { status: 401 }
    )
  }

  const creator = await prisma.creator.findUnique({
    where: { userId: user.id },
  })

  if (!creator) {
    return NextResponse.json(
      { error: &apos;Creator account not found&apos; },
      { status: 404 }
    )
  }

  if (creator.whopOnboarded) {
    return NextResponse.json({ success: true, alreadyCompleted: true })
  }

  try {
    await prisma.creator.update({
      where: { id: creator.id },
      data: { whopOnboarded: true },
    })

    return NextResponse.json({ success: true })
  } catch (error) {
    console.error(&apos;Onboarding completion error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to complete onboarding&apos; },
      { status: 500 }
    )
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Next, create a client component that detects the query parameter and calls this API. Create a file called <code>OnboardingComplete.tsx</code> in <code>app/creator/dashboard</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">OnboardingComplete.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import { useSearchParams, useRouter } from &apos;next/navigation&apos;
import { useEffect, useState } from &apos;react&apos;

export default function OnboardingComplete() {
  const searchParams = useSearchParams()
  const router = useRouter()
  const [status, setStatus] = useState&lt;&apos;idle&apos; | &apos;completing&apos; | &apos;done&apos; | &apos;error&apos;&gt;(&apos;idle&apos;)

  useEffect(() =&gt; {
    const onboardingParam = searchParams.get(&apos;onboarding&apos;)

    if (onboardingParam === &apos;complete&apos; &amp;&amp; status === &apos;idle&apos;) {
      setStatus(&apos;completing&apos;)

      fetch(&apos;/api/creator/onboarding/complete&apos;, {
        method: &apos;POST&apos;,
      })
        .then((res) =&gt; res.json())
        .then((data) =&gt; {
          if (data.success) {
            setStatus(&apos;done&apos;)
            router.replace(&apos;/creator/dashboard&apos;)
            router.refresh()
          } else {
            setStatus(&apos;error&apos;)
          }
        })
        .catch(() =&gt; {
          setStatus(&apos;error&apos;)
        })
    }
  }, [searchParams, router, status])

  if (status === &apos;completing&apos;) {
    return (
      &lt;div className=&quot;p-4 mb-6 bg-blue-50 border border-blue-200 rounded-lg&quot;&gt;
        &lt;p className=&quot;text-sm text-blue-800&quot;&gt;Completing your account setup...&lt;/p&gt;
      &lt;/div&gt;
    )
  }

  if (status === &apos;done&apos;) {
    return (
      &lt;div className=&quot;p-4 mb-6 bg-green-50 border border-green-200 rounded-lg&quot;&gt;
        &lt;p className=&quot;text-sm text-green-800&quot;&gt;Account setup complete! You can now receive payouts.&lt;/p&gt;
      &lt;/div&gt;
    )
  }

  if (status === &apos;error&apos;) {
    return (
      &lt;div className=&quot;p-4 mb-6 bg-red-50 border border-red-200 rounded-lg&quot;&gt;
        &lt;p className=&quot;text-sm text-red-800&quot;&gt;Failed to complete setup. Please try again.&lt;/p&gt;
      &lt;/div&gt;
    )
  }

  return null
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Finally, update the creator dashboard page to include this component. In <code>app/creator/dashboard/page.tsx</code>, add these imports 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { Suspense } from &apos;react&apos;
import OnboardingComplete from &apos;./OnboardingComplete&apos;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then add the component inside <code>&lt;main&gt;</code>, right after the opening tag and before the flex container:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&lt;Suspense fallback={null}&gt;
  &lt;OnboardingComplete /&gt;
&lt;/Suspense&gt;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-main-user-dashboard">Create the main user dashboard</h3><p>After signing in, users gets redirects to <code>/dashboard</code>, which currently shows a 404. Now, you should create a file called <code>page.tsx</code> in <code>app/dashboard</code> with the code below:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &apos;next/navigation&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import Link from &apos;next/link&apos;

export default async function Dashboard() {
  const user = await getCurrentUser()
  
  if (!user) {
    redirect(&apos;/signin&apos;)
  }

  const creator = await prisma.creator.findUnique({
    where: { userId: user.id },
  })

  return (
    &lt;main className=&quot;min-h-screen p-8 max-w-4xl mx-auto&quot;&gt;
      &lt;h1 className=&quot;text-2xl font-bold mb-2&quot;&gt;Dashboard&lt;/h1&gt;
      &lt;p className=&quot;text-gray-600 mb-8&quot;&gt;Welcome back, {user.name || user.email}&lt;/p&gt;

      &lt;div className=&quot;space-y-4&quot;&gt;
        {creator ? (
          &lt;Link
            href=&quot;/creator/dashboard&quot;
            className=&quot;block p-4 border rounded-lg hover:bg-gray-50 transition&quot;
          &gt;
            &lt;h3 className=&quot;font-medium&quot;&gt;Creator Dashboard&lt;/h3&gt;
            &lt;p className=&quot;text-sm text-gray-600&quot;&gt;Manage your tiers and content&lt;/p&gt;
          &lt;/Link&gt;
        ) : (
          &lt;Link
            href=&quot;/creator/register&quot;
            className=&quot;block p-4 border rounded-lg hover:bg-gray-50 transition&quot;
          &gt;
            &lt;h3 className=&quot;font-medium&quot;&gt;Become a Creator&lt;/h3&gt;
            &lt;p className=&quot;text-sm text-gray-600&quot;&gt;Start accepting subscriptions from your fans&lt;/p&gt;
          &lt;/Link&gt;
        )}

        &lt;Link
          href=&quot;/subscriptions&quot;
          className=&quot;block p-4 border rounded-lg hover:bg-gray-50 transition&quot;
        &gt;
          &lt;h3 className=&quot;font-medium&quot;&gt;My Subscriptions&lt;/h3&gt;
          &lt;p className=&quot;text-sm text-gray-600&quot;&gt;Manage creators you&apos;re subscribed to&lt;/p&gt;
        &lt;/Link&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="test-the-creator-registration">Test the creator registration</h3><p>The creator registration flow is complete. Now, it&#x2019;s time to test it:</p><ol><li>Sign in at <code>http://localhost:3000/signin</code></li><li>Go to <code>http://localhost:3000/creator/register</code></li><li>Fill out the form and submit it</li><li>You&#x2019;ll be redirected to the creator dashboard (404 expected)</li><li>Open Prisma Studio and look for the creator table and the <code>whopCompanyId</code> with a value starting with <code>biz_</code>.</li></ol><h2 id="step-6-subscription-tier-management">Step 6: Subscription tier management</h2><p>In the creator dashboard you built the previous step, you see two buttons: <strong>Manage tiers</strong> and <strong>Create content</strong>. In this step, you&#x2019;re going to build the subscription tier management for your creators.</p><p>Each tier will have a name, description, and monthly price. When a creator signs up to your project and completes their verification, they can start creating tiers, which will create a Whop plan using the Whop infrastructure. Then, the customers can go through Whop&#x2019;s checkout to pay for the tier membership.</p><h3 id="update-the-database-schema">Update the database schema</h3><p>First, you need to update the Creator model in your database schema to include the whopProductId and a new model, Tier. To do this, go to the <code>prisma</code> folder and update the <code>schema.prisma</code> file with this (add the new field in the Creator model and a new Tier model below it):</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-html">model Creator {
  id              String   @id @default(cuid())
  userId          String   @unique
  user            User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  
  whopCompanyId   String?  @unique
  whopProductId   String?  @unique
  whopOnboarded   Boolean  @default(false)
  
  username        String   @unique
  displayName     String
  bio             String?
  
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  tiers           Tier[]
  posts           Post[]
  subscriptions   Subscription[]
}

model Tier {
  id            String   @id @default(cuid())
  creatorId     String
  creator       Creator  @relation(fields: [creatorId], references: [id], onDelete: Cascade)
  
  name          String
  description   String?
  priceInCents  Int
  whopPlanId    String?  @unique
  
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then, run the database migration command below to apply the changes to your database:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx prisma migrate dev --name add_tiers_and_whop_product_id</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-tier-api-routes">Create tier API routes</h3><p>Now, let&#x2019;s create the API route for the tiers so that logged in creators can see their tiers and create new ones. When a creator creates a tier, the Whop API creates a Whop product, a Whop plan, and saves their details to the database.</p><p>To create the API route, go to <code>app/api/creator/tiers</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &apos;next/server&apos;
import { z } from &apos;zod&apos;
import { requireCreator } from &apos;@/lib/auth&apos;
import { checkRateLimit } from &apos;@/lib/ratelimit&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import { whop } from &apos;@/lib/whop&apos;

const tierSchema = z.object({
  name: z
    .string()
    .min(1, &apos;Tier name is required&apos;)
    .max(50, &apos;Tier name must be 50 characters or less&apos;),
  description: z
    .string()
    .max(500, &apos;Description must be 500 characters or less&apos;)
    .optional(),
  priceInCents: z
    .number()
    .int(&apos;Price must be a whole number&apos;)
    .min(100, &apos;Price must be at least $1.00&apos;),
})

export async function GET() {
  const { creator, error } = await requireCreator()
  if (error) return error

  const creatorWithTiers = await prisma.creator.findUnique({
    where: { id: creator.id },
    include: { tiers: true },
  })

  return NextResponse.json({ tiers: creatorWithTiers?.tiers || [] })
}

export async function POST(request: NextRequest) {
  const { user, creator, error: authError } = await requireCreator()
  if (authError) return authError

  const { error: rateLimitError } = checkRateLimit(user!.id)
  if (rateLimitError) return rateLimitError

  if (!creator.whopCompanyId) {
    return NextResponse.json(
      { error: &apos;Creator account not set up for payments&apos; },
      { status: 400 }
    )
  }

  const body = await request.json()
  const parsed = tierSchema.safeParse(body)

  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0].message },
      { status: 400 }
    )
  }

  const { name, description, priceInCents } = parsed.data

  try {

    let whopProductId = creator.whopProductId

    if (!whopProductId) {
      const product = await whop.products.create({
        company_id: creator.whopCompanyId,
        title: `${creator.displayName}&apos;s Membership`,
        visibility: &apos;visible&apos;,
      })
      whopProductId = product.id

      await prisma.creator.update({
        where: { id: creator.id },
        data: { whopProductId },
      })
    }

    const priceInDollars = priceInCents / 100

    const plan = await whop.plans.create({
      company_id: creator.whopCompanyId,
      product_id: whopProductId,
      plan_type: &apos;renewal&apos;,
      initial_price: 0,
      renewal_price: priceInDollars,
      billing_period: 30,
    } as Parameters&lt;typeof whop.plans.create&gt;[0])

    const tier = await prisma.tier.create({
      data: {
        creatorId: creator.id,
        name,
        description: description || null,
        priceInCents,
        whopPlanId: plan.id,
      },
    })

    return NextResponse.json({ tier })
  } catch (error) {
    console.error(&apos;Tier creation error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to create tier&apos; },
      { status: 500 }
    )
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-tier-update-and-delete-route">Create tier update and delete route</h3><p>After a creator creates a tier, they might want to update them or delete them entirely - to allow this, let&#x2019;s create a route. Go to <code>app/api/creator/tiers/[tierId]</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &apos;next/server&apos;
import { z } from &apos;zod&apos;
import { requireCreator } from &apos;@/lib/auth&apos;
import { checkRateLimit } from &apos;@/lib/ratelimit&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import { whop } from &apos;@/lib/whop&apos;

const tierSchema = z.object({
  name: z
    .string()
    .min(1, &apos;Tier name is required&apos;)
    .max(50, &apos;Tier name must be 50 characters or less&apos;),
  description: z
    .string()
    .max(500, &apos;Description must be 500 characters or less&apos;)
    .optional(),
  priceInCents: z
    .number()
    .int(&apos;Price must be a whole number&apos;)
    .min(100, &apos;Price must be at least $1.00&apos;),
})

export async function PUT(
  request: NextRequest,
  { params }: { params: Promise&lt;{ tierId: string }&gt; }
) {
  const { user, creator, error: authError } = await requireCreator()
  if (authError) return authError

  const { error: rateLimitError } = checkRateLimit(user!.id)
  if (rateLimitError) return rateLimitError

  const { tierId } = await params

  const tier = await prisma.tier.findUnique({
    where: { id: tierId },
  })

  if (!tier || tier.creatorId !== creator.id) {
    return NextResponse.json({ error: &apos;Tier not found&apos; }, { status: 404 })
  }

  const body = await request.json()
  const parsed = tierSchema.safeParse(body)

  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0].message },
      { status: 400 }
    )
  }

  const { name, description, priceInCents } = parsed.data

  try {

    if (tier.whopPlanId) {
      const priceInDollars = priceInCents / 100
      await whop.plans.update(tier.whopPlanId, {
        initial_price: priceInDollars,
        renewal_price: priceInDollars,
        internal_notes: `Tier: ${name}`,
      })
    }

    const updatedTier = await prisma.tier.update({
      where: { id: tierId },
      data: {
        name,
        description: description || null,
        priceInCents,
      },
    })

    return NextResponse.json({ tier: updatedTier })
  } catch (error) {
    console.error(&apos;Tier update error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to update tier&apos; },
      { status: 500 }
    )
  }
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise&lt;{ tierId: string }&gt; }
) {
  const { user, creator, error: authError } = await requireCreator()
  if (authError) return authError

  const { error: rateLimitError } = checkRateLimit(user!.id)
  if (rateLimitError) return rateLimitError

  const { tierId } = await params

  const tier = await prisma.tier.findUnique({
    where: { id: tierId },
  })

  if (!tier || tier.creatorId !== creator.id) {
    return NextResponse.json({ error: &apos;Tier not found&apos; }, { status: 404 })
  }

  try {

    if (tier.whopPlanId) {
      await whop.plans.delete(tier.whopPlanId)
    }

    await prisma.tier.delete({
      where: { id: tierId },
    })

    return NextResponse.json({ success: true })
  } catch (error) {
    console.error(&apos;Tier deletion error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to delete tier&apos; },
      { status: 500 }
    )
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-tier-management-page">Create the tier management page</h3><p>For your creators to use the routes you created, you need to have a tier management page. Let&#x2019;s go to the <code>app/creator/tiers</code> folder 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &apos;next/navigation&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import Link from &apos;next/link&apos;
import TierForm from &apos;./TierForm&apos;
import TierCard from &apos;./TierCard&apos;

export default async function TiersPage() {
  const user = await getCurrentUser()
  
  if (!user) {
    redirect(&apos;/signin&apos;)
  }

  const creator = await prisma.creator.findUnique({
    where: { userId: user.id },
    include: { tiers: { orderBy: { priceInCents: &apos;asc&apos; } } },
  })

  if (!creator) {
    redirect(&apos;/creator/register&apos;)
  }

  return (
    &lt;main className=&quot;min-h-screen p-8 max-w-4xl mx-auto&quot;&gt;
      &lt;div className=&quot;flex justify-between items-center mb-8&quot;&gt;
        &lt;div&gt;
          &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;Subscription Tiers&lt;/h1&gt;
          &lt;p className=&quot;text-gray-600&quot;&gt;Create and manage your subscription options&lt;/p&gt;
        &lt;/div&gt;
        &lt;Link
          href=&quot;/creator/dashboard&quot;
          className=&quot;text-sm text-blue-600 hover:underline&quot;
        &gt;
          &#x2190; Back to dashboard
        &lt;/Link&gt;
      &lt;/div&gt;

      &lt;div className=&quot;mb-8&quot;&gt;
        &lt;h2 className=&quot;text-lg font-medium mb-4&quot;&gt;Create a new tier&lt;/h2&gt;
        &lt;TierForm /&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;h2 className=&quot;text-lg font-medium mb-4&quot;&gt;Your tiers&lt;/h2&gt;
        {creator.tiers.length === 0 ? (
          &lt;p className=&quot;text-gray-500&quot;&gt;No tiers yet. Create your first one above.&lt;/p&gt;
        ) : (
          &lt;div className=&quot;space-y-4&quot;&gt;
            {creator.tiers.map((tier) =&gt; (
              &lt;TierCard key={tier.id} tier={tier} /&gt;
            ))}
          &lt;/div&gt;
        )}
      &lt;/div&gt;
    &lt;/main&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-tier-form">Create the tier form</h3><p>When creators are creating or editing tiers, you need to show them a tier form with fields like tier name, description, and price. Let&#x2019;s do this by going into the <code>/app/creator/tiers</code> folder and creating a file called <code>TierForm.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">TierForm.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import { useState } from &apos;react&apos;
import { useRouter } from &apos;next/navigation&apos;

interface TierFormProps {
  tier?: {
    id: string
    name: string
    description: string | null
    priceInCents: number
  }
  onCancel?: () =&gt; void
}

export default function TierForm({ tier, onCancel }: TierFormProps) {
  const router = useRouter()
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(&apos;&apos;)

  const isEditing = !!tier

  async function handleSubmit(e: React.FormEvent&lt;HTMLFormElement&gt;) {
    e.preventDefault()
    const form = e.currentTarget
    setLoading(true)
    setError(&apos;&apos;)

    const formData = new FormData(form)
    const priceValue = formData.get(&apos;price&apos;) as string
    const priceInCents = Math.round(parseFloat(priceValue) * 100)

    const data = {
      name: formData.get(&apos;name&apos;),
      description: formData.get(&apos;description&apos;),
      priceInCents,
    }

    try {
      const url = isEditing 
        ? `/api/creator/tiers/${tier.id}` 
        : &apos;/api/creator/tiers&apos;
      
      const response = await fetch(url, {
        method: isEditing ? &apos;PUT&apos; : &apos;POST&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify(data),
      })

      const result = await response.json()

      if (!response.ok) {
        setError(result.error || &apos;Failed to save tier&apos;)
        return
      }

      if (isEditing &amp;&amp; onCancel) {
        onCancel()
      } else {
        form.reset()
      }
      
      router.refresh()
    } catch (err) {
      setError(&apos;Something went wrong. Please try again.&apos;)
    } finally {
      setLoading(false)
    }
  }

  return (
    &lt;form onSubmit={handleSubmit} className=&quot;p-4 border rounded-lg space-y-4&quot;&gt;
      {error &amp;&amp; (
        &lt;div className=&quot;p-3 bg-red-100 border border-red-300 rounded text-red-700 text-sm&quot;&gt;
          {error}
        &lt;/div&gt;
      )}

      &lt;div&gt;
        &lt;label htmlFor=&quot;name&quot; className=&quot;block text-sm font-medium mb-1&quot;&gt;
          Tier name
        &lt;/label&gt;
        &lt;input
          type=&quot;text&quot;
          id=&quot;name&quot;
          name=&quot;name&quot;
          required
          defaultValue={tier?.name || &apos;&apos;}
          placeholder=&quot;e.g., Basic, Premium, VIP&quot;
          className=&quot;w-full p-2 border rounded&quot;
        /&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;label htmlFor=&quot;description&quot; className=&quot;block text-sm font-medium mb-1&quot;&gt;
          Description (optional)
        &lt;/label&gt;
        &lt;textarea
          id=&quot;description&quot;
          name=&quot;description&quot;
          rows={2}
          defaultValue={tier?.description || &apos;&apos;}
          placeholder=&quot;What do subscribers get at this tier?&quot;
          className=&quot;w-full p-2 border rounded&quot;
        /&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;label htmlFor=&quot;price&quot; className=&quot;block text-sm font-medium mb-1&quot;&gt;
          Monthly price (USD)
        &lt;/label&gt;
        &lt;div className=&quot;flex items-center&quot;&gt;
          &lt;span className=&quot;text-gray-500 mr-1&quot;&gt;$&lt;/span&gt;
          &lt;input
            type=&quot;number&quot;
            id=&quot;price&quot;
            name=&quot;price&quot;
            required
            min=&quot;1&quot;
            step=&quot;0.01&quot;
            defaultValue={tier ? (tier.priceInCents / 100).toFixed(2) : &apos;&apos;}
            placeholder=&quot;5.00&quot;
            className=&quot;w-32 p-2 border rounded&quot;
          /&gt;
          &lt;span className=&quot;text-gray-500 ml-2&quot;&gt;/ month&lt;/span&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div className=&quot;flex gap-2&quot;&gt;
        &lt;button
          type=&quot;submit&quot;
          disabled={loading}
          className=&quot;px-4 py-2 bg-black text-white rounded hover:bg-gray-800 disabled:opacity-50 transition&quot;
        &gt;
          {loading ? &apos;Saving...&apos; : isEditing ? &apos;Update tier&apos; : &apos;Create tier&apos;}
        &lt;/button&gt;
        {isEditing &amp;&amp; onCancel &amp;&amp; (
          &lt;button
            type=&quot;button&quot;
            onClick={onCancel}
            className=&quot;px-4 py-2 border rounded hover:bg-gray-50 transition&quot;
          &gt;
            Cancel
          &lt;/button&gt;
        )}
      &lt;/div&gt;
    &lt;/form&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-tier-card">Create the tier card</h3><p>And finally to display the tiers with clear buttons to the creator, go to <code>app/creator/tiers</code> and create a file called <code>TierCard.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">TierCard.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import { useState } from &apos;react&apos;
import { useRouter } from &apos;next/navigation&apos;
import TierForm from &apos;./TierForm&apos;

interface TierCardProps {
  tier: {
    id: string
    name: string
    description: string | null
    priceInCents: number
  }
}

export default function TierCard({ tier }: TierCardProps) {
  const router = useRouter()
  const [isEditing, setIsEditing] = useState(false)
  const [isDeleting, setIsDeleting] = useState(false)

  async function handleDelete() {
    if (!confirm(&apos;Are you sure you want to delete this tier?&apos;)) return

    setIsDeleting(true)

    try {
      const response = await fetch(`/api/creator/tiers/${tier.id}`, {
        method: &apos;DELETE&apos;,
      })

      if (response.ok) {
        router.refresh()
      }
    } catch (error) {
      console.error(&apos;Delete failed:&apos;, error)
    } finally {
      setIsDeleting(false)
    }
  }

  if (isEditing) {
    return &lt;TierForm tier={tier} onCancel={() =&gt; setIsEditing(false)} /&gt;
  }

  return (
    &lt;div className=&quot;p-4 border rounded-lg flex justify-between items-start&quot;&gt;
      &lt;div&gt;
        &lt;h3 className=&quot;font-medium&quot;&gt;{tier.name}&lt;/h3&gt;
        {tier.description &amp;&amp; (
          &lt;p className=&quot;text-sm text-gray-600 mt-1&quot;&gt;{tier.description}&lt;/p&gt;
        )}
        &lt;p className=&quot;text-lg font-bold mt-2&quot;&gt;
          ${(tier.priceInCents / 100).toFixed(2)}/month
        &lt;/p&gt;
      &lt;/div&gt;
      &lt;div className=&quot;flex gap-2&quot;&gt;
        &lt;button
          onClick={() =&gt; setIsEditing(true)}
          className=&quot;px-3 py-1 text-sm border rounded hover:bg-gray-50 transition&quot;
        &gt;
          Edit
        &lt;/button&gt;
        &lt;button
          onClick={handleDelete}
          disabled={isDeleting}
          className=&quot;px-3 py-1 text-sm border border-red-300 text-red-600 rounded hover:bg-red-50 disabled:opacity-50 transition&quot;
        &gt;
          {isDeleting ? &apos;Deleting...&apos; : &apos;Delete&apos;}
        &lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="testing-tiers">Testing tiers</h3><p>Now that you&#x2019;re all done with tiers, let&#x2019;s test your tier management system:</p><ol><li>Sign in as a creator and go to <code>http://localhost:3000/creator/dashboard</code></li><li>Click the <strong>Manage tiers</strong> button</li><li>Create a tier (make sure it&#x2019;s minimum $1)</li><li>Open the Prisma Studio (with <code>npx prisma studio</code>) and check the Tier table. You should see the tier you just created</li><li>Try to edit the tier in your project and check if the edits reflect to your database</li></ol><h2 id="step-7-creator-profiles-and-content">Step 7: Creator profiles and content</h2><p>While you&#x2019;re testing the creator dashboard, you&apos;ve seen a link that says &#x201C;<strong>View public profile</strong>&#x201D; - it redirects creators to their public profile.</p><p>You&#x2019;ve also seen the <strong>Create content</strong> button there. So let&#x2019;s build the public creator profiles and the content sharing system.</p><h3 id="create-the-public-creator-page">Create the public creator page</h3><p>The public profile page lets users view information about the creator like their tiers and subscriber count. To build it, create a folder called <code>[username]</code> inside <code>app/creator</code> and a file inside the <code>[username]</code> folder 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { notFound } from &apos;next/navigation&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import Link from &apos;next/link&apos;

interface ProfilePageProps {
  params: Promise&lt;{ username: string }&gt;
}

export default async function CreatorProfilePage({ params }: ProfilePageProps) {
  const { username } = await params

  const creator = await prisma.creator.findUnique({
    where: { username },
    include: {
      tiers: {
        orderBy: { priceInCents: &apos;asc&apos; },
        include: {
          _count: { select: { subscriptions: true } },
        },
      },
      _count: {
        select: { subscriptions: true },
      },
    },
  })

  if (!creator) {
    notFound()
  }

  return (
    &lt;main className=&quot;min-h-screen p-8 max-w-4xl mx-auto&quot;&gt;
      &lt;div className=&quot;mb-8&quot;&gt;
        &lt;h1 className=&quot;text-3xl font-bold&quot;&gt;{creator.displayName}&lt;/h1&gt;
        &lt;p className=&quot;text-gray-600&quot;&gt;@{creator.username}&lt;/p&gt;
        {creator.bio &amp;&amp; (
          &lt;p className=&quot;mt-4 text-gray-700&quot;&gt;{creator.bio}&lt;/p&gt;
        )}
        &lt;p className=&quot;mt-2 text-sm text-gray-500&quot;&gt;
          {creator._count.subscriptions} subscriber{creator._count.subscriptions !== 1 ? &apos;s&apos; : &apos;&apos;}
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;h2 className=&quot;text-xl font-bold mb-4&quot;&gt;Subscribe&lt;/h2&gt;
        {creator.tiers.length === 0 ? (
          &lt;p className=&quot;text-gray-500&quot;&gt;No subscription tiers available yet.&lt;/p&gt;
        ) : (
          &lt;div className=&quot;grid gap-4 md:grid-cols-2&quot;&gt;
            {creator.tiers.map((tier) =&gt; (
              &lt;div
                key={tier.id}
                className=&quot;p-6 border rounded-lg flex flex-col justify-between&quot;
              &gt;
                &lt;div&gt;
                  &lt;h3 className=&quot;text-lg font-medium&quot;&gt;{tier.name}&lt;/h3&gt;
                  {tier.description &amp;&amp; (
                    &lt;p className=&quot;text-sm text-gray-600 mt-1&quot;&gt;{tier.description}&lt;/p&gt;
                  )}
                  &lt;p className=&quot;text-2xl font-bold mt-4&quot;&gt;
                    ${(tier.priceInCents / 100).toFixed(2)}
                    &lt;span className=&quot;text-sm font-normal text-gray-500&quot;&gt;/month&lt;/span&gt;
                  &lt;/p&gt;
                &lt;/div&gt;
                &lt;Link
                  href={`/subscribe/${creator.username}/${tier.id}`}
                  className=&quot;mt-4 block text-center px-4 py-2 bg-black text-white rounded hover:bg-gray-800 transition&quot;
                &gt;
                  Subscribe
                &lt;/Link&gt;
              &lt;/div&gt;
            ))}
          &lt;/div&gt;
        )}
      &lt;/div&gt;
    &lt;/main&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>This page displays the username of the creator, their user handle, biography, subscriber count, and their tiers with <strong>Subscribe</strong> buttons that redirects users to the Whop checkout you&#x2019;ll build in the next step.</p><h3 id="create-the-api-route-for-posts">Create the API route for posts</h3><p>Now, let&#x2019;s let creators see their posts and share new ones. Go to the <code>app/api/creator/posts</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &apos;next/server&apos;
import { z } from &apos;zod&apos;
import { requireCreator } from &apos;@/lib/auth&apos;
import { checkRateLimit } from &apos;@/lib/ratelimit&apos;
import { prisma } from &apos;@/lib/prisma&apos;

const postSchema = z.object({
  title: z
    .string()
    .min(1, &apos;Title is required&apos;)
    .max(200, &apos;Title must be 200 characters or less&apos;),
  content: z
    .string()
    .min(1, &apos;Content is required&apos;)
    .max(10000, &apos;Content must be 10,000 characters or less&apos;),
  minimumTierId: z
    .string()
    .min(1, &apos;You must select a minimum tier for this post&apos;),
  published: z.boolean().optional(),
})

export async function GET() {
  const { creator, error } = await requireCreator()
  if (error) return error

  const creatorWithPosts = await prisma.creator.findUnique({
    where: { id: creator.id },
    include: {
      posts: {
        orderBy: { createdAt: &apos;desc&apos; },
        include: { minimumTier: true },
      },
    },
  })

  return NextResponse.json({ posts: creatorWithPosts?.posts || [] })
}

export async function POST(request: NextRequest) {
  const { user, creator, error: authError } = await requireCreator()
  if (authError) return authError

  const { error: rateLimitError } = checkRateLimit(user!.id)
  if (rateLimitError) return rateLimitError

  const creatorWithTiers = await prisma.creator.findUnique({
    where: { id: creator.id },
    include: { tiers: true },
  })

  const body = await request.json()
  const parsed = postSchema.safeParse(body)

  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0].message },
      { status: 400 }
    )
  }

  const { title, content, minimumTierId, published } = parsed.data

  const tierExists = creatorWithTiers?.tiers.some((t) =&gt; t.id === minimumTierId)
  if (!tierExists) {
    return NextResponse.json(
      { error: &apos;Invalid tier selected&apos; },
      { status: 400 }
    )
  }

  try {
    const post = await prisma.post.create({
      data: {
        creatorId: creator.id,
        title,
        content,
        minimumTierId,
        published: published || false,
      },
    })

    return NextResponse.json({ post })
  } catch (error) {
    console.error(&apos;Post creation error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to create post&apos; },
      { status: 500 }
    )
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-post-editing-and-deleting-route">Create post editing and deleting route</h3><p>Now you should let your creators edit and delete posts if they wish, and they need a route to do that. Let&#x2019;s go to <code>app/api/creator/posts/[postId]</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &apos;next/server&apos;
import { z } from &apos;zod&apos;
import { requireCreator } from &apos;@/lib/auth&apos;
import { checkRateLimit } from &apos;@/lib/ratelimit&apos;
import { prisma } from &apos;@/lib/prisma&apos;

const postSchema = z.object({
  title: z
    .string()
    .min(1, &apos;Title is required&apos;)
    .max(200, &apos;Title must be 200 characters or less&apos;),
  content: z
    .string()
    .min(1, &apos;Content is required&apos;)
    .max(10000, &apos;Content must be 10,000 characters or less&apos;),
  minimumTierId: z
    .string()
    .min(1, &apos;You must select a minimum tier for this post&apos;),
  published: z.boolean().optional(),
})

export async function PUT(
  request: NextRequest,
  { params }: { params: Promise&lt;{ postId: string }&gt; }
) {
  const { user, creator, error: authError } = await requireCreator()
  if (authError) return authError

  const { error: rateLimitError } = checkRateLimit(user!.id)
  if (rateLimitError) return rateLimitError

  const { postId } = await params

  const creatorWithTiers = await prisma.creator.findUnique({
    where: { id: creator.id },
    include: { tiers: true },
  })

  const post = await prisma.post.findUnique({
    where: { id: postId },
  })

  if (!post || post.creatorId !== creator.id) {
    return NextResponse.json({ error: &apos;Post not found&apos; }, { status: 404 })
  }

  const body = await request.json()
  const parsed = postSchema.safeParse(body)

  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0].message },
      { status: 400 }
    )
  }

  const { title, content, minimumTierId, published } = parsed.data

  const tierExists = creatorWithTiers?.tiers.some((t) =&gt; t.id === minimumTierId)
  if (!tierExists) {
    return NextResponse.json(
      { error: &apos;Invalid tier selected&apos; },
      { status: 400 }
    )
  }

  try {
    const updatedPost = await prisma.post.update({
      where: { id: postId },
      data: {
        title,
        content,
        minimumTierId,
        published,
      },
    })

    return NextResponse.json({ post: updatedPost })
  } catch (error) {
    console.error(&apos;Post update error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to update post&apos; },
      { status: 500 }
    )
  }
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise&lt;{ postId: string }&gt; }
) {
  const { user, creator, error: authError } = await requireCreator()
  if (authError) return authError

  const { error: rateLimitError } = checkRateLimit(user!.id)
  if (rateLimitError) return rateLimitError

  const { postId } = await params

  const post = await prisma.post.findUnique({
    where: { id: postId },
  })

  if (!post || post.creatorId !== creator.id) {
    return NextResponse.json({ error: &apos;Post not found&apos; }, { status: 404 })
  }

  try {
    await prisma.post.delete({
      where: { id: postId },
    })

    return NextResponse.json({ success: true })
  } catch (error) {
    console.error(&apos;Post deletion error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to delete post&apos; },
      { status: 500 }
    )
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-post-management-dashboard">Create post management dashboard</h3><p>Now let&#x2019;s create a management dashboard so that your creators can manage their posts. If they don&#x2019;t have any tiers yet, you should prompt the creator to create one since all posts have to be gated behind a tier (in our example).</p><p>Go to the <code>app/creator/posts</code>folder 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &apos;next/navigation&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import Link from &apos;next/link&apos;
import PostForm from &apos;./PostForm&apos;
import PostCard from &apos;./PostCard&apos;

export default async function PostsPage() {
  const user = await getCurrentUser()

  if (!user) {
    redirect(&apos;/signin&apos;)
  }

  const creator = await prisma.creator.findUnique({
    where: { userId: user.id },
    include: {
      tiers: { orderBy: { priceInCents: &apos;asc&apos; } },
      posts: {
        orderBy: { createdAt: &apos;desc&apos; },
        include: { minimumTier: true },
      },
    },
  })

  if (!creator) {
    redirect(&apos;/creator/register&apos;)
  }

  if (creator.tiers.length === 0) {
    return (
      &lt;main className=&quot;min-h-screen p-8 max-w-4xl mx-auto&quot;&gt;
        &lt;h1 className=&quot;text-2xl font-bold mb-4&quot;&gt;Create Content&lt;/h1&gt;
        &lt;div className=&quot;p-4 bg-yellow-50 border border-yellow-200 rounded-lg&quot;&gt;
          &lt;p className=&quot;text-gray-700&quot;&gt;
            You need to create at least one subscription tier before you can create posts.
          &lt;/p&gt;
          &lt;Link
            href=&quot;/creator/tiers&quot;
            className=&quot;inline-block mt-3 px-4 py-2 bg-black text-white rounded hover:bg-gray-800 transition&quot;
          &gt;
            Create a tier
          &lt;/Link&gt;
        &lt;/div&gt;
      &lt;/main&gt;
    )
  }

  return (
    &lt;main className=&quot;min-h-screen p-8 max-w-4xl mx-auto&quot;&gt;
      &lt;div className=&quot;flex justify-between items-center mb-8&quot;&gt;
        &lt;div&gt;
          &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;Your Posts&lt;/h1&gt;
          &lt;p className=&quot;text-gray-600&quot;&gt;Create and manage content for your subscribers&lt;/p&gt;
        &lt;/div&gt;
        &lt;Link
          href=&quot;/creator/dashboard&quot;
          className=&quot;text-sm text-blue-600 hover:underline&quot;
        &gt;
          &#x2190; Back to dashboard
        &lt;/Link&gt;
      &lt;/div&gt;

      &lt;div className=&quot;mb-8&quot;&gt;
        &lt;h2 className=&quot;text-lg font-medium mb-4&quot;&gt;Create a new post&lt;/h2&gt;
        &lt;PostForm tiers={creator.tiers} /&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;h2 className=&quot;text-lg font-medium mb-4&quot;&gt;
          Your posts ({creator.posts.length})
        &lt;/h2&gt;
        {creator.posts.length === 0 ? (
          &lt;p className=&quot;text-gray-500&quot;&gt;No posts yet. Create your first one above.&lt;/p&gt;
        ) : (
          &lt;div className=&quot;space-y-4&quot;&gt;
            {creator.posts.map((post) =&gt; (
              &lt;PostCard key={post.id} post={post} tiers={creator.tiers} /&gt;
            ))}
          &lt;/div&gt;
        )}
      &lt;/div&gt;
    &lt;/main&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-post-form">Create the post form</h3><p>Let&#x2019;s build the form that creators will use when creating content now. Go to the <code>app/creator/posts</code> folder and create a file called <code>PostForm.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">PostForm.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import { useState } from &apos;react&apos;
import { useRouter } from &apos;next/navigation&apos;

interface Tier {
  id: string
  name: string
  priceInCents: number
}

interface Post {
  id: string
  title: string
  content: string
  published: boolean
  minimumTierId: string | null
}

interface PostFormProps {
  tiers: Tier[]
  post?: Post
  onCancel?: () =&gt; void
}

export default function PostForm({ tiers, post, onCancel }: PostFormProps) {
  const router = useRouter()
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(&apos;&apos;)

  const isEditing = !!post

  async function handleSubmit(e: React.FormEvent&lt;HTMLFormElement&gt;) {
    e.preventDefault()
    const form = e.currentTarget
    setLoading(true)
    setError(&apos;&apos;)

    const formData = new FormData(form)
    const data = {
      title: formData.get(&apos;title&apos;),
      content: formData.get(&apos;content&apos;),
      minimumTierId: formData.get(&apos;minimumTierId&apos;),
      published: formData.get(&apos;published&apos;) === &apos;on&apos;,
    }

    try {
      const url = isEditing
        ? `/api/creator/posts/${post.id}`
        : &apos;/api/creator/posts&apos;

      const response = await fetch(url, {
        method: isEditing ? &apos;PUT&apos; : &apos;POST&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify(data),
      })

      const result = await response.json()

      if (!response.ok) {
        setError(result.error || &apos;Failed to save post&apos;)
        return
      }

      if (isEditing &amp;&amp; onCancel) {
        onCancel()
      } else {
        form.reset()
      }

      router.refresh()
    } catch (err) {
      setError(&apos;Something went wrong. Please try again.&apos;)
    } finally {
      setLoading(false)
    }
  }

  return (
    &lt;form onSubmit={handleSubmit} className=&quot;p-4 border rounded-lg space-y-4&quot;&gt;
      {error &amp;&amp; (
        &lt;div className=&quot;p-3 bg-red-100 border border-red-300 rounded text-red-700 text-sm&quot;&gt;
          {error}
        &lt;/div&gt;
      )}

      &lt;div&gt;
        &lt;label htmlFor=&quot;title&quot; className=&quot;block text-sm font-medium mb-1&quot;&gt;
          Title
        &lt;/label&gt;
        &lt;input
          type=&quot;text&quot;
          id=&quot;title&quot;
          name=&quot;title&quot;
          required
          defaultValue={post?.title || &apos;&apos;}
          placeholder=&quot;Post title&quot;
          className=&quot;w-full p-2 border rounded&quot;
        /&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;label htmlFor=&quot;content&quot; className=&quot;block text-sm font-medium mb-1&quot;&gt;
          Content
        &lt;/label&gt;
        &lt;textarea
          id=&quot;content&quot;
          name=&quot;content&quot;
          required
          rows={6}
          defaultValue={post?.content || &apos;&apos;}
          placeholder=&quot;Write your post content here...&quot;
          className=&quot;w-full p-2 border rounded&quot;
        /&gt;
        &lt;p className=&quot;text-xs text-gray-500 mt-1&quot;&gt;
          Plain text only. Image and video uploads can be added as a future enhancement.
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;label htmlFor=&quot;minimumTierId&quot; className=&quot;block text-sm font-medium mb-1&quot;&gt;
          Minimum tier required
        &lt;/label&gt;
        &lt;select
          id=&quot;minimumTierId&quot;
          name=&quot;minimumTierId&quot;
          required
          defaultValue={post?.minimumTierId || &apos;&apos;}
          className=&quot;w-full p-2 border rounded&quot;
        &gt;
          &lt;option value=&quot;&quot;&gt;Select a tier&lt;/option&gt;
          {tiers.map((tier) =&gt; (
            &lt;option key={tier.id} value={tier.id}&gt;
              {tier.name} (${(tier.priceInCents / 100).toFixed(2)}/month)
            &lt;/option&gt;
          ))}
        &lt;/select&gt;
        &lt;p className=&quot;text-xs text-gray-500 mt-1&quot;&gt;
          Subscribers at this tier or higher can view this post. Content gating is covered in Step 10.
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;div className=&quot;flex items-center gap-2&quot;&gt;
        &lt;input
          type=&quot;checkbox&quot;
          id=&quot;published&quot;
          name=&quot;published&quot;
          defaultChecked={post?.published || false}
          className=&quot;rounded&quot;
        /&gt;
        &lt;label htmlFor=&quot;published&quot; className=&quot;text-sm&quot;&gt;
          Publish immediately (uncheck to save as draft)
        &lt;/label&gt;
      &lt;/div&gt;

      &lt;div className=&quot;flex gap-2&quot;&gt;
        &lt;button
          type=&quot;submit&quot;
          disabled={loading}
          className=&quot;px-4 py-2 bg-black text-white rounded hover:bg-gray-800 disabled:opacity-50 transition&quot;
        &gt;
          {loading ? &apos;Saving...&apos; : isEditing ? &apos;Update post&apos; : &apos;Create post&apos;}
        &lt;/button&gt;
        {isEditing &amp;&amp; onCancel &amp;&amp; (
          &lt;button
            type=&quot;button&quot;
            onClick={onCancel}
            className=&quot;px-4 py-2 border rounded hover:bg-gray-50 transition&quot;
          &gt;
            Cancel
          &lt;/button&gt;
        )}
      &lt;/div&gt;
    &lt;/form&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-post-card-component">Create post card component</h3><p>To be able to display all posts of the creator in the content dashboard, let&#x2019;s create a post card component so that creators can see a neat list of their posts. Go to the <code>app/creator/posts</code> folder and create a file called <code>PostCard.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">PostCard.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import { useState } from &apos;react&apos;
import { useRouter } from &apos;next/navigation&apos;
import PostForm from &apos;./PostForm&apos;

interface Tier {
  id: string
  name: string
  priceInCents: number
}

interface Post {
  id: string
  title: string
  content: string
  published: boolean
  minimumTierId: string | null
  minimumTier: Tier | null
  createdAt: Date | string
}

interface PostCardProps {
  post: Post
  tiers: Tier[]
}

export default function PostCard({ post, tiers }: PostCardProps) {
  const router = useRouter()
  const [isEditing, setIsEditing] = useState(false)
  const [isDeleting, setIsDeleting] = useState(false)

  async function handleDelete() {
    if (!confirm(&apos;Are you sure you want to delete this post?&apos;)) return

    setIsDeleting(true)

    try {
      const response = await fetch(`/api/creator/posts/${post.id}`, {
        method: &apos;DELETE&apos;,
      })

      if (response.ok) {
        router.refresh()
      }
    } catch (error) {
      console.error(&apos;Delete failed:&apos;, error)
    } finally {
      setIsDeleting(false)
    }
  }

  if (isEditing) {
    return (
      &lt;PostForm
        tiers={tiers}
        post={post}
        onCancel={() =&gt; setIsEditing(false)}
      /&gt;
    )
  }

  return (
    &lt;div className=&quot;p-4 border rounded-lg&quot;&gt;
      &lt;div className=&quot;flex justify-between items-start&quot;&gt;
        &lt;div className=&quot;flex-1&quot;&gt;
          &lt;div className=&quot;flex items-center gap-2 mb-1&quot;&gt;
            &lt;h3 className=&quot;font-medium&quot;&gt;{post.title}&lt;/h3&gt;
            {!post.published &amp;&amp; (
              &lt;span className=&quot;px-2 py-0.5 text-xs bg-gray-200 rounded&quot;&gt;
                Draft
              &lt;/span&gt;
            )}
          &lt;/div&gt;
          &lt;p className=&quot;text-sm text-gray-600 line-clamp-2&quot;&gt;{post.content}&lt;/p&gt;
          &lt;div className=&quot;flex items-center gap-3 mt-2 text-xs text-gray-500&quot;&gt;
            {post.minimumTier &amp;&amp; (
              &lt;span&gt;
                Requires: {post.minimumTier.name} ($
                {(post.minimumTier.priceInCents / 100).toFixed(2)}/mo)
              &lt;/span&gt;
            )}
            &lt;span&gt;
              {new Date(post.createdAt).toLocaleDateString()}
            &lt;/span&gt;
          &lt;/div&gt;
        &lt;/div&gt;
        &lt;div className=&quot;flex gap-2 ml-4&quot;&gt;
          &lt;button
            onClick={() =&gt; setIsEditing(true)}
            className=&quot;px-3 py-1 text-sm border rounded hover:bg-gray-50 transition&quot;
          &gt;
            Edit
          &lt;/button&gt;
          &lt;button
            onClick={handleDelete}
            disabled={isDeleting}
            className=&quot;px-3 py-1 text-sm border border-red-300 text-red-600 rounded hover:bg-red-50 disabled:opacity-50 transition&quot;
          &gt;
            {isDeleting ? &apos;Deleting...&apos; : &apos;Delete&apos;}
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="test-profiles-and-posts">Test profiles and posts</h3><ol><li>Sign in as a creator and go to <code>http://localhost:3000/creator/dashboard</code></li><li>Click the <strong>View public profile</strong> button in the creator dashboard</li><li>Go back to your dashboard and click <strong>Create content</strong></li><li>Fill out the content form and publish it</li><li>Open Prisma Studio and check the Post table to verify your post</li></ol><h2 id="step-8-checkouts">Step 8: Checkouts</h2><p>When a customer clicks on the <strong>Subscribe</strong> button of a tier, they get redirected to the details page of the tier so that they can get more information before they actually make a payment.</p><p>To make this page, let&#x2019;s go to <code>app/subscribe/[username]/[tierId]</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect, notFound } from &apos;next/navigation&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import Link from &apos;next/link&apos;
import CheckoutButton from &apos;./CheckoutButton&apos;

interface SubscribePageProps {
  params: Promise&lt;{ username: string; tierId: string }&gt;
}

export default async function SubscribePage({ params }: SubscribePageProps) {
  const { username, tierId } = await params
  const user = await getCurrentUser()

  if (!user) {
    redirect(`/signin?redirect=/subscribe/${username}/${tierId}`)
  }

  const creator = await prisma.creator.findUnique({
    where: { username },
    include: {
      tiers: {
        orderBy: { priceInCents: &apos;asc&apos; },
      },
      _count: {
        select: { subscriptions: true },
      },
    },
  })

  if (!creator) {
    notFound()
  }

  const tier = creator.tiers.find((t) =&gt; t.id === tierId)

  if (!tier) {
    notFound()
  }

  const existingSubscription = await prisma.subscription.findUnique({
    where: {
      userId_creatorId: {
        userId: user.id,
        creatorId: creator.id,
      },
    },
  })

  if (existingSubscription) {
    redirect(`/creator/${username}?already_subscribed=true`)
  }

  const tierIndex = creator.tiers.findIndex((t) =&gt; t.id === tierId)
  const accessibleTierIds = creator.tiers
    .slice(0, tierIndex + 1)
    .map((t) =&gt; t.id)

  const postCount = await prisma.post.count({
    where: {
      creatorId: creator.id,
      published: true,
      minimumTierId: { in: accessibleTierIds },
    },
  })

  return (
    &lt;main className=&quot;min-h-screen p-8 max-w-xl mx-auto&quot;&gt;
      &lt;Link
        href={`/creator/${username}`}
        className=&quot;text-sm text-blue-600 hover:underline&quot;
      &gt;
        &#x2190; Back to {creator.displayName}&apos;s profile
      &lt;/Link&gt;

      &lt;div className=&quot;mt-6 p-6 border rounded-lg&quot;&gt;
        &lt;h1 className=&quot;text-2xl font-bold mb-1&quot;&gt;Subscribe to {creator.displayName}&lt;/h1&gt;
        &lt;p className=&quot;text-gray-600 mb-6&quot;&gt;@{creator.username}&lt;/p&gt;

        &lt;div className=&quot;p-4 bg-gray-50 rounded-lg mb-6&quot;&gt;
          &lt;h2 className=&quot;font-medium text-lg&quot;&gt;{tier.name}&lt;/h2&gt;
          {tier.description &amp;&amp; (
            &lt;p className=&quot;text-sm text-gray-600 mt-1&quot;&gt;{tier.description}&lt;/p&gt;
          )}
          &lt;p className=&quot;text-3xl font-bold mt-4&quot;&gt;
            ${(tier.priceInCents / 100).toFixed(2)}
            &lt;span className=&quot;text-base font-normal text-gray-500&quot;&gt;/month&lt;/span&gt;
          &lt;/p&gt;
        &lt;/div&gt;

        &lt;div className=&quot;mb-6 text-sm text-gray-600&quot;&gt;
          &lt;p&gt;&#x2713; Access to {postCount} post{postCount !== 1 ? &apos;s&apos; : &apos;&apos;}&lt;/p&gt;
          &lt;p&gt;&#x2713; Support {creator.displayName} directly&lt;/p&gt;
          &lt;p&gt;&#x2713; Cancel anytime&lt;/p&gt;
        &lt;/div&gt;

        &lt;CheckoutButton
          creatorId={creator.id}
          tierId={tier.id}
          creatorUsername={creator.username}
        /&gt;

        &lt;p className=&quot;text-xs text-gray-500 mt-4 text-center&quot;&gt;
          Payments are securely processed by Whop
        &lt;/p&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-checkout-button">Create the checkout button</h3><p>You need to have a button that redirects users to a checkout in the tier details page. You can do this by going to the <code>app/subscribe/[username]/[tierId]</code> folder and create a file called <code>CheckoutButton.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">CheckoutButton.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import { useState } from &apos;react&apos;

interface CheckoutButtonProps {
  creatorId: string
  tierId: string
  creatorUsername: string
}

export default function CheckoutButton({
  creatorId,
  tierId,
  creatorUsername,
}: CheckoutButtonProps) {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(&apos;&apos;)

  async function handleCheckout() {
    setLoading(true)
    setError(&apos;&apos;)

    try {
      const response = await fetch(&apos;/api/checkout&apos;, {
        method: &apos;POST&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify({ creatorId, tierId, creatorUsername }),
      })

      const data = await response.json()

      if (!response.ok) {
        setError(data.error || &apos;Failed to create checkout&apos;)
        return
      }

      window.location.href = data.checkoutUrl
    } catch (err) {
      setError(&apos;Something went wrong. Please try again.&apos;)
    } finally {
      setLoading(false)
    }
  }

  return (
    &lt;div&gt;
      {error &amp;&amp; (
        &lt;div className=&quot;p-3 mb-4 bg-red-100 border border-red-300 rounded text-red-700 text-sm&quot;&gt;
          {error}
        &lt;/div&gt;
      )}
      &lt;button
        onClick={handleCheckout}
        disabled={loading}
        className=&quot;w-full p-3 bg-black text-white rounded-lg hover:bg-gray-800 disabled:opacity-50 transition font-medium&quot;
      &gt;
        {loading ? &apos;Loading...&apos; : &apos;Continue to checkout&apos;}
      &lt;/button&gt;
    &lt;/div&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-checkout-api-route">Create the checkout API route</h3><p>With the API route, you will create the checkout on the creator&#x2019;s connected account based on their <code>whopCompanyId</code>. To do this, go to <code>app/api/checkout</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &apos;next/server&apos;
import { z } from &apos;zod&apos;
import { requireAuth } from &apos;@/lib/auth&apos;
import { checkRateLimit } from &apos;@/lib/ratelimit&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import { whop } from &apos;@/lib/whop&apos;

const checkoutSchema = z.object({
  creatorId: z.string().min(1, &apos;Creator ID is required&apos;),
  tierId: z.string().min(1, &apos;Tier ID is required&apos;),
  creatorUsername: z.string().min(1, &apos;Creator username is required&apos;),
})

export async function POST(request: NextRequest) {
  const { user, error: authError } = await requireAuth()
  if (authError) return authError

  const { error: rateLimitError } = checkRateLimit(user.id)
  if (rateLimitError) return rateLimitError

  const body = await request.json()
  const parsed = checkoutSchema.safeParse(body)

  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0].message },
      { status: 400 }
    )
  }

  const { creatorId, tierId, creatorUsername } = parsed.data

  const creator = await prisma.creator.findUnique({
    where: { id: creatorId },
    include: { tiers: true },
  })

  if (!creator || !creator.whopCompanyId) {
    return NextResponse.json(
      { error: &apos;Creator not found or not set up for payments&apos; },
      { status: 404 }
    )
  }

  const tier = creator.tiers.find((t) =&gt; t.id === tierId)

  if (!tier) {
    return NextResponse.json({ error: &apos;Tier not found&apos; }, { status: 404 })
  }

  if (!tier.whopPlanId) {
    return NextResponse.json(
      { error: &apos;Tier not properly configured for payments&apos; },
      { status: 400 }
    )
  }

  const existingSubscription = await prisma.subscription.findUnique({
    where: {
      userId_creatorId: {
        userId: user.id,
        creatorId: creator.id,
      },
    },
  })

  if (existingSubscription) {
    return NextResponse.json(
      { error: &apos;You are already subscribed to this creator&apos; },
      { status: 400 }
    )
  }

  try {

    const baseUrl = process.env.AUTH_URL || &apos;http://localhost:3000&apos;
    const redirectUrl = baseUrl.startsWith(&apos;https://&apos;)
      ? `${baseUrl}/creator/${creatorUsername}?subscribed=true`
      : undefined 

    const checkoutConfig = await whop.checkoutConfigurations.create({
      plan_id: tier.whopPlanId,
      ...(redirectUrl &amp;&amp; { redirect_url: redirectUrl }),
      metadata: {
        platform_user_id: user.id,
        platform_creator_id: creator.id,
        platform_tier_id: tier.id,
      },
    })

    return NextResponse.json({ checkoutUrl: checkoutConfig.purchase_url })
  } catch (error) {
    console.error(&apos;Checkout creation error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to create checkout&apos; },
      { status: 500 }
    )
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="update-the-creator-profile-for-success-messages">Update the creator profile for success messages</h3><p>Now that you&#x2019;re building the subscriptions system, let&#x2019;s update the <code>page.tsx</code> file in <code>app/creator/[username]</code> with the content below so that you can display success messages:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { notFound } from &apos;next/navigation&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import Link from &apos;next/link&apos;

interface ProfilePageProps {
  params: Promise&lt;{ username: string }&gt;
  searchParams: Promise&lt;{ subscribed?: string; already_subscribed?: string }&gt;
}

export default async function CreatorProfilePage({
  params,
  searchParams,
}: ProfilePageProps) {
  const { username } = await params
  const { subscribed, already_subscribed } = await searchParams

  const creator = await prisma.creator.findUnique({
    where: { username },
    include: {
      tiers: {
        orderBy: { priceInCents: &apos;asc&apos; },
        include: {
          _count: { select: { subscriptions: true } },
        },
      },
      _count: {
        select: { subscriptions: true },
      },
    },
  })

  if (!creator) {
    notFound()
  }

  return (
    &lt;main className=&quot;min-h-screen p-8 max-w-4xl mx-auto&quot;&gt;
      {subscribed === &apos;true&apos; &amp;&amp; (
        &lt;div className=&quot;mb-6 p-4 bg-green-50 border border-green-200 rounded-lg&quot;&gt;
          &lt;p className=&quot;text-green-800 font-medium&quot;&gt;
            Thanks for subscribing! Your subscription is being processed.
          &lt;/p&gt;
          &lt;p className=&quot;text-green-600 text-sm mt-1&quot;&gt;
            You&apos;ll have access to exclusive content once the payment is confirmed.
          &lt;/p&gt;
        &lt;/div&gt;
      )}

      {already_subscribed === &apos;true&apos; &amp;&amp; (
        &lt;div className=&quot;mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg&quot;&gt;
          &lt;p className=&quot;text-blue-800&quot;&gt;
            You&apos;re already subscribed to this creator!
          &lt;/p&gt;
        &lt;/div&gt;
      )}

      &lt;div className=&quot;mb-8&quot;&gt;
        &lt;h1 className=&quot;text-3xl font-bold&quot;&gt;{creator.displayName}&lt;/h1&gt;
        &lt;p className=&quot;text-gray-600&quot;&gt;@{creator.username}&lt;/p&gt;
        {creator.bio &amp;&amp; (
          &lt;p className=&quot;mt-4 text-gray-700&quot;&gt;{creator.bio}&lt;/p&gt;
        )}
        &lt;p className=&quot;mt-2 text-sm text-gray-500&quot;&gt;
          {creator._count.subscriptions} subscriber{creator._count.subscriptions !== 1 ? &apos;s&apos; : &apos;&apos;}
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;h2 className=&quot;text-xl font-bold mb-4&quot;&gt;Subscribe&lt;/h2&gt;
        {creator.tiers.length === 0 ? (
          &lt;p className=&quot;text-gray-500&quot;&gt;No subscription tiers available yet.&lt;/p&gt;
        ) : (
          &lt;div className=&quot;grid gap-4 md:grid-cols-2&quot;&gt;
            {creator.tiers.map((tier) =&gt; (
              &lt;div
                key={tier.id}
                className=&quot;p-6 border rounded-lg flex flex-col justify-between&quot;
              &gt;
                &lt;div&gt;
                  &lt;h3 className=&quot;text-lg font-medium&quot;&gt;{tier.name}&lt;/h3&gt;
                  {tier.description &amp;&amp; (
                    &lt;p className=&quot;text-sm text-gray-600 mt-1&quot;&gt;{tier.description}&lt;/p&gt;
                  )}
                  &lt;p className=&quot;text-2xl font-bold mt-4&quot;&gt;
                    ${(tier.priceInCents / 100).toFixed(2)}
                    &lt;span className=&quot;text-sm font-normal text-gray-500&quot;&gt;/month&lt;/span&gt;
                  &lt;/p&gt;
                &lt;/div&gt;
                &lt;Link
                  href={`/subscribe/${creator.username}/${tier.id}`}
                  className=&quot;mt-4 block text-center px-4 py-2 bg-black text-white rounded hover:bg-gray-800 transition&quot;
                &gt;
                  Subscribe
                &lt;/Link&gt;
              &lt;/div&gt;
            ))}
          &lt;/div&gt;
        )}
      &lt;/div&gt;
    &lt;/main&gt;
  )
}
</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="testing-the-checkout-using-whop-sandbox">Testing the checkout using Whop sandbox</h3><p>Whop has a sandbox environment for testing these types of integrations without real payments. Let&#x2019;s use it to try our checkout system:</p><ol><li>Sign in with a different Whop account to your project</li><li>Go to the creator profile (<code>http://localhost:3000/creator/[username]</code>), select a tier, and click <strong>Subscribe</strong></li><li>In the tier details page, click <strong>Continue to checkout</strong> and use the test cards of Whop to complete the checkout</li><li>After a successful payment, you&#x2019;ll stay on the Whop page</li></ol><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">The redirect back to your app only works with HTTPS URLs. Since localhost uses HTTP, Whop can&apos;t redirect you automatically. Once you deploy your project with HTTPS (Step 13), the redirect will work and users will return to the creator&apos;s profile page with a success message.</div></div><p>Now, you can manually check if the payment went through using the Whop dashboard at Whop&apos;s sandbox under Connected accounts &gt; The connected company account details page &gt; Customers.</p><h2 id="step-9-handling-webhooks">Step 9: Handling webhooks</h2><p>When a customer completes a checkout on Whop, your app needs a confirmation from the system so that it can go back to your database and create the subscription record. To do this, you&#x2019;ll use webhooks sent by Whop.</p><p>In this step, you&#x2019;ll set up a webhook endpoint that listens to Whop using ngrok.</p><h3 id="install-ngrok">Install ngrok</h3><p>The reason we&#x2019;re using ngrok is that while Whop sends HTTP requests to your server when an event happens, like <code>payment_succeeded</code>, the problem is since you&#x2019;ve not deployed your project yet, Whop can&#x2019;t reach it.</p><p>Ngrok helps us solve this by creating a tunnel to your local development environment. When Whop sends a webhook to the ngrok URL, ngrok forwards it to your machine.</p><p>Let&#x2019;s start by installing ngrok with the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install \-g ngrok</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then, in a new terminal (you already have one running the <code>npm run dev</code>), start ngrok with the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">ngrok http 3000</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>When you start ngrok, you&#x2019;ll see an output containing the <strong>forwarding URL</strong> - it looks like:  </p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">https://example-forwarding-url.ngrok-free.dev</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Keep note of this URL, you&#x2019;re going to use it soon.</p><h3 id="create-the-webhook-endpoint">Create the webhook endpoint</h3><p>Now let&#x2019;s create an endpoint that allows you to get the webhook messages. Go to <code>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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &apos;next/server&apos;
import { whop } from &apos;@/lib/whop&apos;
import { prisma } from &apos;@/lib/prisma&apos;

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

  try {
    const webhookData = whop.webhooks.unwrap(rawBody, { headers })

    const { type, data } = webhookData as any

    if (type === &apos;payment.succeeded&apos;) {
      await handlePaymentSucceeded(data)
    }

    return NextResponse.json({ received: true })
  } catch (error) {
    console.error(&apos;Webhook verification failed:&apos;, error)
    return NextResponse.json(
      { error: &apos;Invalid webhook signature&apos; },
      { status: 401 }
    )
  }
}

async function handlePaymentSucceeded(data: any) {
  const metadata = data.checkout_configuration?.metadata || data.metadata

  const platformUserId = metadata?.platform_user_id
  const platformCreatorId = metadata?.platform_creator_id
  const platformTierId = metadata?.platform_tier_id
  const membershipId = data.membership?.id || data.id

  if (!platformUserId || !platformCreatorId || !platformTierId) {
    console.error(&apos;Missing platform metadata in payment:&apos;, { metadata })
    return
  }

  const existingSubscription = await prisma.subscription.findFirst({
    where: {
      userId: platformUserId,
      creatorId: platformCreatorId,
    },
  })

  if (existingSubscription) {
    await prisma.subscription.update({
      where: { id: existingSubscription.id },
      data: { status: &apos;ACTIVE&apos;, whopMembershipId: membershipId },
    })
    return
  }

  await prisma.subscription.create({
    data: {
      userId: platformUserId,
      creatorId: platformCreatorId,
      tierId: platformTierId,
      whopMembershipId: membershipId,
      status: &apos;ACTIVE&apos;,
    },
  })
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="configure-the-webhook-in-whop-and-get-your-webhook-secret">Configure the webhook in Whop and get your webhook secret</h3><p>Now, let&#x2019;s go configure the webhook in your Whop dashboard and get the webhook secret you&#x2019;ll use later:</p><ol><li>Go to the Whop sandbox dashboard</li><li>Open the Developer page</li><li>In the Webhooks section, click <strong>Create webhook</strong> </li><li>Enter your ngrok URL followed by <code>/api/webhooks/whop</code> (like <code>https://abc123.ngrok-free.app/api/webhooks/whop</code>)</li><li>Enable the <code>payment_succeeded</code> event</li><li>Click <strong>Save</strong></li></ol><p>Once you complete the steps above, you now have a webhook ready. Click the secret that starts with <code>ws_</code> under the Secret column of your webhook list.</p><figure class="kg-card kg-video-card kg-width-regular" data-kg-thumbnail="https://whop.com/blog/content/media/2026/01/WebhookSecret_thumb.jpg" data-kg-custom-thumbnail>
            <div class="kg-video-container">
                <video src="https://whop.com/blog/content/media/2026/01/WebhookSecret.mp4" poster="https://img.spacergif.org/v1/1920x1080/0a/spacer.png" width="1920" height="1080" loop autoplay muted playsinline preload="metadata" style="background: transparent url(&apos;https://whop.com/blog/content/media/2026/01/WebhookSecret_thumb.jpg&apos;) 50% 50% / cover no-repeat;"></video>
                <div class="kg-video-overlay">
                    <button class="kg-video-large-play-icon" aria-label="Play video">
                        <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                            <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"/>
                        </svg>
                    </button>
                </div>
                <div class="kg-video-player-container kg-video-hide">
                    <div class="kg-video-player">
                        <button class="kg-video-play-icon" aria-label="Play video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"/>
                            </svg>
                        </button>
                        <button class="kg-video-pause-icon kg-video-hide" aria-label="Pause video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <rect x="3" y="1" width="7" height="22" rx="1.5" ry="1.5"/>
                                <rect x="14" y="1" width="7" height="22" rx="1.5" ry="1.5"/>
                            </svg>
                        </button>
                        <span class="kg-video-current-time">0:00</span>
                        <div class="kg-video-time">
                            /<span class="kg-video-duration">0:19</span>
                        </div>
                        <input type="range" class="kg-video-seek-slider" max="100" value="0">
                        <button class="kg-video-playback-rate" aria-label="Adjust playback speed">1&#xD7;</button>
                        <button class="kg-video-unmute-icon" aria-label="Unmute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <path d="M15.189 2.021a9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h1.794a.249.249 0 0 1 .221.133 9.73 9.73 0 0 0 7.924 4.85h.06a1 1 0 0 0 1-1V3.02a1 1 0 0 0-1.06-.998Z"/>
                            </svg>
                        </button>
                        <button class="kg-video-mute-icon kg-video-hide" aria-label="Mute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <path d="M16.177 4.3a.248.248 0 0 0 .073-.176v-1.1a1 1 0 0 0-1.061-1 9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h.114a.251.251 0 0 0 .177-.073ZM23.707 1.706A1 1 0 0 0 22.293.292l-22 22a1 1 0 0 0 0 1.414l.009.009a1 1 0 0 0 1.405-.009l6.63-6.631A.251.251 0 0 1 8.515 17a.245.245 0 0 1 .177.075 10.081 10.081 0 0 0 6.5 2.92 1 1 0 0 0 1.061-1V9.266a.247.247 0 0 1 .073-.176Z"/>
                            </svg>
                        </button>
                        <input type="range" class="kg-video-volume-slider" max="100" value="100">
                    </div>
                </div>
            </div>
            
        </figure><h3 id="update-your-environment-file">Update your environment file</h3><p>By now, your <code>.env</code> file should look like:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">DATABASE_URL=&quot;postgresql://postgres:yourpassword@localhost:5432/patreon_clone?schema=public&quot;

SESSION_SECRET=&quot;your-session-secret&quot;
AUTH_URL=&quot;http://localhost:3000&quot;

WHOP_SANDBOX=&quot;true&quot;

WHOP_APP_ID=&quot;app_XXXXXXXXX&quot;
WHOP_API_KEY=&quot;apik_XXXXXXXXX&quot;
WHOP_COMPANY_ID=&quot;biz_XXXXXXXXX&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Now, below all that, you should add the line, replace the <code>your_webhook_secret_here</code> part with your actual webhook secret you got previously, and save the file:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">WHOP_WEBHOOK_SECRET=your_webhook_secret_here</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="update-your-whop-sdk-configuration">Update your Whop SDK configuration</h3><p>Go to the <code>lib</code> folder and update the <code>whop.ts</code> file to call for your webhook secret as well:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Whop from &quot;@whop/sdk&quot;

const isSandbox = process.env.WHOP_SANDBOX === &apos;true&apos;

export const whop = new Whop({
  appID: process.env.WHOP_APP_ID,
  apiKey: process.env.WHOP_API_KEY,
  webhookKey: Buffer.from(process.env.WHOP_WEBHOOK_SECRET || &quot;&quot;).toString(&apos;base64&apos;),
  ...(isSandbox &amp;&amp; { baseURL: &quot;https://sandbox-api.whop.com/api/v1&quot; }),
})</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="test-the-webhook">Test the webhook</h3><p>Let&#x2019;s test if everything works as intended. While your server and ngrok is running:</p><ol><li>Sign into your app with a different Whop account</li><li>Go to the creator&#x2019;s profile and subscribe to a user</li><li>Complete the checkout using a test card<ol><li><strong>Card number:</strong> <code>4242 4242 4242 4242</code> </li><li><strong>Expiry:</strong> Any future date (e.g., <code>12/34</code>) </li><li><strong>CVC:</strong> Any 3 digits (e.g., <code>123</code>) </li></ol></li><li>After a successful payment, check your terminal, there should be a webhook log</li><li>Open Prisma Studio and verify if the Subscription was created</li></ol><h2 id="step-10-gating-the-creator-content">Step 10: Gating the creator content</h2><p>Now that your users can subscribe to the creators and you can see the information on both the creator dashboard and your database, let&#x2019;s update the creator profiles to show the content they share and configure how users access them.</p><p>First, let&#x2019;s remember how tier-based access works. When a creator is creating a post, they choose a minimum tier. All tiers are ordered by price, and the higher tiers get access to all lower tier posts.</p><h3 id="create-a-helper-function-to-check-access">Create a helper function to check access</h3><p>Let&#x2019;s go to the <code>lib</code> folder and create a file called <code>access.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">access.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { Tier } from &apos;@prisma/client&apos;

interface AccessCheckParams {
  postMinimumTierId: string | null
  userTierId: string | null
  allTiers: Tier[]
}

export function canAccessPost({
  postMinimumTierId,
  userTierId,
  allTiers,
}: AccessCheckParams): boolean {
  if (!postMinimumTierId) {
    return true
  }

  if (!userTierId) {
    return false
  }

  const sortedTiers = [...allTiers].sort((a, b) =&gt; a.priceInCents - b.priceInCents)
  
  const userTierIndex = sortedTiers.findIndex(t =&gt; t.id === userTierId)
  const postTierIndex = sortedTiers.findIndex(t =&gt; t.id === postMinimumTierId)

  return userTierIndex &gt;= postTierIndex
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="update-the-creator-profiles">Update the creator profiles</h3><p>Now, update the creator profiles so that customers can actually see the content creators share. Go to the folder <code>app/creator/[username]</code> and update the <code>page.tsx</code> content with this:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { notFound } from &apos;next/navigation&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import { canAccessPost } from &apos;@/lib/access&apos;
import Link from &apos;next/link&apos;

interface ProfilePageProps {
  params: Promise&lt;{ username: string }&gt;
  searchParams: Promise&lt;{ subscribed?: string; already_subscribed?: string }&gt;
}

export default async function CreatorProfilePage({
  params,
  searchParams,
}: ProfilePageProps) {
  const { username } = await params
  const { subscribed, already_subscribed } = await searchParams
  const user = await getCurrentUser()

  const creator = await prisma.creator.findUnique({
    where: { username },
    include: {
      tiers: {
        orderBy: { priceInCents: &apos;asc&apos; },
      },
      posts: {
        where: { published: true },
        orderBy: { createdAt: &apos;desc&apos; },
        include: { minimumTier: true },
      },
      _count: {
        select: { subscriptions: true },
      },
    },
  })

  if (!creator) {
    notFound()
  }

  let userSubscription = null
  if (user) {
    userSubscription = await prisma.subscription.findUnique({
      where: {
        userId_creatorId: {
          userId: user.id,
          creatorId: creator.id,
        },
      },
      include: { tier: true },
    })
  }

  const isActiveSubscriber = userSubscription?.status === &apos;ACTIVE&apos;

  return (
    &lt;main className=&quot;min-h-screen p-8 max-w-4xl mx-auto&quot;&gt;
      {subscribed === &apos;true&apos; &amp;&amp; (
        &lt;div className=&quot;mb-6 p-4 bg-green-50 border border-green-200 rounded-lg&quot;&gt;
          &lt;p className=&quot;text-green-800 font-medium&quot;&gt;
            Thanks for subscribing! Your subscription is being processed.
          &lt;/p&gt;
          &lt;p className=&quot;text-green-600 text-sm mt-1&quot;&gt;
            You&apos;ll have access to exclusive content once the payment is confirmed.
          &lt;/p&gt;
        &lt;/div&gt;
      )}

      {already_subscribed === &apos;true&apos; &amp;&amp; (
        &lt;div className=&quot;mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg&quot;&gt;
          &lt;p className=&quot;text-blue-800&quot;&gt;
            You&apos;re already subscribed to this creator!
          &lt;/p&gt;
        &lt;/div&gt;
      )}

      &lt;div className=&quot;mb-8&quot;&gt;
        &lt;h1 className=&quot;text-3xl font-bold&quot;&gt;{creator.displayName}&lt;/h1&gt;
        &lt;p className=&quot;text-gray-600&quot;&gt;@{creator.username}&lt;/p&gt;
        {creator.bio &amp;&amp; (
          &lt;p className=&quot;mt-4 text-gray-700&quot;&gt;{creator.bio}&lt;/p&gt;
        )}
        &lt;p className=&quot;mt-2 text-sm text-gray-500&quot;&gt;
          {creator._count.subscriptions} subscriber{creator._count.subscriptions !== 1 ? &apos;s&apos; : &apos;&apos;}
        &lt;/p&gt;
        {isActiveSubscriber &amp;&amp; userSubscription &amp;&amp; (
          &lt;p className=&quot;mt-2 text-sm text-green-500 font-medium&quot;&gt;
            &#x2713; Subscribed to {userSubscription.tier.name}
          &lt;/p&gt;
        )}
      &lt;/div&gt;

      &lt;div className=&quot;mb-12&quot;&gt;
        &lt;h2 className=&quot;text-xl font-bold mb-4&quot;&gt;Posts&lt;/h2&gt;
        {creator.posts.length === 0 ? (
          &lt;p className=&quot;text-gray-500&quot;&gt;No posts yet.&lt;/p&gt;
        ) : (
          &lt;div className=&quot;space-y-4&quot;&gt;
            {creator.posts.map((post) =&gt; {
              const hasAccess = canAccessPost({
                postMinimumTierId: post.minimumTierId,
                userTierId: isActiveSubscriber ? userSubscription.tierId : null,
                allTiers: creator.tiers,
              })

              return (
                &lt;div
                  key={post.id}
                  className=&quot;p-6 border rounded-lg&quot;
                &gt;
                  &lt;div className=&quot;flex justify-between items-start mb-2&quot;&gt;
                    &lt;h3 className=&quot;text-lg font-medium&quot;&gt;{post.title}&lt;/h3&gt;
                    {post.minimumTier &amp;&amp; (
                      &lt;span className=&quot;text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded&quot;&gt;
                        {post.minimumTier.name}
                      &lt;/span&gt;
                    )}
                  &lt;/div&gt;
                  
                  {hasAccess ? (
                    &lt;p className=&quot;text-gray-700 whitespace-pre-wrap&quot;&gt;{post.content}&lt;/p&gt;
                  ) : (
                    &lt;div className=&quot;relative&quot;&gt;
                      &lt;p className=&quot;text-gray-400 blur-sm select-none&quot;&gt;
                        {post.content.substring(0, 150)}...
                      &lt;/p&gt;
                      &lt;div className=&quot;absolute inset-0 flex items-center justify-center&quot;&gt;
                        &lt;div className=&quot;text-center&quot;&gt;
                          &lt;p className=&quot;text-gray-600 font-medium&quot;&gt;
                            Subscribe to unlock
                          &lt;/p&gt;
                          &lt;p className=&quot;text-sm text-gray-500 mt-1&quot;&gt;
                            {post.minimumTier?.name} tier or higher
                          &lt;/p&gt;
                        &lt;/div&gt;
                      &lt;/div&gt;
                    &lt;/div&gt;
                  )}
                  
                  &lt;p className=&quot;text-xs text-gray-400 mt-4&quot;&gt;
                    {new Date(post.createdAt).toLocaleDateString()}
                  &lt;/p&gt;
                &lt;/div&gt;
              )
            })}
          &lt;/div&gt;
        )}
      &lt;/div&gt;

      &lt;div&gt;
        &lt;h2 className=&quot;text-xl font-bold mb-4&quot;&gt;
          {isActiveSubscriber ? &apos;Subscription Tiers&apos; : &apos;Subscribe&apos;}
        &lt;/h2&gt;
        {creator.tiers.length === 0 ? (
          &lt;p className=&quot;text-gray-500&quot;&gt;No subscription tiers available yet.&lt;/p&gt;
        ) : (
          &lt;div className=&quot;grid gap-4 md:grid-cols-2&quot;&gt;
            {creator.tiers.map((tier) =&gt; {
              const isCurrentTier = userSubscription?.tierId === tier.id

              return (
                &lt;div
                  key={tier.id}
                  className={`p-6 border rounded-lg flex flex-col justify-between ${
                    isCurrentTier ? &apos;border-green-500 bg-green-50&apos; : &apos;&apos;
                  }`}
                &gt;
                  &lt;div&gt;
                    &lt;div className=&quot;flex justify-between items-start&quot;&gt;
                      &lt;h3 className=&quot;text-lg font-medium&quot;&gt;{tier.name}&lt;/h3&gt;
                      {isCurrentTier &amp;&amp; (
                        &lt;span className=&quot;text-xs bg-green-500 text-white px-2 py-1 rounded&quot;&gt;
                          Current
                        &lt;/span&gt;
                      )}
                    &lt;/div&gt;
                    {tier.description &amp;&amp; (
                      &lt;p className=&quot;text-sm text-gray-600 mt-1&quot;&gt;{tier.description}&lt;/p&gt;
                    )}
                    &lt;p className=&quot;text-2xl font-bold mt-4&quot;&gt;
                      ${(tier.priceInCents / 100).toFixed(2)}
                      &lt;span className=&quot;text-sm font-normal text-gray-500&quot;&gt;/month&lt;/span&gt;
                    &lt;/p&gt;
                  &lt;/div&gt;
                  {!isActiveSubscriber &amp;&amp; (
                    &lt;Link
                      href={`/subscribe/${creator.username}/${tier.id}`}
                      className=&quot;mt-4 block text-center px-4 py-2 bg-black text-white rounded hover:bg-gray-800 transition&quot;
                    &gt;
                      Subscribe
                    &lt;/Link&gt;
                  )}
                &lt;/div&gt;
              )
            })}
          &lt;/div&gt;
        )}
      &lt;/div&gt;
    &lt;/main&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="test-content-gating">Test content gating</h3><p>Let&#x2019;s test if the content gating works:</p><ol><li>Create a post from the creator dashboard</li><li>Sign in with a non-subscriber account and go to the creator page, you won&#x2019;t see the posts</li><li>Sign in with a subscriber account, you&#x2019;ll see the posts of the creator above the tiers</li></ol><h2 id="step-11-creator-payouts">Step 11: Creator payouts</h2><p>When a customer pays for a subscription, the money they paid goes into the creator&#x2019;s Whop balance, so, you need to create a page that lets your creators withdraw funds to their bank account. Whop provides two options:</p><ul><li><strong>Hosted payout portal -</strong> Redirects creators to a Whop-hosted page</li><li><strong>Embedded payout components -</strong> Embed the payout interface directly to your app</li></ul><p>In this step, we&#x2019;re going to use the hosted payout portal since it&#x2019;s simpler and doesn&#x2019;t require you to install any extra dependencies. If you wish to learn more about the embedded payout components, check out the <a href="https://docs.whop.com/developer/platforms/render-payout-portal#embedded-payout-portal">Embedded payout portal guide in our documentation</a>.</p><h3 id="create-the-payout-portal-api-route">Create the payout portal API route</h3><p>You&#x2019;re going to need an endpoint that generates a temporary link to the Whop payout portal. You can do this by going into the <code>app/api/creator/payouts</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &apos;next/server&apos;
import { requireCreator } from &apos;@/lib/auth&apos;
import { whop } from &apos;@/lib/whop&apos;

export async function POST() {
  const { creator, error } = await requireCreator()
  if (error) return error

  if (!creator.whopCompanyId) {
    return NextResponse.json(
      { error: &apos;Creator account not set up for payments&apos; },
      { status: 400 }
    )
  }

  const baseUrl = process.env.AUTH_URL || &apos;http://localhost:3000&apos;

  const accountLink = await whop.accountLinks.create({
    company_id: creator.whopCompanyId,
    use_case: &apos;payouts_portal&apos;,
    return_url: `${baseUrl}/creator/payouts?returned=true`,
    refresh_url: `${baseUrl}/creator/payouts`,
  })

  return NextResponse.json({ url: accountLink.url })
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-payout-page">Create the payout page</h3><p>Now, to create the actual page your creators go when they want payouts, go to the <code>app/creator/payouts</code> folder 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &apos;next/navigation&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import Link from &apos;next/link&apos;
import PayoutButton from &apos;./PayoutButton&apos;

interface PayoutsPageProps {
  searchParams: Promise&lt;{ returned?: string }&gt;
}

export default async function PayoutsPage({ searchParams }: PayoutsPageProps) {
  const { returned } = await searchParams
  const user = await getCurrentUser()

  if (!user) {
    redirect(&apos;/signin&apos;)
  }

  const creator = await prisma.creator.findUnique({
    where: { userId: user.id },
  })

  if (!creator) {
    redirect(&apos;/creator/register&apos;)
  }

  return (
    &lt;main className=&quot;min-h-screen p-8 max-w-4xl mx-auto&quot;&gt;
      &lt;div className=&quot;flex justify-between items-center mb-8&quot;&gt;
        &lt;div&gt;
          &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;Payouts&lt;/h1&gt;
          &lt;p className=&quot;text-gray-600&quot;&gt;Manage your earnings and withdrawals&lt;/p&gt;
        &lt;/div&gt;
        &lt;Link
          href=&quot;/creator/dashboard&quot;
          className=&quot;text-sm text-blue-600 hover:underline&quot;
        &gt;
          &#x2190; Back to dashboard
        &lt;/Link&gt;
      &lt;/div&gt;

      {returned === &apos;true&apos; &amp;&amp; (
        &lt;div className=&quot;mb-6 p-4 bg-green-50 border border-green-200 rounded-lg&quot;&gt;
          &lt;p className=&quot;text-green-800&quot;&gt;
            Payout settings updated successfully.
          &lt;/p&gt;
        &lt;/div&gt;
      )}

      {!creator.whopOnboarded ? (
        &lt;div className=&quot;p-6 bg-yellow-50 border border-yellow-200 rounded-lg&quot;&gt;
          &lt;h2 className=&quot;font-medium mb-2&quot;&gt;Complete account setup first&lt;/h2&gt;
          &lt;p className=&quot;text-sm text-gray-600 mb-4&quot;&gt;
            You need to complete your creator onboarding before you can access payouts.
          &lt;/p&gt;
          &lt;Link
            href=&quot;/creator/dashboard&quot;
            className=&quot;inline-block px-4 py-2 bg-black text-white rounded hover:bg-gray-800 transition&quot;
          &gt;
            Go to dashboard
          &lt;/Link&gt;
        &lt;/div&gt;
      ) : (
        &lt;div className=&quot;space-y-6&quot;&gt;
          &lt;div className=&quot;p-6 border rounded-lg&quot;&gt;
            &lt;h2 className=&quot;text-lg font-medium mb-2&quot;&gt;Payout Portal&lt;/h2&gt;
            &lt;p className=&quot;text-gray-600 mb-4&quot;&gt;
              Access Whop&apos;s payout portal to view your balance, complete identity verification, add payout methods, and withdraw your earnings.
            &lt;/p&gt;
            &lt;PayoutButton /&gt;
          &lt;/div&gt;

          &lt;div className=&quot;p-6 bg-gray-50 rounded-lg&quot;&gt;
            &lt;h3 className=&quot;font-medium mb-2&quot;&gt;How payouts work&lt;/h3&gt;
            &lt;ul className=&quot;text-sm text-gray-600 space-y-2&quot;&gt;
              &lt;li&gt;&#x2022; Subscriber payments go to your Whop company balance&lt;/li&gt;
              &lt;li&gt;&#x2022; Complete identity verification (KYC) to enable withdrawals&lt;/li&gt;
              &lt;li&gt;&#x2022; Add a bank account or other payout method&lt;/li&gt;
              &lt;li&gt;&#x2022; Withdraw funds manually or set up automatic payouts&lt;/li&gt;
            &lt;/ul&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      )}
    &lt;/main&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-payout-button">Create the payout button</h3><p>The payout links are temporary, and creators need a way to easily go to the payout page. To do this, let&#x2019;s create a payout button. Go to the <code>app/creator/payouts</code> folder and create a file called <code>PayoutButton.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">PayoutButton.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import { useState } from &apos;react&apos;

export default function PayoutButton() {
  const [loading, setLoading] = useState(false)

  async function handleClick() {
    setLoading(true)

    try {
      const response = await fetch(&apos;/api/creator/payouts&apos;, {
        method: &apos;POST&apos;,
      })

      if (!response.ok) {
        throw new Error(&apos;Failed to get payout portal link&apos;)
      }

      const { url } = await response.json()
      window.location.href = url
    } catch (error) {
      console.error(&apos;Error:&apos;, error)
      alert(&apos;Failed to open payout portal. Please try again.&apos;)
      setLoading(false)
    }
  }

  return (
    &lt;button
      onClick={handleClick}
      disabled={loading}
      className=&quot;px-4 py-2 bg-black text-white rounded hover:bg-gray-800 transition disabled:opacity-50&quot;
    &gt;
      {loading ? &apos;Opening...&apos; : &apos;Open Payout Portal&apos;}
    &lt;/button&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="add-a-link-to-the-payout-page-to-dashboard">Add a link to the payout page to dashboard</h3><p>Let&#x2019;s update the creator dashboard (at <code>app/creator/dashboard/page.tsx</code>) by adding the code below to create a <strong>Payouts</strong> button. You can place it anywhere you like on the page:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&lt;Link
  href=&quot;/creator/payouts&quot;
  className=&quot;block p-4 border rounded-lg hover:bg-gray-50 transition&quot;
&gt;
  &lt;h3 className=&quot;font-medium&quot;&gt;Payouts&lt;/h3&gt;
  &lt;p className=&quot;text-sm text-gray-600&quot;&gt;View balance and withdraw earnings&lt;/p&gt;
&lt;/Link&gt;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="testing-the-payouts">Testing the payouts</h3><ol><li>Sign in as a creator (make sure you&#x2019;ve completed your account setup and KYC)</li><li>Go to your creator dashboard and click <strong>Payouts</strong> and <strong>Open payout portal</strong></li><li>You&#x2019;ll be redirected to the Whop&#x2019;s hosted payout portal where you can configure and receive payouts</li></ol><h2 id="step-12-creating-the-homepage-subscriptions-and-creator-discovery">Step 12: Creating the homepage, subscriptions, and creator discovery</h2><p>Now, your app is almost complete, but there are a few missing pages. Like the homepage, or the subscription dashboard.</p><p>Let&#x2019;s create those pages, but first, you should make a few changes in your database to make sure the cancelling process in the subscription dashboard works correctly.</p><h3 id="update-the-prisma-schema">Update the Prisma schema</h3><p>There are two main ways you can let customers cancel their subscriptions: you can let them cancel it immediately, or cancel at the end of the subscription period (month). The second option is much more common, so let&#x2019;s implement that.</p><p>First, let&#x2019;s open the <code>prisma</code> folder and edit the <code>schema.prisma</code> file so that the <code>SubscriptionStatus</code> part has the contents:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">enum SubscriptionStatus {
  ACTIVE
  CANCELING
  CANCELED
  PAST_DUE
  EXPIRED
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then, run the migration command on your terminal:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">typescript</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">npx prisma migrate dev --name add_canceling_status</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-homepage">Create the homepage</h3><p>As a Patreon clone, your project needs a homepage. Next.js already provides one with the <code>page.tsx</code> file in the <code>app</code> folder. To customize it, let&#x2019;s open the <code>page.tsx</code> file and replace its contents with:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { prisma } from &apos;@/lib/prisma&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import Link from &apos;next/link&apos;
import CreatorSearch from &apos;./CreatorSearch&apos;
import FAQAccordion from &apos;./FAQAccordion&apos;

const CREATORS_PER_PAGE = 12

interface HomePageProps {
  searchParams: Promise&lt;{ page?: string }&gt;
}

export default async function HomePage({ searchParams }: HomePageProps) {
  const { page } = await searchParams
  const user = await getCurrentUser()

  const currentPage = Math.max(1, parseInt(page || &apos;1&apos;, 10))
  const skip = (currentPage - 1) * CREATORS_PER_PAGE

  const [creators, totalCount] = await Promise.all([
    prisma.creator.findMany({
      select: {
        id: true,
        username: true,
        displayName: true,
      },
      orderBy: { createdAt: &apos;desc&apos; },
      skip,
      take: CREATORS_PER_PAGE,
    }),
    prisma.creator.count(),
  ])

  const totalPages = Math.ceil(totalCount / CREATORS_PER_PAGE)

  return (
    &lt;main className=&quot;min-h-screen bg-gradient-to-br from-green-50 via-white to-emerald-50 bg-[length:400%_400%] animate-gradient&quot;&gt;
      &lt;div className=&quot;py-20 px-8&quot;&gt;
        &lt;div className=&quot;max-w-4xl mx-auto text-center&quot;&gt;
          &lt;h1 className=&quot;text-5xl font-bold mb-6 text-gray-900&quot;&gt;
            Support creators you love
          &lt;/h1&gt;
          &lt;p className=&quot;text-xl text-gray-600 mb-8 max-w-2xl mx-auto&quot;&gt;
            Subscribe to your favorite creators and get access to exclusive content. Join a community of fans and creators.
          &lt;/p&gt;
          {user ? (
            &lt;Link
              href=&quot;/dashboard&quot;
              className=&quot;inline-flex items-center gap-2 px-6 py-3 bg-green-500 text-white font-medium rounded-lg hover:bg-green-600 hover:shadow-lg hover:scale-105 transition-all duration-200&quot;
            &gt;
              Go to Dashboard
            &lt;/Link&gt;
          ) : (
            &lt;Link
              href=&quot;/signin&quot;
              className=&quot;inline-flex items-center gap-2 px-6 py-3 bg-green-500 text-white font-medium rounded-lg hover:bg-green-600 hover:shadow-lg hover:scale-105 transition-all duration-200&quot;
            &gt;
              &lt;img
                src=&quot;/SignIn.svg&quot;
                alt=&quot;&quot;
                width={20}
                height={20}
                className=&quot;brightness-0 invert&quot;
              /&gt;
              Get started
            &lt;/Link&gt;
          )}
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div className=&quot;py-16 px-8 bg-gray-50&quot;&gt;
        &lt;div className=&quot;max-w-4xl mx-auto&quot;&gt;
          &lt;h2 className=&quot;text-2xl font-bold text-center mb-12 text-gray-900&quot;&gt;How it works&lt;/h2&gt;
          &lt;div className=&quot;grid md:grid-cols-3 gap-8&quot;&gt;
            &lt;div className=&quot;text-center p-6 rounded-xl transition-all duration-300 hover:bg-white hover:shadow-lg hover:-translate-y-1&quot;&gt;
              &lt;div className=&quot;w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4 transition-transform duration-300 hover:scale-110&quot;&gt;
                &lt;img
                  src=&quot;/FindCreators.svg&quot;
                  alt=&quot;Find creators&quot;
                  width={32}
                  height={32}
                  className=&quot;brightness-0 invert&quot;
                /&gt;
              &lt;/div&gt;
              &lt;h3 className=&quot;font-semibold mb-2 text-gray-900&quot;&gt;Find creators&lt;/h3&gt;
              &lt;p className=&quot;text-gray-600 text-sm&quot;&gt;
                Discover creators who share content you care about.
              &lt;/p&gt;
            &lt;/div&gt;
            &lt;div className=&quot;text-center p-6 rounded-xl transition-all duration-300 hover:bg-white hover:shadow-lg hover:-translate-y-1&quot;&gt;
              &lt;div className=&quot;w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4 transition-transform duration-300 hover:scale-110&quot;&gt;
                &lt;img
                  src=&quot;/Subscribe.svg&quot;
                  alt=&quot;Subscribe&quot;
                  width={32}
                  height={32}
                  className=&quot;brightness-0 invert&quot;
                /&gt;
              &lt;/div&gt;
              &lt;h3 className=&quot;font-semibold mb-2 text-gray-900&quot;&gt;Subscribe&lt;/h3&gt;
              &lt;p className=&quot;text-gray-600 text-sm&quot;&gt;
                Choose a tier that fits your budget and subscribe monthly.
              &lt;/p&gt;
            &lt;/div&gt;
            &lt;div className=&quot;text-center p-6 rounded-xl transition-all duration-300 hover:bg-white hover:shadow-lg hover:-translate-y-1&quot;&gt;
              &lt;div className=&quot;w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4 transition-transform duration-300 hover:scale-110&quot;&gt;
                &lt;img
                  src=&quot;/EnjoyContent.svg&quot;
                  alt=&quot;Enjoy content&quot;
                  width={32}
                  height={32}
                  className=&quot;brightness-0 invert&quot;
                /&gt;
              &lt;/div&gt;
              &lt;h3 className=&quot;font-semibold mb-2 text-gray-900&quot;&gt;Enjoy content&lt;/h3&gt;
              &lt;p className=&quot;text-gray-600 text-sm&quot;&gt;
                Get access to exclusive posts and support creators directly.
              &lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div className=&quot;py-16 px-8&quot;&gt;
        &lt;div className=&quot;max-w-4xl mx-auto&quot;&gt;
          &lt;h2 className=&quot;text-2xl font-bold text-center mb-8 text-gray-900&quot;&gt;Find creators&lt;/h2&gt;
          &lt;CreatorSearch
            creators={creators}
            currentPage={currentPage}
            totalPages={totalPages}
          /&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div className=&quot;py-16 px-8 bg-gray-50&quot;&gt;
        &lt;div className=&quot;max-w-3xl mx-auto&quot;&gt;
          &lt;h2 className=&quot;text-2xl font-bold text-center mb-12 text-gray-900&quot;&gt;Frequently Asked Questions&lt;/h2&gt;
          &lt;FAQAccordion /&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">Make sure to upload the icons you want to use into your <code spellcheck="false" style="white-space: pre-wrap;">public</code> folder.</div></div><h3 id="create-the-creator-search">Create the creator search</h3><p>In the homepage, letting customers (even if they&#x2019;re <strong>not</strong> a member) search existing creator profiles is a good idea. To do that, let&#x2019;s implement a search field by going into the <code>app</code> folder and creating a file called <code>CreatorSearch.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">CreatorSearch.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import { useState } from &apos;react&apos;
import Link from &apos;next/link&apos;
import { useRouter } from &apos;next/navigation&apos;

interface Creator {
  id: string
  username: string
  displayName: string
}

interface CreatorSearchProps {
  creators: Creator[]
  currentPage: number
  totalPages: number
}

export default function CreatorSearch({ creators, currentPage, totalPages }: CreatorSearchProps) {
  const router = useRouter()
  const [search, setSearch] = useState(&apos;&apos;)

  const filteredCreators = creators.filter((creator) =&gt; {
    const searchLower = search.toLowerCase()
    return (
      creator.displayName.toLowerCase().includes(searchLower) ||
      creator.username.toLowerCase().includes(searchLower)
    )
  })

  function goToPage(page: number) {
    router.push(`/?page=${page}`)
  }

  return (
    &lt;div&gt;
      &lt;input
        type=&quot;text&quot;
        placeholder=&quot;Search by name or username...&quot;
        value={search}
        onChange={(e) =&gt; setSearch(e.target.value)}
        className=&quot;w-full p-3 border border-gray-300 rounded-lg mb-6 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent&quot;
      /&gt;

      {filteredCreators.length === 0 ? (
        &lt;p className=&quot;text-center text-gray-500&quot;&gt;
          {search ? &apos;No creators found.&apos; : &apos;No creators yet.&apos;}
        &lt;/p&gt;
      ) : (
        &lt;&gt;
          &lt;div className=&quot;grid gap-4 md:grid-cols-2 lg:grid-cols-3&quot;&gt;
            {filteredCreators.map((creator) =&gt; (
              &lt;Link
                key={creator.id}
                href={`/creator/${creator.username}`}
                className=&quot;block p-4 bg-white border border-gray-200 rounded-lg hover:border-green-300 hover:shadow-sm transition&quot;
              &gt;
                &lt;p className=&quot;font-medium text-gray-900&quot;&gt;{creator.displayName}&lt;/p&gt;
                &lt;p className=&quot;text-sm text-gray-500&quot;&gt;@{creator.username}&lt;/p&gt;
              &lt;/Link&gt;
            ))}
          &lt;/div&gt;

          {totalPages &gt; 1 &amp;&amp; !search &amp;&amp; (
            &lt;div className=&quot;flex items-center justify-center gap-4 mt-8&quot;&gt;
              &lt;button
                onClick={() =&gt; goToPage(currentPage - 1)}
                disabled={currentPage &lt;= 1}
                className=&quot;px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed&quot;
              &gt;
                Previous
              &lt;/button&gt;
              &lt;span className=&quot;text-sm text-gray-600&quot;&gt;
                Page {currentPage} of {totalPages}
              &lt;/span&gt;
              &lt;button
                onClick={() =&gt; goToPage(currentPage + 1)}
                disabled={currentPage &gt;= totalPages}
                className=&quot;px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed&quot;
              &gt;
                Next
              &lt;/button&gt;
            &lt;/div&gt;
          )}
        &lt;/&gt;
      )}
    &lt;/div&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-faq-section">Create the FAQ section</h3><p>To make the homepage a bit more informative, let&apos;s add an FAQ section to the homepage with dropdowns. Go to the <code>app</code> folder and create a file called <code>FAQAccordion.tsx</code> with the content (and customize FAQs and answers):</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">FAQAccordion.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import { useState } from &apos;react&apos;

const faqs = [
  {
    question: &apos;How do I subscribe to a creator?&apos;,
    answer: &quot;Find a creator you like, choose a subscription tier that fits your budget, and complete the checkout. You&apos;ll get instant access to their exclusive content.&quot;,
  },
  {
    question: &apos;Can I cancel my subscription anytime?&apos;,
    answer: &quot;Yes, you can cancel your subscription at any time. You&apos;ll continue to have access until the end of your current billing period.&quot;,
  },
  {
    question: &apos;How do creators get paid?&apos;,
    answer: &apos;Creators receive payouts directly to their bank account after completing identity verification. Payments are processed securely through our payment partner.&apos;,
  },
  {
    question: &apos;What payment methods are accepted?&apos;,
    answer: &apos;We accept all major credit and debit cards. All payments are processed securely and your payment information is never stored on our servers.&apos;,
  },
  {
    question: &apos;How do I become a creator?&apos;,
    answer: &apos;Sign up for an account, then apply to become a creator from your dashboard. Once approved, you can set up your profile, create subscription tiers, and start posting content.&apos;,
  },
  {
    question: &apos;Is my payment information secure?&apos;,
    answer: &apos;Absolutely. We use industry-standard encryption and never store your full payment details. All transactions are processed through secure, PCI-compliant payment processors.&apos;,
  },
]

export default function FAQAccordion() {
  const [openIndex, setOpenIndex] = useState&lt;number | null&gt;(null)

  const toggle = (index: number) =&gt; {
    setOpenIndex(openIndex === index ? null : index)
  }

  return (
    &lt;div className=&quot;space-y-4&quot;&gt;
      {faqs.map((faq, index) =&gt; (
        &lt;div
          key={index}
          className=&quot;bg-white rounded-lg shadow-sm overflow-hidden transition-all duration-200 hover:shadow-md&quot;
        &gt;
          &lt;button
            onClick={() =&gt; toggle(index)}
            className=&quot;w-full px-6 py-4 text-left flex justify-between items-center gap-4&quot;
          &gt;
            &lt;h3 className=&quot;font-semibold text-gray-900&quot;&gt;{faq.question}&lt;/h3&gt;
            &lt;svg
              className={`w-5 h-5 text-gray-500 transition-transform duration-200 flex-shrink-0 ${
                openIndex === index ? &apos;rotate-180&apos; : &apos;&apos;
              }`}
              fill=&quot;none&quot;
              viewBox=&quot;0 0 24 24&quot;
              stroke=&quot;currentColor&quot;
            &gt;
              &lt;path strokeLinecap=&quot;round&quot; strokeLinejoin=&quot;round&quot; strokeWidth={2} d=&quot;M19 9l-7 7-7-7&quot; /&gt;
            &lt;/svg&gt;
          &lt;/button&gt;
          &lt;div
            className={`overflow-hidden transition-all duration-200 ${
              openIndex === index ? &apos;max-h-40 pb-4&apos; : &apos;max-h-0&apos;
            }`}
          &gt;
            &lt;p className=&quot;px-6 text-gray-600 text-sm&quot;&gt;{faq.answer}&lt;/p&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  )
}
</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-subscriptions-dashboard">Create the subscriptions dashboard</h3><p>Now that you have a home page, let&#x2019;s take a look at the subscriptions dashboard. It&#x2019;s where your users can view and manage their subscriptions.</p><p>Go to <code>app/subscriptions</code> folder 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &apos;next/navigation&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import Link from &apos;next/link&apos;
import CancelButton from &apos;./CancelButton&apos;

export default async function SubscriptionsPage() {
  const user = await getCurrentUser()

  if (!user) {
    redirect(&apos;/signin&apos;)
  }

  const subscriptions = await prisma.subscription.findMany({
    where: {
      userId: user.id,
      status: { in: [&apos;ACTIVE&apos;, &apos;CANCELING&apos;] },
    },
    include: {
      creator: true,
      tier: true,
    },
    orderBy: { createdAt: &apos;desc&apos; },
  })

  return (
    &lt;main className=&quot;min-h-screen p-8 max-w-4xl mx-auto&quot;&gt;
      &lt;div className=&quot;flex justify-between items-center mb-8&quot;&gt;
        &lt;div&gt;
          &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;My Subscriptions&lt;/h1&gt;
          &lt;p className=&quot;text-gray-600&quot;&gt;Manage your active subscriptions&lt;/p&gt;
        &lt;/div&gt;
        &lt;Link
          href=&quot;/dashboard&quot;
          className=&quot;text-sm text-blue-600 hover:underline&quot;
        &gt;
          &#x2190; Back to dashboard
        &lt;/Link&gt;
      &lt;/div&gt;

      {subscriptions.length === 0 ? (
        &lt;div className=&quot;text-center py-12&quot;&gt;
          &lt;p className=&quot;text-gray-500 mb-4&quot;&gt;You don&apos;t have any active subscriptions.&lt;/p&gt;
          &lt;Link
            href=&quot;/&quot;
            className=&quot;text-blue-600 hover:underline&quot;
          &gt;
            Discover creators to subscribe to
          &lt;/Link&gt;
        &lt;/div&gt;
      ) : (
        &lt;div className=&quot;space-y-4&quot;&gt;
          {subscriptions.map((subscription) =&gt; (
            &lt;div
              key={subscription.id}
              className=&quot;p-6 border rounded-lg&quot;
            &gt;
              &lt;div className=&quot;flex justify-between items-start&quot;&gt;
                &lt;div&gt;
                  &lt;h3 className=&quot;font-medium text-lg&quot;&gt;
                    {subscription.creator.displayName}
                  &lt;/h3&gt;
                  &lt;p className=&quot;text-sm text-gray-500&quot;&gt;
                    @{subscription.creator.username}
                  &lt;/p&gt;
                &lt;/div&gt;
                &lt;div className=&quot;text-right&quot;&gt;
                  &lt;p className=&quot;font-medium&quot;&gt;
                    ${(subscription.tier.priceInCents / 100).toFixed(2)}/month
                  &lt;/p&gt;
                  &lt;p className=&quot;text-sm text-gray-500&quot;&gt;
                    {subscription.tier.name}
                  &lt;/p&gt;
                &lt;/div&gt;
              &lt;/div&gt;

              &lt;div className=&quot;mt-4 flex items-center justify-between&quot;&gt;
                &lt;div className=&quot;flex items-center gap-4&quot;&gt;
                  {subscription.status === &apos;ACTIVE&apos; &amp;&amp; (
                    &lt;span className=&quot;text-xs bg-green-100 text-green-600 px-2 py-1 rounded&quot;&gt;
                      Active
                    &lt;/span&gt;
                  )}
                  {subscription.status === &apos;CANCELING&apos; &amp;&amp; (
                    &lt;span className=&quot;text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded&quot;&gt;
                      Cancels at period end
                    &lt;/span&gt;
                  )}
                  &lt;span className=&quot;text-xs text-gray-400&quot;&gt;
                    Subscribed {new Date(subscription.createdAt).toLocaleDateString()}
                  &lt;/span&gt;
                &lt;/div&gt;

                &lt;div className=&quot;flex items-center gap-3&quot;&gt;
                  &lt;Link
                    href={`/creator/${subscription.creator.username}`}
                    className=&quot;text-sm text-blue-600 hover:underline&quot;
                  &gt;
                    View profile
                  &lt;/Link&gt;
                  {subscription.status === &apos;ACTIVE&apos; &amp;&amp; (
                    &lt;CancelButton subscriptionId={subscription.id} /&gt;
                  )}
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;
          ))}
        &lt;/div&gt;
      )}
    &lt;/main&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-cancel-button">Create the cancel button</h3><p>Your subscription page needs a cancel button to let users easily cancel subscriptions. Let&#x2019;s create the cancel button by going into the <code>app/subscriptions</code> folder and creating a file called <code>CancelButton.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">CancelButton.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&apos;use client&apos;

import { useState } from &apos;react&apos;
import { useRouter } from &apos;next/navigation&apos;

interface CancelButtonProps {
  subscriptionId: string
}

export default function CancelButton({ subscriptionId }: CancelButtonProps) {
  const router = useRouter()
  const [loading, setLoading] = useState(false)
  const [showConfirm, setShowConfirm] = useState(false)

  async function handleCancel() {
    setLoading(true)

    try {
      const response = await fetch(`/api/subscriptions/${subscriptionId}/cancel`, {
        method: &apos;POST&apos;,
      })

      if (!response.ok) {
        const data = await response.json()
        alert(data.error || &apos;Failed to cancel subscription&apos;)
        return
      }

      router.refresh()
    } catch (error) {
      alert(&apos;Something went wrong. Please try again.&apos;)
    } finally {
      setLoading(false)
      setShowConfirm(false)
    }
  }

  if (showConfirm) {
    return (
      &lt;div className=&quot;flex items-center gap-2&quot;&gt;
        &lt;span className=&quot;text-sm text-gray-600&quot;&gt;Cancel subscription?&lt;/span&gt;
        &lt;button
          onClick={handleCancel}
          disabled={loading}
          className=&quot;text-sm text-red-600 hover:underline disabled:opacity-50&quot;
        &gt;
          {loading ? &apos;Canceling...&apos; : &apos;Yes, cancel&apos;}
        &lt;/button&gt;
        &lt;button
          onClick={() =&gt; setShowConfirm(false)}
          disabled={loading}
          className=&quot;text-sm text-gray-600 hover:underline&quot;
        &gt;
          No
        &lt;/button&gt;
      &lt;/div&gt;
    )
  }

  return (
    &lt;button
      onClick={() =&gt; setShowConfirm(true)}
      className=&quot;text-sm text-red-600 hover:underline&quot;
    &gt;
      Cancel
    &lt;/button&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-cancel-api-route">Create the cancel API route</h3><p>You have the page, you have the buttons, now it&#x2019;s time to create an API route to actually make the cancellation happen. Go to the <code>app/api/subscriptions/[id]/cancel</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &apos;next/server&apos;
import { requireAuth } from &apos;@/lib/auth&apos;
import { prisma } from &apos;@/lib/prisma&apos;
import { whop } from &apos;@/lib/whop&apos;

interface RouteParams {
  params: Promise&lt;{ id: string }&gt;
}

export async function POST(request: NextRequest, { params }: RouteParams) {
  const { user, error } = await requireAuth()
  if (error) return error

  const { id } = await params

  const subscription = await prisma.subscription.findUnique({
    where: { id },
  })

  if (!subscription) {
    return NextResponse.json({ error: &apos;Subscription not found&apos; }, { status: 404 })
  }

  if (subscription.userId !== user.id) {
    return NextResponse.json({ error: &apos;Not authorized&apos; }, { status: 403 })
  }

  if (subscription.status !== &apos;ACTIVE&apos;) {
    return NextResponse.json(
      { error: &apos;Subscription is not active&apos; },
      { status: 400 }
    )
  }

  if (!subscription.whopMembershipId) {
    return NextResponse.json(
      { error: &apos;Subscription is not linked to Whop&apos; },
      { status: 400 }
    )
  }

  try {
    await whop.memberships.cancel(subscription.whopMembershipId, {
      cancellation_mode: &apos;at_period_end&apos;,
    })

    await prisma.subscription.update({
      where: { id },
      data: { status: &apos;CANCELING&apos; },
    })

    return NextResponse.json({ success: true })
  } catch (error) {
    console.error(&apos;Cancel subscription error:&apos;, error)
    return NextResponse.json(
      { error: &apos;Failed to cancel subscription&apos; },
      { status: 500 }
    )
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="update-the-webhook-handler">Update the webhook handler</h3><p>Now let&#x2019;s update our webhooks so that Whop and your project can communicate and you can keep your database up-to-date. To do that, you need a webhook handler to listen to the <code>membership.cancel_at_period_end_changed</code> event from whop.</p><p>Go to the <code>app/api/webhooks/whop</code> file and update the existing <code>route.ts</code> file 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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &apos;next/server&apos;
import { whop } from &apos;@/lib/whop&apos;
import { prisma } from &apos;@/lib/prisma&apos;

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

  try {
    const webhookData = whop.webhooks.unwrap(rawBody, { headers })
    const { type, data } = webhookData as any

    if (type === &apos;payment.succeeded&apos;) {
      await handlePaymentSucceeded(data)
    } else if (type === &apos;membership.cancel_at_period_end_changed&apos;) {
      await handleCancelAtPeriodEndChanged(data)
    } else if (type === &apos;membership.deactivated&apos;) {
      await handleMembershipDeactivated(data)
    }

    return NextResponse.json({ received: true })
  } catch (error) {
    console.error(&apos;Webhook verification failed:&apos;, error)
    return NextResponse.json(
      { error: &apos;Invalid webhook signature&apos; },
      { status: 401 }
    )
  }
}

async function handlePaymentSucceeded(data: any) {
  const metadata = data.checkout_configuration?.metadata || data.metadata

  const platformUserId = metadata?.platform_user_id
  const platformCreatorId = metadata?.platform_creator_id
  const platformTierId = metadata?.platform_tier_id
  const membershipId = data.membership?.id || data.id

  if (!platformUserId || !platformCreatorId || !platformTierId) {
    console.error(&apos;Missing platform metadata in payment:&apos;, { metadata })
    return
  }

  const existingSubscription = await prisma.subscription.findFirst({
    where: {
      userId: platformUserId,
      creatorId: platformCreatorId,
    },
  })

  if (existingSubscription) {
    await prisma.subscription.update({
      where: { id: existingSubscription.id },
      data: { status: &apos;ACTIVE&apos;, whopMembershipId: membershipId },
    })
    return
  }

  await prisma.subscription.create({
    data: {
      userId: platformUserId,
      creatorId: platformCreatorId,
      tierId: platformTierId,
      whopMembershipId: membershipId,
      status: &apos;ACTIVE&apos;,
    },
  })
}

async function handleCancelAtPeriodEndChanged(data: any) {
  const membershipId = data.id

  if (!membershipId) {
    console.error(&apos;Missing membership ID in webhook&apos;)
    return
  }

  const subscription = await prisma.subscription.findUnique({
    where: { whopMembershipId: membershipId },
  })

  if (!subscription) {
    console.error(&apos;Subscription not found for membership:&apos;, membershipId)
    return
  }

  const newStatus = data.cancel_at_period_end ? &apos;CANCELING&apos; : &apos;ACTIVE&apos;

  await prisma.subscription.update({
    where: { id: subscription.id },
    data: { status: newStatus },
  })
}

async function handleMembershipDeactivated(data: any) {
  const membershipId = data.id

  const subscription = await prisma.subscription.findUnique({
    where: { whopMembershipId: membershipId },
  })

  if (!subscription) {
    console.error(&apos;Subscription not found for membership:&apos;, membershipId)
    return
  }

  await prisma.subscription.update({
    where: { id: subscription.id },
    data: { status: &apos;CANCELED&apos; },
  })
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="update-webhook-settings-on-whop">Update webhook settings on Whop</h3><p>Now, for your webhook handler to actually get the webhook messages, let&#x2019;s go back to Whop and enable the <code>membership_cancel_at_period_end_changed</code> event:</p><ol><li>Go to your Whop dashboard and open the Developers page</li><li>Click on the context menu button of the webhook you created and select <strong>Edit</strong></li><li>In the Events list, find the <code>membership_cancel_at_period_end_changed</code> option and enable it</li><li>Click <strong>Save</strong></li></ol><figure class="kg-card kg-video-card kg-width-regular" data-kg-thumbnail="https://whop.com/blog/content/media/2026/01/WebhookEdit_thumb.jpg" data-kg-custom-thumbnail>
            <div class="kg-video-container">
                <video src="https://whop.com/blog/content/media/2026/01/WebhookEdit.mp4" poster="https://img.spacergif.org/v1/1920x1080/0a/spacer.png" width="1920" height="1080" loop autoplay muted playsinline preload="metadata" style="background: transparent url(&apos;https://whop.com/blog/content/media/2026/01/WebhookEdit_thumb.jpg&apos;) 50% 50% / cover no-repeat;"></video>
                <div class="kg-video-overlay">
                    <button class="kg-video-large-play-icon" aria-label="Play video">
                        <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                            <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"/>
                        </svg>
                    </button>
                </div>
                <div class="kg-video-player-container kg-video-hide">
                    <div class="kg-video-player">
                        <button class="kg-video-play-icon" aria-label="Play video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"/>
                            </svg>
                        </button>
                        <button class="kg-video-pause-icon kg-video-hide" aria-label="Pause video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <rect x="3" y="1" width="7" height="22" rx="1.5" ry="1.5"/>
                                <rect x="14" y="1" width="7" height="22" rx="1.5" ry="1.5"/>
                            </svg>
                        </button>
                        <span class="kg-video-current-time">0:00</span>
                        <div class="kg-video-time">
                            /<span class="kg-video-duration">0:11</span>
                        </div>
                        <input type="range" class="kg-video-seek-slider" max="100" value="0">
                        <button class="kg-video-playback-rate" aria-label="Adjust playback speed">1&#xD7;</button>
                        <button class="kg-video-unmute-icon" aria-label="Unmute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <path d="M15.189 2.021a9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h1.794a.249.249 0 0 1 .221.133 9.73 9.73 0 0 0 7.924 4.85h.06a1 1 0 0 0 1-1V3.02a1 1 0 0 0-1.06-.998Z"/>
                            </svg>
                        </button>
                        <button class="kg-video-mute-icon kg-video-hide" aria-label="Mute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                                <path d="M16.177 4.3a.248.248 0 0 0 .073-.176v-1.1a1 1 0 0 0-1.061-1 9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h.114a.251.251 0 0 0 .177-.073ZM23.707 1.706A1 1 0 0 0 22.293.292l-22 22a1 1 0 0 0 0 1.414l.009.009a1 1 0 0 0 1.405-.009l6.63-6.631A.251.251 0 0 1 8.515 17a.245.245 0 0 1 .177.075 10.081 10.081 0 0 0 6.5 2.92 1 1 0 0 0 1.061-1V9.266a.247.247 0 0 1 .073-.176Z"/>
                            </svg>
                        </button>
                        <input type="range" class="kg-video-volume-slider" max="100" value="100">
                    </div>
                </div>
            </div>
            
        </figure><h3 id="update-the-middleware-file">Update the middleware file</h3><p>The <code>/subscriptions</code> page should be protected, so we should declare that in the <code>middleware.ts</code> file in your project root. Update the file content with:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">middleware.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &apos;next/server&apos;
import type { NextRequest } from &apos;next/server&apos;
import { getIronSession } from &apos;iron-session&apos;
import { sessionOptions, SessionData } from &apos;@/lib/session&apos;

export async function middleware(request: NextRequest) {
  const response = NextResponse.next()

  const session = await getIronSession&lt;SessionData&gt;(
    request.cookies as any,
    sessionOptions
  )

  const isProtected =
    request.nextUrl.pathname.startsWith(&apos;/dashboard&apos;) ||
    request.nextUrl.pathname.startsWith(&apos;/creator&apos;) ||
    request.nextUrl.pathname.startsWith(&apos;/subscriptions&apos;)

  if (isProtected &amp;&amp; !session.isLoggedIn) {
    return NextResponse.redirect(new URL(&apos;/signin&apos;, request.url))
  }

  return response
}

export const config = {
  matcher: [&apos;/dashboard/:path*&apos;, &apos;/creator/:path*&apos;, &apos;/subscriptions/:path*&apos;],
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-a-navigation-header">Create a navigation header</h3><p>Your users need an easy way to access the important pages in your project, and the easiest way to make one is creating a navigation header. Let&#x2019;s go to the <code>app</code> folder and create a file called <code>Header.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Header.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &apos;next/link&apos;
import { getCurrentUser } from &apos;@/lib/auth&apos;
import { prisma } from &apos;@/lib/prisma&apos;

export default async function Header() {
  const user = await getCurrentUser()

  let creator = null
  if (user) {
    creator = await prisma.creator.findUnique({
      where: { userId: user.id },
      select: { username: true },
    })
  }

  return (
    &lt;header className=&quot;border-b border-gray-200 bg-white&quot;&gt;
      &lt;div className=&quot;max-w-4xl mx-auto px-8 py-4 flex items-center justify-between&quot;&gt;
        &lt;Link href=&quot;/&quot; className=&quot;font-bold text-lg text-gray-900&quot;&gt;
          Creator Platform
        &lt;/Link&gt;

        &lt;nav className=&quot;flex items-center gap-6&quot;&gt;
          {user ? (
            &lt;&gt;
              &lt;Link
                href=&quot;/subscriptions&quot;
                className=&quot;text-sm text-gray-600 hover:text-green-500 transition&quot;
              &gt;
                Subscriptions
              &lt;/Link&gt;
              {creator ? (
                &lt;Link
                  href={`/creator/${creator.username}`}
                  className=&quot;text-sm text-gray-600 hover:text-green-500 transition&quot;
                &gt;
                  My Profile
                &lt;/Link&gt;
              ) : (
                &lt;Link
                  href=&quot;/dashboard&quot;
                  className=&quot;text-sm text-gray-600 hover:text-green-500 transition&quot;
                &gt;
                  Dashboard
                &lt;/Link&gt;
              )}
            &lt;/&gt;
          ) : (
            &lt;Link
              href=&quot;/signin&quot;
              className=&quot;text-sm px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition&quot;
            &gt;
              Sign in
            &lt;/Link&gt;
          )}
        &lt;/nav&gt;
      &lt;/div&gt;
    &lt;/header&gt;
  )
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>And update the <code>layout.tsx</code> file to include your header on all pages:</p><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">Make sure to change the <code spellcheck="false" style="white-space: pre-wrap;">title</code> and <code spellcheck="false" style="white-space: pre-wrap;">description</code> strings in the <code spellcheck="false" style="white-space: pre-wrap;">metadata</code> part to set the name and description of your project&#x2019;s metadata.</div></div>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">layout.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import type { Metadata } from &quot;next&quot;;
import { Geist, Geist_Mono } from &quot;next/font/google&quot;;
import &quot;./globals.css&quot;;
import Header from &quot;./Header&quot;;

const geistSans = Geist({
  variable: &quot;--font-geist-sans&quot;,
  subsets: [&quot;latin&quot;],
});

const geistMono = Geist_Mono({
  variable: &quot;--font-geist-mono&quot;,
  subsets: [&quot;latin&quot;],
});

export const metadata: Metadata = {
  title: &quot;Creator Platform&quot;,
  description: &quot;Support creators you love with monthly subscriptions&quot;,
};

export default function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      &gt;
        &lt;Header /&gt;
        {children}
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="edit-the-global-css">Edit the global CSS</h3><p>Lastly, let&apos;s make your project look better. All pages in your project reference a CSS file called <code>global.css</code> that you can find in the <code>app</code> folder. Open the <code>global.css</code> file, and replace its contents with:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">global.css</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-css">@import &quot;tailwindcss&quot;;

:root {
  --background: #ffffff;
  --foreground: #171717;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --font-sans: var(--font-geist-sans);
  --font-mono: var(--font-geist-mono);
}

body {
  background: var(--background);
  color: var(--foreground);
  font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}

@keyframes gradient {
  0% {
    background-position: 0% 50%;
  }
  50% {
    background-position: 100% 50%;
  }
  100% {
    background-position: 0% 50%;
  }
}

.animate-gradient {
  animation: gradient 15s ease infinite;
}
</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="test-the-homepage-and-subscription-dashboard">Test the homepage and subscription dashboard</h3><ol><li>Go to <code>http://localhost:3000</code>, you should see the new homepage</li><li>Try searching for creators via the search field</li><li>Use the Go to Dashboard button to go to your dashboard</li><li>Test the header by going into different pages</li><li>Log into an account that has a subscription and try to cancel it in the subscriptions dashboard</li></ol><h2 id="step-13-deploying-the-project">Step 13: Deploying the project</h2><p>Your project is ready to launch - good job! In this step, you&#x2019;ll update some of the keys you use, take your project out of the Whop sandbox, push it to GitHub, deploy it to Vercel (with a production database), and configure some settings.</p><h3 id="get-your-production-whop-keys">Get your production Whop keys</h3><p>Firstly, let&#x2019;s update your Whop keys. In development, you used Sandbox.Whop.com, now, it&#x2019;s time to use whop.com and its keys:</p><ul><li>Go to the Whop dashboard of your company (create a new one if you don&#x2019;t have any) and copy your company ID (starts with <code>biz_</code>) from the URL</li><li>Go to the Developer page and on the Company API keys section, click the <strong>Create</strong> button, give your API key a name, and select the permissions below, and <strong>Create</strong> the API key. Once created, copy it. You&#x2019;ll use it later:</li></ul><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;">Permissions to select</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"><span style="white-space: pre-wrap;">Create child companies</span></li><li value="2"><span style="white-space: pre-wrap;">Read business information</span></li><li value="3"><span style="white-space: pre-wrap;">Create checkout configurations</span></li><li value="4"><span style="white-space: pre-wrap;">Read checkout configurations</span></li><li value="5"><span style="white-space: pre-wrap;">Create plans</span></li><li value="6"><span style="white-space: pre-wrap;">Read plans</span></li><li value="7"><span style="white-space: pre-wrap;">Update plans</span></li><li value="8"><span style="white-space: pre-wrap;">Delete plans</span></li><li value="9"><span style="white-space: pre-wrap;">Create products</span></li><li value="10"><span style="white-space: pre-wrap;">Read products</span></li><li value="11"><span style="white-space: pre-wrap;">Update products</span></li><li value="12"><span style="white-space: pre-wrap;">Read payments</span></li><li value="13"><span style="white-space: pre-wrap;">Read members</span></li><li value="14"><span style="white-space: pre-wrap;">Read member emails</span></li><li value="15"><span style="white-space: pre-wrap;">Read changes to payments</span></li><li value="16"><span style="white-space: pre-wrap;">Read changes to memberships</span></li><li value="17"><span style="white-space: pre-wrap;">Read payout destinations</span></li></ul></div>
        </div><ul><li>Now use the <strong>Create app</strong> button in the Apps section, give it the permissions below (using the Permissions tab), and copy its app ID (from the app details tab)</li></ul><h3 id="generate-a-new-production-session-secret">Generate a new production session secret</h3><p>Let&#x2019;s open up a terminal and run the command below to get a new session secret. Don&#x2019;t share it anywhere, and don&#x2019;t lose it - you&#x2019;ll use it soon:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">node -e &quot;console.log(require(&apos;crypto&apos;).randomBytes(32).toString(&apos;hex&apos;))&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="preparing-for-deployment">Preparing for deployment</h3><p>In production, we&#x2019;re going to use Vercel Postgres, but you&#x2019;re free to choose a different cloud database solution. Now, let&#x2019;s make changes to support Vercel Postgres.</p><h4 id="update-the-prisma-scheme">Update the Prisma scheme</h4><p>Open the <code>prisma.scheme</code> file in the <code>prisma</code> folder and update the <code>datasource</code> part with:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">prisma.scheme</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">datasource db {
  provider  = &quot;postgresql&quot;
  url       = env(&quot;POSTGRES_PRISMA_URL&quot;)
  directUrl = env(&quot;POSTGRES_URL_NON_POOLED&quot;)
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h4 id="add-the-postinstall-script">Add the postinstall script</h4><p>To let Vercel generate the Prisma client, you need to open the <code>package.json</code> file in your project root and add the line below to the <code>scripts</code> section:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">prisma.scheme</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&quot;postinstall&quot;: &quot;prisma generate&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="pushing-your-code-to-github">Pushing your code to GitHub</h3><p>Let&#x2019;s initialize a Git repository on your project root by running the commands below in your terminal:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">git init
git add .
git commit -m &quot;Initial commit&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then, go to GitHub.com and create a new private repository - this way, your code will stay private and Vercel can still access it once you connect both accounts.</p><p>Once you&#x2019;ve created the repository, push your code using:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">git remote add origin https://github.com/your-username/project-name.git
git branch -M main
git push -u origin main</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="deploy-to-vercel">Deploy to Vercel</h3><p>Let&#x2019;s deploy your project to Vercel now:</p><ol><li>Go to Vercel.com and sign in with your GitHub account</li><li>Click <strong>Add new</strong> and select <strong>Project</strong></li><li>Click the <strong>Import</strong> button next to the GitHub repository you just created</li></ol><p>This will display a popup with deployment settings. There, you should configure your environment variables:</p>
<!--kg-card-begin: html-->
<table>
  <thead>
    <tr>
      <th>Variable</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>SESSION_SECRET</code></td>
      <td>The secret you generated earlier</td>
    </tr>
    <tr>
      <td><code>AUTH_URL</code></td>
      <td>Leave blank for now</td>
    </tr>
    <tr>
      <td><code>WHOP_APP_ID</code></td>
      <td>Your production app ID (starts with <code>app_</code>)</td>
    </tr>
    <tr>
      <td><code>WHOP_API_KEY</code></td>
      <td>Your production API key (starts with <code>apik_</code>)</td>
    </tr>
    <tr>
      <td><code>WHOP_COMPANY_ID</code></td>
      <td>Your production company ID (starts with <code>biz_</code>)</td>
    </tr>
    <tr>
      <td><code>WHOP_WEBHOOK_SECRET</code></td>
      <td>Leave blank for now</td>
    </tr>
  </tbody>
</table>
<!--kg-card-end: html-->
<p>After adding the environment variables, you can deploy the project. It&#x2019;s not going to start working right away, there are still things you need to do.</p><h3 id="create-the-production-database">Create the production database</h3><p>After deploying your project, let&#x2019;s create the production database:</p><ol><li>Click on the Storage tab of your Vercel project</li><li>Click <strong>Create Database</strong> and select Neon</li><li>In the database creation page, select the region, plan, and database name</li><li>Once the database is created, it should be connected to your project. If not, use the <strong>Connect Project</strong> button in the database page to manually connect the two</li></ol><h3 id="run-database-migrations">Run database migrations</h3><p>One of the easiest ways to simply run the migrations from your computer to Vercel is adding the lines below to your <code>.env</code> file, and replacing their secrets with the ones you see on the details page of your database on Vercel:</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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">POSTGRES_PRISMA_URL=&quot;your-neon-connection-string&quot;
POSTGRES_URL_NON_POOLED=&quot;your-neon-connection-string&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Once you add these lines to your <code>.env</code> file and replace the keys, run the command below in your terminal to complete the migration:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">script.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">npx prisma migrate deploy</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Once you&#x2019;re done with the migrations, let&#x2019;s copy your project&#x2019;s URL, you&#x2019;ll use it later:</p><ol><li>Go to the Deployments tab of your project</li><li>Click on the context menu button of the latest deployment</li><li>Select <strong>Copy URL</strong></li></ol><h3 id="update-the-authurl-secret-on-vercel">Update the <code>AUTH_URL</code> secret on Vercel</h3><p>Now, let&#x2019;s add the <code>AUTH_URL</code> secret to your project:</p><ol><li>Go to the <strong>Settings</strong> tab of your project on Vercel</li><li>Open the <strong>Environment Variables</strong> page and click <strong>Add Environment Variable</strong></li><li> Enter <code>AUTH_URL</code> as the <strong>Key</strong> and the the URL you copied earlier as the <strong>Value</strong></li><li>Click <strong>Save</strong></li></ol><h3 id="configure-whop-oauth-redirect-on-whop">Configure Whop OAuth redirect on Whop</h3><p>Now, let&#x2019;s go to Whop and update your app&#x2019;s OAuth redirect so that it doesn&#x2019;t try to redirect users who sign in to your localhost address:</p><ol><li>Go to your Whop dashboard and open the Developers page</li><li>Select your app in the App section and go to its OAuth tab</li><li>If you have an entry under Redirect URL table (localhost), delete it</li><li>Create a new one using the <strong>Create redirect URL</strong> button</li><li>Enter <code>https://your-project-url.vercel.app/api/auth/callback</code></li><li>Click <strong>Create</strong></li></ol><h3 id="get-a-production-webhook-secret">Get a production webhook secret</h3><p>While you&#x2019;re on Whop, let&#x2019;s get the webhook secret that you&#x2019;ll use in production:</p><ol><li>Go back to the Developer page of your dashboard and click <strong>Create webhook</strong></li><li>There, enter <code>https://your-project-url.vercel.app/api/webhooks/whop</code> as the Endpoint URL and make sure <strong>Connected account events</strong> is enabled</li><li>In the permissions, select <code>payment_succeeded</code>, <code>membership_cancel_at_period_end_changed</code>, and <code>membership_deactivated</code> and click <strong>Save</strong></li><li>Copy the webhook secret (starts with <code>ws_</code>)</li></ol><p>Now, let&#x2019;s open Vercel and update your environment variable:</p><ol><li>Go to the project settings on Vercel and open Environment Variables</li><li>Click <strong>Add Environment Variable</strong>, enter <code>WHOP_WEBHOOK_SECRET</code> as the Key, and your webhook secret as the Value</li><li>Click <strong>Save</strong></li></ol><h3 id="redeploy-after-updating-environment-variables">Redeploy after updating environment variables</h3><p>Now that you&#x2019;ve added new environment variables to your project, let&#x2019;s redeploy it:</p><ol><li>Go to the Deployments tab of your project on Vercel</li><li>Click on the context menu of your latest deployment</li><li>Select <strong>Redeploy</strong> and confirm on the popup</li></ol><h3 id="test-production-deployment">Test production deployment</h3><p>Now that your final product is deployed on Vercel, let&#x2019;s take make some final tests - see if you can do these without any errors:</p><ol><li>Sign in with Whop OAuth</li><li>Register as a creator</li><li>Create tiers and content</li><li>Find the creator profile in home page</li><li>Content gating</li><li>Subscription cancelling</li></ol><h2 id="step-14-what%E2%80%99s-next">Step 14: What&#x2019;s next?</h2><p>Now that you have a functional Patreon clone with authentication, subscription tiers, gated content, payments infrastructure, and payouts, let&#x2019;s take a look at some features you can implement to your project to take it to the next step:</p><h4 id="payment-and-subscription-features">Payment and subscription features</h4><ul><li><strong>Promo codes -</strong> Use Whop API to offer percentage or fixed-amount discounts</li><li><strong>Free trials -</strong> Allow creators to offer trial period before charging with the Whop API</li><li><strong>Annual memberships -</strong> Let creators create annual subscription options</li><li><strong>Subscription upgrade/downgrades -</strong> Let subscribers switch between subscription tiers</li><li><strong>Embedded checkouts -</strong> Embed Whop checkouts using the Whop API instead of redirecting users to the hosted checkout page</li><li><strong>Failed payment handling -</strong> Listen for failed payments webhooks from Whop and notify subscribers to update payment methods</li><li><strong>Refund system -</strong> Use Whop API to add a refund flow</li></ul><h4 id="creator-tools">Creator tools</h4><ul><li><strong>Advanced analytics dashboard -</strong> Show creators their subscriber data, revenue trends, and other analytics</li><li><strong>File attachments -</strong> Create a file storage system and let creators attach files to their posts</li><li><strong>Scheduled posts -</strong> Add a scheduling feature so that creators can make future posts</li><li><strong>Subscriber management -</strong> Let creators view their subscriber list and manage them</li><li><strong>Custom creator profiles -</strong> Let creators customize their profile colors, banners, and profile pictures</li></ul><h4 id="subscriber-experience">Subscriber experience</h4><ul><li><strong>Comments and likes on posts -</strong> Add engagement features like comments and likes to increase engagement</li><li><strong>Content search -</strong> Add search functionality to creator profiles to subscribers can search for content</li></ul><h4 id="growth">Growth</h4><ul><li><strong>Creator categories and tags -</strong> Add categories and tags to creator profiles so customers can easily find relevant creators</li><li><strong>Featured creators -</strong> Highlight your top creators on your home page</li><li><strong>SEO optimization -</strong> Add proper meta tags, Open Graph images, and structured data for creator profiles</li></ul><h4 id="technical-improvements">Technical improvements</h4><ul><li><strong>Rate limiting -</strong> Add rate limiting to your API route to prevent abuse</li><li><strong>Error monitoring -</strong> Implement Sentry or a similar feature to catch and track production errors</li><li><strong>Caching -</strong> Add caching for frequently accessed pages for faster loading times</li><li><strong>Mobile app -</strong> Build a native iOS app using Whop&#x2019;s iOS SDK</li></ul><h2 id="ready-to-build-your-own-patreon-clone">Ready to build your own Patreon clone?</h2><p>You have everything you need to build a Patreon clone. If you haven&#x2019;t already, go to <a href="http://whop.com"><u>Whop.com</u></a>, create an account, and build a 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 capabilities of the Whop API and payment rails, check out our documentation.</p><div class="kg-card kg-button-card kg-align-left"><a href="https://docs.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