Topic Details

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

Last successful fetch
Sat, 07 Mar 2026 14:33:37 +0000
Last ping
Sat, 07 Mar 2026 14:33:37 +0000
Last fetch error
Fri, 06 Mar 2026 14:46:43 +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
Sat, 07 Mar 2026 14:33:37 +0000
<item><title><![CDATA[How to build a Substack clone with Next.js and Whop]]></title><description><![CDATA[You can easily build a Substack clone by using the Whop Payments Network, its infrastructure, and other services like Supabase and Vercel. In this guide, we will walk you through each step.]]></description><link>https://whop.com/blog/build-substack-clone/</link><guid isPermaLink="false">69a874f359e499000180085f</guid><category><![CDATA[Tutorials]]></category><category><![CDATA[Engineering]]></category><dc:creator><![CDATA[East]]></dc:creator><pubDate>Fri, 06 Mar 2026 19:44:04 GMT</pubDate><media:content url="https://whop.com/blog/content/images/2026/03/build-substack-clone.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/03/build-substack-clone.webp" alt="How to build a Substack clone with Next.js and Whop"><p class="ai-prompt-widget__description">Open the tutorial prompt in your favorite AI coding tool:</p>
  <div class="ai-prompt-widget__buttons" id="ai-prompt-buttons"></div>
</div>
<!--kg-card-end: html-->
<p>Building a platform like Substack is easier than you think thanks to the <a href="https://network.whop.com/">Whop Payments Network</a>, its other infrastructure solutions (like user authentication and embedded chats), Supabase, and Vercel - which are the services we&apos;re going to use in this tutorial.</p><p>In the steps below, you&apos;ll build Penstack. A full publishing platform where writers create publications, write articles with a rich text editor (complete with a paywall break), monetize through paid subscriptions, and engage their readers through embedded chat.</p><p>You can preview the finished product <a href="https://penstack-fresh.vercel.app/" rel="noopener nofollow">demo here</a> and find the full codebase in this <a href="https://github.com/whopio/whop-tutorials/tree/main/penstack">GitHub repository</a>.</p><h2 id="project-overview">Project overview</h2><p>Before we dive deep into the code of the project, let&apos;s take a general look at what we&apos;re going to build. The project will have:</p><ul><li>A <strong>rich text editor</strong> with a custom paywall break node. Writers will be able to place the break wherever they want, and the server slices content at that point for non-subscribers</li><li><strong>Writer onboarding</strong> where authenticated users can become a writer, set a publication name, handle, bio, and category (from a list)</li><li><strong>KYC and payment setup</strong> via the Whop Payments Network. Writer will complete their identity verification and connect their account to receive payouts</li><li><strong>Paid subscriptions with Direct Charges</strong> where subscribers pay the writer directly. 90% goes to the writer&apos;s connected account, 10% is retained as a platform fee</li><li><strong>Explore page</strong> with a trending algorithm that surfaces popular publications and recent posts</li><li>An <strong>embedded Whop chat</strong> for publication profiles where readers can chat with each other</li><li><strong>Notification system</strong> for new posts, subscribers, followers, and payment events</li><li><strong>Analytics dashboard</strong> for writers where they can see subscriber counts, post performance, and revenue</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;">Tech stack</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;">Next.js (App Router)</strong></b><span style="white-space: pre-wrap;"> - Server Components + API routes + Vercel deploy in one</span></li><li value="2"><b><strong style="white-space: pre-wrap;">Whop OAuth 2.1 + PKCE</strong></b><span style="white-space: pre-wrap;"> - sign-in, tokens, identity</span></li><li value="3"><b><strong style="white-space: pre-wrap;">Whop Payments Network (Direct Charges)</strong></b><span style="white-space: pre-wrap;"> - connected accounts, recurring billing, KYC built-in</span></li><li value="4"><b><strong style="white-space: pre-wrap;">Supabase (PostgreSQL via Vercel)</strong></b><span style="white-space: pre-wrap;"> - cloud-only, Vercel auto-populates connection strings</span></li><li value="5"><b><strong style="white-space: pre-wrap;">Prisma</strong></b><span style="white-space: pre-wrap;"> - type-safe queries, declarative schema, migrations</span></li><li value="6"><b><strong style="white-space: pre-wrap;">Zod</strong></b><span style="white-space: pre-wrap;"> - runtime validation at system boundaries</span></li><li value="7"><b><strong style="white-space: pre-wrap;">Tiptap</strong></b><span style="white-space: pre-wrap;"> - extensible ProseMirror wrapper with custom paywall break node</span></li><li value="8"><b><strong style="white-space: pre-wrap;">Uploadthing</strong></b><span style="white-space: pre-wrap;"> - type-safe uploads</span></li><li value="9"><b><strong style="white-space: pre-wrap;">iron-session</strong></b><span style="white-space: pre-wrap;"> - encrypted cookies, no session store, no Redis, no JWTs</span></li><li value="10"><b><strong style="white-space: pre-wrap;">Whop Embedded Components</strong></b><span style="white-space: pre-wrap;"> - pre-built chat UI</span></li><li value="11"><b><strong style="white-space: pre-wrap;">Vercel</strong></b><span style="white-space: pre-wrap;"> - </span><code spellcheck="false" style="white-space: pre-wrap;"><span>vercel.ts</span></code><span style="white-space: pre-wrap;"> for type-safe config</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;">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>src/app/</span></code><span style="white-space: pre-wrap;"> - pages and API routes</span></li><li value="2"><code spellcheck="false" style="white-space: pre-wrap;"><span>src/components/</span></code><span style="white-space: pre-wrap;"> - editor, chat, dashboard, explore, post, settings, writer, and shared UI components</span></li><li value="3"><code spellcheck="false" style="white-space: pre-wrap;"><span>src/constants/</span></code><span style="white-space: pre-wrap;"> - app config and categories</span></li><li value="4"><code spellcheck="false" style="white-space: pre-wrap;"><span>src/hooks/</span></code><span style="white-space: pre-wrap;"> - custom React hooks</span></li><li value="5"><code spellcheck="false" style="white-space: pre-wrap;"><span>src/lib/</span></code><span style="white-space: pre-wrap;"> - auth, session, env validation, Prisma, rate limiting, Whop SDK, uploads, utilities</span></li><li value="6"><code spellcheck="false" style="white-space: pre-wrap;"><span>src/services/</span></code><span style="white-space: pre-wrap;"> - business logic (explore, notifications, posts, subscriptions, writers)</span></li><li value="7"><code spellcheck="false" style="white-space: pre-wrap;"><span>src/types/</span></code><span style="white-space: pre-wrap;"> - TypeScript type declarations</span></li><li value="8"><code spellcheck="false" style="white-space: pre-wrap;"><span>src/middleware.ts</span></code><span style="white-space: pre-wrap;"> - route protection</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;">The payment flow</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"><ol><li value="1"><span style="white-space: pre-wrap;">Subscribers click the &quot;Subscribe&quot; button</span></li><li value="2"><span style="white-space: pre-wrap;">Our project creates a checkout session via the Whop API</span></li><li value="3"><span style="white-space: pre-wrap;">Subscriber completes the payment with a Whop-hosted checkout</span></li><li value="4"><span style="white-space: pre-wrap;">Whop Payments Network charges (90% to writer&apos;s connected Whop account and 10% to our platform)</span></li><li value="5"><span style="white-space: pre-wrap;">Whop fires webhooks</span></li><li value="6"><span style="white-space: pre-wrap;">Our project creates the subscription record and sends a notification</span></li></ol><p><b><strong style="white-space: pre-wrap;">Important note:</strong></b><span style="white-space: pre-wrap;"> Writers must complete KYC before they can set a subscription price and start receiving payments. Until then, they can publish free content only.</span></p></div>
        </div><h2 id="why-whop">Why Whop</h2><p>On a publishing platform like this, we will encounter three infrastructure problems: payment system, user authentication, and community engagement. We will solve these with the following services:</p><ul><li><strong>Whop Payments Network</strong> will solve all payment systems for us with simple integrations and collect payments from subscribers with Direct Charge</li><li><strong>Whop OAuth</strong>&apos;s simple &quot;Sign in with Whop&quot; button will allow users to easily join the project, saving us the trouble of storing passwords</li><li><strong>Whop embedded chats</strong> will be available on author profiles to enable interaction between authors and readers and keep reader communities active</li></ul><h2 id="prerequisites">Prerequisites</h2><p>Before starting, you should have:</p><ul><li>Working familiarity with Next.js and React (we use the App Router and Server Components)</li><li>A Whop developer account (free to create at whop.com)</li><li>A Vercel account (free tier)</li><li>A Supabase account (free tier)</li></ul><h2 id="part-1-scaffold-deploy-and-authenticate">Part 1: Scaffold, deploy, and authenticate</h2><p>In this tutorial, we will start by laying the foundations in Vercel and then begin development, rather than transferring to Vercel after classic local development.</p><p>This way, we will have the OAuth redirect URL early on and can catch any issues immediately (because Vercel will not be able to build).</p><h3 id="create-the-project">Create the project</h3><p>Let&apos;s use the command below to scaffold a new Next.js app. We&apos;ll call our project &quot;Penstack&quot;:</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 penstack --typescript --tailwind --eslint --app --src-dir --use-npm
cd penstack</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then, install the dependencies we&apos;ll use throughout the project:</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 @whop/embedded-components-react-js @whop/embedded-components-vanilla-js iron-session zod prisma @prisma/client @prisma/adapter-pg uploadthing @uploadthing/react @tiptap/core @tiptap/react @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-placeholder @tiptap/extension-underline @tiptap/extension-link @tiptap/pm lucide-react</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="deploy-before-building">Deploy before building</h3><p>New Next.js projects will build without requiring any configuration, so you should transfer your project to a GitHub repository (use a private repo if you don&apos;t want your project to be open source) and connect it to Vercel.</p><p>Then add the project&apos;s URL as an environment variable in Vercel:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Environment Variable</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">NEXT_PUBLIC_APP_URL=https://your-app.vercel.app</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="supabase-through-the-vercel-integration">Supabase through the Vercel integration</h3><p>Now, you should add Supabase through the Vercel Integrations marketplace instead of creating a project directly in the Supabase dashboard. Vercel automatically populates <code>DATABASE_URL</code> and <code>DIRECT_URL</code> as environment variables with connection pooling pre-configured through Supavisor.</p><p>Then, pull the variables to your local development 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">vercel env pull .env.local</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>This is the pattern for every environment variable in this tutorial: add to Vercel first, then <code>vercel env pull</code> to sync locally.</p><h3 id="validate-environment-variables">Validate environment variables</h3><p>Incomplete or incorrect environment variables should be presented as simple error messages, and we will use a Zod schema for this purpose. Go to <code>src/lib</code> and create a file called <code>env.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">env.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&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 { z } from &quot;zod&quot;;

const envSchema = z.object({
  WHOP_APP_ID: z.string().startsWith(&quot;app_&quot;),
  WHOP_API_KEY: z.string().startsWith(&quot;apik_&quot;),
  WHOP_COMPANY_ID: z.string().startsWith(&quot;biz_&quot;),
  WHOP_WEBHOOK_SECRET: z.string().min(1),
  WHOP_CLIENT_ID: z.string().min(1),
  WHOP_CLIENT_SECRET: z.string().min(1),

  DATABASE_URL: z.string().url(),
  DIRECT_URL: z.string().url(),

  UPLOADTHING_TOKEN: z.string().min(1),

  SESSION_SECRET: z.string().min(32),

  NEXT_PUBLIC_APP_URL: z.string().url(),

  NEXT_PUBLIC_DEMO_MODE: z.string().optional(),

  WHOP_SANDBOX: z.string().optional(),
});

let _env: z.infer&lt;typeof envSchema&gt; | null = null;

export function getEnv() {
  if (!_env) {
    _env = envSchema.parse(process.env);
  }
  return _env;
}

export const env = new Proxy({} as z.infer&lt;typeof envSchema&gt;, {
  get(_target, prop: string) {
    return getEnv()[prop as keyof z.infer&lt;typeof envSchema&gt;];
  },
});

export function isDemoMode(): boolean {
  return process.env.NEXT_PUBLIC_DEMO_MODE === &quot;true&quot;;
}
</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="prisma-setup">Prisma setup</h3><p>Initialize Prisma and install its config dependency:</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
npm install -D dotenv</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Prisma 7 creates <code>prisma/schema.prisma</code> and <code>prisma.config.ts</code>. Replace the schema with a minimal User model, just enough to store authenticated users. We&apos;ll expand it significantly in Part 2.</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&quot;
  output   = &quot;../src/generated/prisma&quot;
}

datasource db {
  provider = &quot;postgresql&quot;
}

model User {
  id          String   @id @default(cuid())
  whopUserId  String   @unique
  email       String?
  username    String?
  displayName String?
  avatarUrl   String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Now, let&apos;s update the <code>prisma.config.ts</code> file in your project root with the content below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">prisma.config.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 &quot;dotenv/config&quot;;
import { defineConfig } from &quot;prisma/config&quot;;

export default defineConfig({
  schema: &quot;prisma/schema.prisma&quot;,
  migrations: {
    path: &quot;prisma/migrations&quot;,
  },
  datasource: {
    url: process.env[&quot;DIRECT_URL&quot;],
  },
});</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>The <code>url</code> here is what the Prisma CLI uses for <code>prisma db push</code> and migrations. It must point at Supabase&apos;s <strong>session mode pooler</strong> (port 5432). The <strong>transaction mode pooler</strong> (port 6543) strips session-level state that schema operations depend on.</p><p>For your <code>.env.local</code>, you need two Supabase connection strings:</p><ul><li><code>DATABASE_URL</code> - the <strong>transaction mode</strong> pooler (port 6543), used by your app for queries</li><li><code>DIRECT_URL</code> - the <strong>session mode</strong> pooler (port 5432), used by the Prisma CLI for schema operations</li></ul><p>Both come from Supabase&apos;s connection pooler. Do <strong>not</strong> use the direct database connection (<code>db.xxx.supabase.co</code>) for <code>DIRECT_URL</code>.</p><p>Now, let&apos;s push the schema using the command:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&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 db push</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then create the Prisma client singleton at <code>src/lib</code> with a file called <code>prisma.ts</code> with the content below. Without the singleton pattern, Next.js hot-reloads would create a new database connection on each reload and exhaust the connection pool.</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 &quot;@/generated/prisma/client&quot;;
import { PrismaPg } from &quot;@prisma/adapter-pg&quot;;

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

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

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

if (process.env.NODE_ENV !== &quot;production&quot;) globalForPrisma.prisma = prisma;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="set-up-your-whop-app">Set up your Whop app</h3><p>During development, we&apos;ll use the sandbox environment of Whop, which allows us to simulate payments without moving real money. Follow the steps below to create a Whop app:</p><ol><li>Go to sandbox.whop.com, create a whop, and go to its dashboard</li><li>In the dashboard, open the Developer page and find the Apps section</li><li>Click the <strong>Create your first app</strong> button in the Apps section, give it a name, and click <strong>Create</strong></li><li>Get the App ID, API Key, Company ID, Client ID, and Client Secret of the app</li><li>Go to the OAuth tab of the app and set the redirect URL to http://localhost:3000/api/auth/callback<br></li></ol><p>When you&apos;re moving from development to production, you&apos;re going to have to repeat these steps <strong>outside the sandbox</strong>, in whop.com. We&apos;ll touch on this later at Part 7.</p><p>Next, add the line below to your <code>.env.local</code> file so that the OAuth and API calls are routed to the sandbox instead of the real environment:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">.env.local</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">WHOP_SANDBOX=true</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="whop-oauth-with-pkce">Whop OAuth with PKCE</h3><p>In this section, we&apos;re going to take a look at authenticating users through Whop&apos;s OAuth flow with PKCE and store sessions in cookies using <code>iron-session</code>.</p><h3 id="session-configuration">Session configuration</h3><p>Go to <code>src/lib</code> and create a file called <code>session.ts</code> with the content:</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 { getIronSession, SessionOptions } from &quot;iron-session&quot;;
import { cookies } from &quot;next/headers&quot;;

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

const sessionOptions: SessionOptions = {
  password: process.env.SESSION_SECRET!,
  cookieName: &quot;penstack_session&quot;,
  cookieOptions: {
    secure: process.env.NODE_ENV === &quot;production&quot;,
    httpOnly: true,
    sameSite: &quot;lax&quot;,
    maxAge: 60 * 60 * 24 * 7, // 7 days
  },
};

export async function getSession() {
  const cookieStore = await cookies();
  return getIronSession&lt;SessionData&gt;(cookieStore, sessionOptions);
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="whop-sdk-and-pkce-generation">Whop SDK and PKCE generation</h3><p>Go to <code>src/lib</code> and create a file called <code>whop.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&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;;

let _whop: Whop | null = null;
export function getWhop(): Whop {
  if (!_whop) {
    _whop = new Whop({
      appID: process.env.WHOP_APP_ID!,
      apiKey: process.env.WHOP_API_KEY!,
      ...(process.env.WHOP_SANDBOX === &quot;true&quot; &amp;&amp; {
        baseURL: &quot;https://sandbox-api.whop.com/api/v1&quot;,
      }),
    });
  }
  return _whop;
}

export const whop = new Proxy({} as Whop, {
  get(_target, prop, receiver) {
    return Reflect.get(getWhop(), prop, receiver);
  },
});

const isSandbox = process.env.WHOP_SANDBOX === &quot;true&quot;;
const whopDomain = isSandbox ? &quot;sandbox.whop.com&quot; : &quot;whop.com&quot;;
const whopApiDomain = isSandbox ? &quot;sandbox-api.whop.com&quot; : &quot;api.whop.com&quot;;

export const WHOP_OAUTH = {
  authorizationUrl: `https://${whopApiDomain}/oauth/authorize`,
  tokenUrl: `https://${whopApiDomain}/oauth/token`,
  userInfoUrl: `https://${whopApiDomain}/oauth/userinfo`,
  clientId: process.env.WHOP_CLIENT_ID!,
  clientSecret: process.env.WHOP_CLIENT_SECRET!,
  scopes: [
    &quot;openid&quot;,
    &quot;profile&quot;,
    &quot;email&quot;,
    &quot;chat:message:create&quot;,
    &quot;chat:read&quot;,
    &quot;dms:read&quot;,
    &quot;dms:message:manage&quot;,
    &quot;dms:channel:manage&quot;,
  ],
  redirectUri: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback`,
};

export async function generatePKCE() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  const verifier = base64UrlEncode(array);

  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest(&quot;SHA-256&quot;, data);
  const challenge = base64UrlEncode(new Uint8Array(digest));

  return { verifier, challenge };
}

function base64UrlEncode(buffer: Uint8Array): string {
  let binary = &quot;&quot;;
  for (const byte of buffer) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary).replace(/\+/g, &quot;-&quot;).replace(/\//g, &quot;_&quot;).replace(/=+$/, &quot;&quot;);
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="rate-limiting">Rate limiting</h3><p>We want each route to pass a unique key (like <code>auth:login</code> or <code>writers:create:{userID}</code>) and a limit. If the caller is under the limit, it must return <code>null</code> and the route should continue. If over, it should return a 429 response that route sends back instantly.</p><p>Let&apos;s go to <code>src/lib</code> and create a file called <code>rate-limit.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">rate-limit.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 &quot;next/server&quot;;

interface RateLimitConfig {
  interval: number; // ms
  maxRequests: number;
}

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

export function rateLimit(
  key: string,
  config: RateLimitConfig = { interval: 60_000, maxRequests: 30 }
): NextResponse | null {
  const now = Date.now();
  const entry = rateLimitMap.get(key);

  if (!entry || now - entry.lastReset &gt; config.interval) {
    rateLimitMap.set(key, { count: 1, lastReset: now });
    return null;
  }

  if (entry.count &gt;= config.maxRequests) {
    return NextResponse.json(
      { error: &quot;Too many requests. Please try again later.&quot; },
      {
        status: 429,
        headers: {
          &quot;Retry-After&quot;: String(
            Math.ceil((config.interval - (now - entry.lastReset)) / 1000)
          ),
        },
      }
    );
  }

  entry.count++;
  return null;
}

if (typeof globalThis !== &quot;undefined&quot;) {
  const CLEANUP_INTERVAL = 5 * 60 * 1000;
  setInterval(() =&gt; {
    const now = Date.now();
    for (const [key, entry] of rateLimitMap.entries()) {
      if (now - entry.lastReset &gt; 10 * 60 * 1000) {
        rateLimitMap.delete(key);
      }
    }
  }, CLEANUP_INTERVAL).unref?.();
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="the-login-route">The login route</h3><p>Now, go to <code>src/app/api/auth/login</code> and create a file called <code>route.ts</code> with the content below. This will generate a PKCE challenge, store the verifier in a cookie, and redirect to Whop&apos;s user authorization page.</p><p>It also accepts an optional <code>?returnTo=</code> parameter so users who click Follow or Like while logged out land back on the same page after signing in:</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 &quot;next/server&quot;;
import { WHOP_OAUTH, generatePKCE } from &quot;@/lib/whop&quot;;

function randomHex(bytes: number): string {
  const buf = new Uint8Array(bytes);
  crypto.getRandomValues(buf);
  return Array.from(buf)
    .map((b) =&gt; b.toString(16).padStart(2, &quot;0&quot;))
    .join(&quot;&quot;);
}

export async function GET(request: NextRequest) {
  const returnTo = request.nextUrl.searchParams.get(&quot;returnTo&quot;);
  const safeReturnTo =
    returnTo &amp;&amp; returnTo.startsWith(&quot;/&quot;) &amp;&amp; !returnTo.startsWith(&quot;//&quot;)
      ? returnTo
      : null;

  const { verifier, challenge } = await generatePKCE();
  const state = randomHex(16);
  const nonce = randomHex(16);

  const authUrl = new URL(WHOP_OAUTH.authorizationUrl);
  authUrl.searchParams.set(&quot;client_id&quot;, WHOP_OAUTH.clientId);
  authUrl.searchParams.set(&quot;redirect_uri&quot;, WHOP_OAUTH.redirectUri);
  authUrl.searchParams.set(&quot;response_type&quot;, &quot;code&quot;);
  authUrl.searchParams.set(&quot;scope&quot;, WHOP_OAUTH.scopes.join(&quot; &quot;));
  authUrl.searchParams.set(&quot;code_challenge&quot;, challenge);
  authUrl.searchParams.set(&quot;code_challenge_method&quot;, &quot;S256&quot;);
  authUrl.searchParams.set(&quot;state&quot;, state);
  authUrl.searchParams.set(&quot;nonce&quot;, nonce);

  const cookieValue = JSON.stringify({
    codeVerifier: verifier,
    state,
    ...(safeReturnTo ? { returnTo: safeReturnTo } : {}),
  });

  const response = NextResponse.redirect(authUrl.toString());
  response.cookies.set(&quot;oauth_pkce&quot;, cookieValue, {
    httpOnly: true,
    secure: WHOP_OAUTH.redirectUri.startsWith(&quot;https&quot;),
    sameSite: &quot;lax&quot;,
    path: &quot;/&quot;,
    maxAge: 600, // 10 minutes
  });

  return response;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="callback-configuration">Callback configuration</h3><p>When users log or sign up with Whop, it redirects them back with an authorization code, and we need a route that exchanges it for an access token using the PKCE verifier, fetches the user&apos;s profile, upserts them into the database, and establishes the session.</p><p>If the login was triggered with a <code>returnTo</code> URL (stored in the PKCE cookie), the user is redirected back to that page instead of the home page. To create this, go to <code>src/app/api/auth/callback</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&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 &quot;next/server&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { WHOP_OAUTH } from &quot;@/lib/whop&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;

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

  if (!code || !state) {
    return NextResponse.json(
      { error: &quot;Missing authorization code or state&quot; },
      { status: 400 }
    );
  }

  const pkceCookie = request.cookies.get(&quot;oauth_pkce&quot;);
  if (!pkceCookie?.value) {
    return NextResponse.json(
      { error: &quot;Missing PKCE cookie. Please try logging in again.&quot; },
      { status: 400 }
    );
  }

  let storedState: string;
  let codeVerifier: string;
  let returnTo: string | undefined;
  try {
    const parsed = JSON.parse(pkceCookie.value);
    storedState = parsed.state;
    codeVerifier = parsed.codeVerifier;
    returnTo = parsed.returnTo;
  } catch {
    return NextResponse.json(
      { error: &quot;Invalid PKCE cookie.&quot; },
      { status: 400 }
    );
  }

  if (state !== storedState) {
    return NextResponse.json(
      { error: &quot;State mismatch &#x2014; possible CSRF.&quot; },
      { status: 400 }
    );
  }

  const tokenResponse = await fetch(WHOP_OAUTH.tokenUrl, {
    method: &quot;POST&quot;,
    headers: { &quot;Content-Type&quot;: &quot;application/x-www-form-urlencoded&quot; },
    body: new URLSearchParams({
      grant_type: &quot;authorization_code&quot;,
      code,
      redirect_uri: WHOP_OAUTH.redirectUri,
      client_id: WHOP_OAUTH.clientId,
      client_secret: WHOP_OAUTH.clientSecret,
      code_verifier: codeVerifier,
    }),
  });

  if (!tokenResponse.ok) {
    const error = await tokenResponse.text();
    console.error(&quot;Token exchange failed:&quot;, error);
    return NextResponse.json(
      {
        error: &quot;Failed to exchange authorization code&quot;,
        detail: error,
        tokenUrl: WHOP_OAUTH.tokenUrl,
        redirectUri: WHOP_OAUTH.redirectUri,
      },
      { status: 502 }
    );
  }

  const tokenData = await tokenResponse.json();
  const accessToken: string = tokenData.access_token;

  const userInfoResponse = await fetch(WHOP_OAUTH.userInfoUrl, {
    headers: { Authorization: `Bearer ${accessToken}` },
  });

  if (!userInfoResponse.ok) {
    console.error(&quot;User info fetch failed:&quot;, await userInfoResponse.text());
    return NextResponse.json(
      { error: &quot;Failed to fetch user info&quot; },
      { status: 502 }
    );
  }

  const userInfo = await userInfoResponse.json();

  const user = await prisma.user.upsert({
    where: { whopUserId: userInfo.sub },
    update: {
      email: userInfo.email ?? null,
      username: userInfo.preferred_username ?? null,
      displayName: userInfo.name ?? null,
      avatarUrl: userInfo.picture ?? null,
    },
    create: {
      whopUserId: userInfo.sub,
      email: userInfo.email ?? null,
      username: userInfo.preferred_username ?? null,
      displayName: userInfo.name ?? null,
      avatarUrl: userInfo.picture ?? null,
    },
  });

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

  const redirectPath =
    returnTo &amp;&amp; returnTo.startsWith(&quot;/&quot;) &amp;&amp; !returnTo.startsWith(&quot;//&quot;)
      ? returnTo
      : &quot;/&quot;;
  const response = NextResponse.redirect(new URL(redirectPath, request.url));
  response.cookies.delete(&quot;oauth_pkce&quot;);
  return response;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="logout-route">Logout route</h3><p>Finally, we need a way for users to sign out. Go to <code>src/app/api/auth/logout</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 &quot;next/server&quot;;
import { getSession } from &quot;@/lib/session&quot;;

export async function GET() {
  const session = await getSession();
  session.destroy();
  return NextResponse.redirect(new URL(&quot;/&quot;, process.env.NEXT_PUBLIC_APP_URL!));
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>This destroys the iron-session cookie and redirects the user back to the home page.</p><h3 id="using-a-single-authentication-function">Using a single authentication function</h3><p>In our project, all server components and API routes need to know who the users interacting with them are. Instead of performing session and database checks everywhere, let&apos;s use a single <code>requireAuth</code> function.</p><p>Go to <code>src/lib</code> and create a file called <code>auth.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">auth.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&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 &quot;next/navigation&quot;;
import { getSession } from &quot;./session&quot;;
import { prisma } from &quot;./prisma&quot;;

export async function requireAuth(
  options?: { redirect?: boolean }
): Promise&lt;{
  id: string;
  whopUserId: string;
  email: string | null;
  username: string | null;
  displayName: string | null;
  avatarUrl: string | null;
} | null&gt; {
  const session = await getSession();

  if (!session.userId) {
    if (options?.redirect === false) return null;
    redirect(&quot;/api/auth/login&quot;);
  }

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

  if (!user) {
    session.destroy();
    if (options?.redirect === false) return null;
    redirect(&quot;/api/auth/login&quot;);
  }

  return user;
}

export async function getWriterProfile(userId: string) {
  return prisma.writer.findUnique({ where: { userId } });
}

export async function isAuthenticated(): Promise&lt;boolean&gt; {
  const session = await getSession();
  return !!session.userId;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="vercel-configuration">Vercel configuration</h3><p>Create <code>vercel.ts</code> at the project root. The key line is <code>buildCommand</code>. It runs <code>prisma generate</code> before <code>next build</code> so the Prisma client exists when Vercel builds your app.</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">vercel.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&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">const config = {
  framework: &quot;nextjs&quot; as const,
  buildCommand: &quot;prisma generate &amp;&amp; next build&quot;,
  regions: [&quot;iad1&quot;],
  headers: [
    {
      source: &quot;/(.*)&quot;,
      headers: [
        {
          key: &quot;X-Content-Type-Options&quot;,
          value: &quot;nosniff&quot;,
        },
        {
          key: &quot;X-Frame-Options&quot;,
          value: &quot;DENY&quot;,
        },
        {
          key: &quot;Referrer-Policy&quot;,
          value: &quot;strict-origin-when-cross-origin&quot;,
        },
      ],
    },
  ],
};

export default config;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="check-what-weve-done-so-far">Check what we&apos;ve done so far</h3><p>Now that we&apos;ve built the scaffolding and authentication, let&apos;s complete our first checkpoint. Deploy your changes to Vercel (push to your GitHub and Vercel should auto-build), visit your production URL and navigate to <code>/api/auth/login</code>.</p><p>You should be redirected to Whop&apos;s authorization page. After granting access, you land back on the home page.</p><p>With authentication working in production, you have a solid foundation. In Part 2, we&apos;ll build out the complete data model and the writer onboarding flow.</p><h2 id="part-2-data-models-and-writer-onboarding">Part 2: Data models and writer onboarding</h2><p>Now that our user verification system works, we must determine the data structures necessary for our project to function as a publishing platform.</p><p>In this section, we will look at the complete schema, file uploading, and the onboarding flow for regular users to become authors.</p><h3 id="the-complete-data-model">The complete data model</h3><p>We will use a total of eight models in our project. Some details to note:</p><ul><li>Writers are separate from Users, not every user is a writer.</li><li>Post.content is <code>Json</code>, Tiptap (the editor we use) outputs JSON and storing it as such lets the server slice the node array at <code>paywallIndex</code> for preview posts so paid content can&apos;t be seen by unauthorized users.</li><li>Single paid tier per writer, all writers have a single plan and price.</li><li>WebhookEvent stores processed event IDs for idempotency since webhooks can fire more than once.</li></ul><p>Now, go to <code>prisma</code> and update the <code>schema.prisma</code> file content with:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">schema.prisma</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&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&quot;
  output   = &quot;../src/generated/prisma&quot;
}

datasource db {
  provider = &quot;postgresql&quot;
}

//  Enums
enum PublicationCategory {
  TECHNOLOGY
  BUSINESS
  CULTURE
  POLITICS
  SCIENCE
  HEALTH
  FINANCE
  SPORTS
  FOOD
  TRAVEL
  MUSIC
  ART
  EDUCATION
  OTHER
}

enum PostVisibility {
  FREE
  PAID
  PREVIEW
}

enum SubscriptionStatus {
  ACTIVE
  PAST_DUE
  CANCELLED
  PAUSED
  TRIALING
}

enum NotificationType {
  NEW_POST
  NEW_SUBSCRIBER
  NEW_FOLLOWER
  PAYMENT_RECEIVED
  PAYMENT_FAILED
}

// Models
model User {
  id          String   @id @default(cuid())
  whopUserId  String   @unique
  email       String?
  username    String?
  displayName String?
  avatarUrl   String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  writer        Writer?
  subscriptions Subscription[]
  follows       Follow[]
  likes         Like[]
  notifications Notification[]
}

model Writer {
  id        String   @id @default(cuid())
  userId    String   @unique
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  handle    String
  name      String
  bio       String?
  avatarUrl String?
  bannerUrl String?
  category  PublicationCategory @default(OTHER)

  whopCompanyId     String?
  whopProductId     String?
  whopPlanId        String?
  whopChatChannelId String?

  kycCompleted      Boolean @default(false)
  monthlyPriceInCents Int?
  chatPublic        Boolean @default(true)

  posts         Post[]
  subscriptions Subscription[]
  followers     Follow[]

  @@index([handle])
  @@index([category])
}

model Post {
  id        String   @id @default(cuid())
  writerId  String
  writer    Writer   @relation(fields: [writerId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  slug          String
  title         String
  subtitle      String?
  coverImageUrl String?
  content       Json
  visibility    PostVisibility @default(FREE)
  paywallIndex  Int?

  published   Boolean   @default(false)
  publishedAt DateTime?
  viewCount   Int       @default(0)

  likes Like[]

  @@unique([writerId, slug])
  @@index([writerId, published, publishedAt])
  @@index([published, publishedAt])
  @@index([visibility])
}

model Subscription {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  writerId  String
  writer    Writer   @relation(fields: [writerId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  status             SubscriptionStatus @default(ACTIVE)
  whopMembershipId   String?            @unique
  currentPeriodEnd   DateTime?
  cancelledAt        DateTime?
  lastWebhookEventId String?

  @@unique([userId, writerId])
  @@index([writerId, status])
}

model Follow {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  writerId  String
  writer    Writer   @relation(fields: [writerId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())

  @@unique([userId, writerId])
  @@index([writerId])
}

model Like {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  postId    String
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())

  @@unique([userId, postId])
  @@index([postId])
}

model Notification {
  id        String           @id @default(cuid())
  userId    String
  user      User             @relation(fields: [userId], references: [id], onDelete: Cascade)
  type      NotificationType
  title     String
  message   String
  postId    String?
  writerId  String?
  read      Boolean          @default(false)
  createdAt DateTime         @default(now())

  @@index([userId, read, createdAt])
}

model WebhookEvent {
  id          String   @id
  eventType   String
  processedAt DateTime @default(now())
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then push the updated schema using the command:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&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 db push</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="uploadthing-for-file-uploads">Uploadthing for file uploads</h3><p>We&apos;re going to use Uploadthing for avatars, banners, and cover images instead of Supabase Storage. Uploadthing lets us use type-safe file routes with built-in React upload components, which means less custom code.</p><p>Go to <code>src/lib</code> and create a file called <code>uploadthing.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">uploadthing.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 { createUploadthing, type FileRouter } from &quot;uploadthing/next&quot;;
import { getSession } from &quot;./session&quot;;

const f = createUploadthing();

export const uploadRouter = {
  avatarUploader: f({
    image: { maxFileSize: &quot;2MB&quot;, maxFileCount: 1 },
  })
    .middleware(async () =&gt; {
      const session = await getSession();
      if (!session.userId) throw new Error(&quot;Unauthorized&quot;);
      return { userId: session.userId };
    })
    .onUploadComplete(async ({ metadata, file }) =&gt; {
      return { url: file.ufsUrl, userId: metadata.userId };
    }),

  bannerUploader: f({
    image: { maxFileSize: &quot;4MB&quot;, maxFileCount: 1 },
  })
    .middleware(async () =&gt; {
      const session = await getSession();
      if (!session.userId) throw new Error(&quot;Unauthorized&quot;);
      return { userId: session.userId };
    })
    .onUploadComplete(async ({ metadata, file }) =&gt; {
      return { url: file.ufsUrl, userId: metadata.userId };
    }),

  coverImageUploader: f({
    image: { maxFileSize: &quot;4MB&quot;, maxFileCount: 1 },
  })
    .middleware(async () =&gt; {
      const session = await getSession();
      if (!session.userId) throw new Error(&quot;Unauthorized&quot;);
      return { userId: session.userId };
    })
    .onUploadComplete(async ({ metadata, file }) =&gt; {
      return { url: file.ufsUrl, userId: metadata.userId };
    }),

  editorImageUploader: f({
    image: { maxFileSize: &quot;4MB&quot;, maxFileCount: 1 },
  })
    .middleware(async () =&gt; {
      const session = await getSession();
      if (!session.userId) throw new Error(&quot;Unauthorized&quot;);
      return { userId: session.userId };
    })
    .onUploadComplete(async ({ file }) =&gt; {
      return { url: file.ufsUrl };
    }),
} satisfies FileRouter;

export type UploadRouter = typeof uploadRouter;
</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>To set up Uploadthing, create a project at uploadthing.com, copy your <code>UPLOADTHING_TOKEN</code> key and add it to your Vercel environment variables. Then, use the command below to pull the variables:</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">vercel env pull .env.local</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Now, go to <code>src/app/api/uploadthing</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 { createRouteHandler } from &quot;uploadthing/next&quot;;
import { uploadRouter } from &quot;@/lib/uploadthing&quot;;

export const { GET, POST } = createRouteHandler({
  router: uploadRouter,
});</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="writer-onboarding-flow">Writer onboarding flow</h3><p>All users in the project can become writers by going through the onboarding flow. They can do this through a multi-step onboarding at <code>/settings</code>.</p><p>To create this, let&apos;s go to <code>src/app/api/writers</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 &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;

const createWriterSchema = z.object({
  handle: z
    .string()
    .min(3)
    .max(30)
    .regex(
      /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
      &quot;Handle must be lowercase alphanumeric with optional hyphens&quot;
    ),
  name: z.string().min(1).max(100),
  bio: z.string().max(500).optional(),
  category: z.enum([
    &quot;TECHNOLOGY&quot;,
    &quot;BUSINESS&quot;,
    &quot;CULTURE&quot;,
    &quot;POLITICS&quot;,
    &quot;SCIENCE&quot;,
    &quot;HEALTH&quot;,
    &quot;FINANCE&quot;,
    &quot;SPORTS&quot;,
    &quot;FOOD&quot;,
    &quot;TRAVEL&quot;,
    &quot;MUSIC&quot;,
    &quot;ART&quot;,
    &quot;EDUCATION&quot;,
    &quot;OTHER&quot;,
  ]),
  avatarUrl: z.string().url().optional(),
  bannerUrl: z.string().url().optional(),
});

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

  const limited = rateLimit(`writers:create:${user.id}`, {
    interval: 60_000,
    maxRequests: 5,
  });
  if (limited) return limited;

  const existingWriter = await prisma.writer.findUnique({
    where: { userId: user.id },
  });
  if (existingWriter) {
    return NextResponse.json(
      { error: &quot;You already have a publication&quot; },
      { status: 409 }
    );
  }

  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json(
      { error: &quot;Invalid JSON body&quot; },
      { status: 400 }
    );
  }

  const parsed = createWriterSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0].message },
      { status: 400 }
    );
  }

  const { handle, name, bio, category, avatarUrl, bannerUrl } = parsed.data;

  const handleTaken = await prisma.writer.findFirst({ where: { handle } });
  if (handleTaken) {
    return NextResponse.json(
      { error: &quot;Handle is already taken&quot; },
      { status: 409 }
    );
  }

  const writer = await prisma.writer.create({
    data: {
      userId: user.id,
      handle,
      name,
      bio,
      category,
      avatarUrl,
      bannerUrl,
    },
  });

  return NextResponse.json(writer, { status: 201 });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>The handles become the publication URL (<code>/writer-handle</code>) once the onboarding is complete, so the regex we use for handles (<code>/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/</code>) enforces URL-safe slugs.</p><p>The settings page and onboarding wizard are standard React components that collect these fields across four steps, use the Uploadthing components for avatar and banner uploads, and POST the collected data to this endpoint. See <code>src/app/settings/page.tsx</code> and <code>src/components/settings/onboarding-wizard.tsx</code> in the <a href="https://github.com/whopio/whop-tutorials/tree/main/penstack">repo</a>.</p><h3 id="publication-categories">Publication categories</h3><p>The explore page of the project uses a category filter and they&apos;re defined as both a Prisma enum (a fixed set of allowed values) and a constants file (a mapping from those values to readable labels for the UI).</p><p>To create this, go to <code>src/constants</code> and create a file called <code>categories.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">categories.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 { PublicationCategory } from &quot;@/generated/prisma/client&quot;;

export const CATEGORY_LABELS: Record&lt;PublicationCategory, string&gt; = {
  TECHNOLOGY: &quot;Technology&quot;,
  BUSINESS: &quot;Business&quot;,
  CULTURE: &quot;Culture&quot;,
  POLITICS: &quot;Politics&quot;,
  SCIENCE: &quot;Science&quot;,
  HEALTH: &quot;Health&quot;,
  FINANCE: &quot;Finance&quot;,
  SPORTS: &quot;Sports&quot;,
  FOOD: &quot;Food&quot;,
  TRAVEL: &quot;Travel&quot;,
  MUSIC: &quot;Music&quot;,
  ART: &quot;Art&quot;,
  EDUCATION: &quot;Education&quot;,
  OTHER: &quot;Other&quot;,
};

export const CATEGORY_OPTIONS = Object.entries(CATEGORY_LABELS).map(
  ([value, label]) =&gt; ({ value: value as PublicationCategory, label })
);</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="app-configuration-constants">App configuration constants</h3><p>Several parts of the project reference shared constants like page sizes, trending algorithm weights, and pricing limits. Go to <code>src/constants</code> and create a file called <code>config.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">config.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">export const PLATFORM_FEE_PERCENT = 10;
export const MIN_PRICE_CENTS = 100;
export const MAX_PRICE_CENTS = 100_000;
export const POSTS_PER_PAGE = 10;
export const TRENDING_WRITERS_COUNT = 6;
export const TRENDING_WINDOW_DAYS = 14;
export const TRENDING_WEIGHTS = {
  followers: 1,
  subscribers: 3,
  recentPosts: 2,
} as const;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="verify-the-onboarding-flow">Verify the onboarding flow</h3><p>The build is done, so let&apos;s verify our writer onboarding flow:</p><ol><li>Sign in through Whop OAuth</li><li>Navigate to <code>/settings</code>. The onboarding wizard appears since you don&apos;t have a writer profile</li><li>Complete the four steps and submit</li><li>Confirm the Writer record was created in Supabase with the correct <code>userId</code> reference</li><li>Visit <code>/{your-handle}</code>, the publication page should be there</li></ol><p>You now have authentication, a complete data model, file uploads, and writer onboarding. In Part 3, we&apos;ll build the rich text editor that writers use to create posts.</p><h2 id="part-3-the-rich-text-editor">Part 3: The rich text editor</h2><p>The text editor is one of the most important parts of the project because we need a text editor that authors can use easily but that still offers standard formatting options (so they can freely customise the articles they write).</p><p>In this section, we will set up the rich text editor Tiptap, add the custom paywall break extension, and set up the API calls to add articles to the database.</p><h3 id="paywall-break-extension-configuration">Paywall break extension configuration</h3><p>One of the key features that will set our editor apart from other classic text editors is the paywall break extension. Authors will be able to insert this component wherever they wish in their text. Everything above the component will be readable by everyone, while the content below will be exclusive to subscribers.</p><p>To create the component file, go to <code>/src/components/editor/extensions</code> and create a file called <code>paywall-break.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">paywall-break.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 { Node, mergeAttributes } from &quot;@tiptap/core&quot;;

declare module &quot;@tiptap/core&quot; {
  interface Commands&lt;ReturnType&gt; {
    paywallBreak: {
      setPaywallBreak: () =&gt; ReturnType;
    };
  }
}

export const PaywallBreak = Node.create({
  name: &quot;paywallBreak&quot;,
  group: &quot;block&quot;,
  atom: true,

  parseHTML() {
    return [{ tag: &apos;div[data-type=&quot;paywall-break&quot;]&apos; }];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      &quot;div&quot;,
      mergeAttributes(HTMLAttributes, {
        &quot;data-type&quot;: &quot;paywall-break&quot;,
        class: &quot;paywall-break&quot;,
      }),
      &quot;Content below is for paid subscribers only&quot;,
    ];
  },

  addCommands() {
    return {
      setPaywallBreak:
        () =&gt;
        ({ commands }) =&gt; {
          return commands.insertContent({ type: this.name });
        },
    };
  },

  addKeyboardShortcuts() {
    return {
      &quot;Mod-Shift-p&quot;: () =&gt; this.editor.commands.setPaywallBreak(),
    };
  },
});</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="editor-setup">Editor setup</h3><p>Now, let&apos;s create our editor which has basic formatting, image uploads, and the paywall break. Go to <code>src/components/editor</code> and create a file called <code>editor.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">editor.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">&quot;use client&quot;;

import { useRef } from &quot;react&quot;;
import { useEditor, EditorContent, type JSONContent } from &quot;@tiptap/react&quot;;
import StarterKit from &quot;@tiptap/starter-kit&quot;;
import UnderlineExt from &quot;@tiptap/extension-underline&quot;;
import LinkExt from &quot;@tiptap/extension-link&quot;;
import ImageExt from &quot;@tiptap/extension-image&quot;;
import Placeholder from &quot;@tiptap/extension-placeholder&quot;;
import { PaywallBreak } from &quot;./extensions/paywall-break&quot;;
import { Toolbar } from &quot;./toolbar&quot;;

interface EditorProps {
  initialContent?: JSONContent;
  onChange: (content: JSONContent) =&gt; void;
  editable?: boolean;
}

export function Editor({
  initialContent,
  onChange,
  editable = true,
}: EditorProps) {
  const fileInputRef = useRef&lt;HTMLInputElement&gt;(null);

  const editor = useEditor({
    extensions: [
      StarterKit,
      UnderlineExt,
      LinkExt.configure({ openOnClick: false }),
      ImageExt,
      Placeholder.configure({ placeholder: &quot;Start writing...&quot; }),
      PaywallBreak,
    ],
    content: initialContent,
    editable,
    onUpdate: ({ editor }) =&gt; {
      onChange(editor.getJSON());
    },
  });

  if (!editor) return null;

  function handleImageUpload() {
    fileInputRef.current?.click();
  }

  async function handleFileChange(e: React.ChangeEvent&lt;HTMLInputElement&gt;) {
    const file = e.target.files?.[0];
    if (!file || !editor) return;

    const formData = new FormData();
    formData.append(&quot;files&quot;, file);

    try {
      const res = await fetch(&quot;/api/uploadthing&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;x-uploadthing-package&quot;: &quot;uploadthing&quot; },
        body: formData,
      });
      const data = await res.json();
      if (data?.[0]?.ufsUrl) {
        editor.chain().focus().setImage({ src: data[0].ufsUrl }).run();
      }
    } catch {
      alert(&quot;Image upload failed. Please try again.&quot;);
    }

    e.target.value = &quot;&quot;;
  }

  return (
    &lt;div className=&quot;rounded-lg border border-gray-200&quot;&gt;
      {editable &amp;&amp; (
        &lt;Toolbar editor={editor} onImageUpload={handleImageUpload} /&gt;
      )}
      &lt;div className=&quot;min-h-[400px] p-4&quot;&gt;
        &lt;EditorContent editor={editor} /&gt;
      &lt;/div&gt;
      &lt;input
        ref={fileInputRef}
        type=&quot;file&quot;
        accept=&quot;image/*&quot;
        className=&quot;hidden&quot;
        onChange={handleFileChange}
      /&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>The toolbar (see <code>src/components/editor/toolbar.tsx</code> in the <a href="https://github.com/whopio/whop-tutorials/tree/main/penstack">repo</a>) is a flat array of button definitions: bold, italic, underline, headings, lists, blockquote, code block, link, image, horizontal rule, and a paywall break toggle (lock icon). Each button calls the corresponding Tiptap chain command and highlights when active.</p><h3 id="how-the-paywallindex-works">How the paywallIndex works</h3><p>When a writer publishes a preview in our project, our server needs to understand how to split this content, which is why we use the <code>paywallIndex</code> field in our Post model. This allows us to configure where the server should split the post and which part should be shown to unsubscribed users.</p><p>Then, the <code>/write</code> page finds the <code>paywallBreak</code> section in the JSON content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title"></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">let paywallIndex: number | undefined;
if (content?.content) {
  const idx = content.content.findIndex(
    (node) =&gt; node.type === &quot;paywallBreak&quot;
  );
  if (idx !== -1) paywallIndex = idx;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="the-api-for-post-creation">The API for post creation</h3><p>The POST handler we&apos;ll use should link the editor to the database, validate with Zod, generate a unique slug, and create the post record. To do this, go to <code>src/app/api/posts</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 &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireAuth, getWriterProfile } from &quot;@/lib/auth&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;
import { POSTS_PER_PAGE } from &quot;@/constants/config&quot;;

const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  subtitle: z.string().max(400).optional(),
  content: z.unknown(),
  visibility: z.enum([&quot;FREE&quot;, &quot;PAID&quot;, &quot;PREVIEW&quot;]),
  paywallIndex: z.number().int().min(0).optional(),
  published: z.boolean(),
  coverImageUrl: z.string().url().optional(),
});

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

  const limited = rateLimit(`posts:create:${user.id}`, {
    interval: 60_000,
    maxRequests: 10,
  });
  if (limited) return limited;

  const writer = await getWriterProfile(user.id);
  if (!writer) {
    return NextResponse.json(
      { error: &quot;You must be a writer to create posts&quot; },
      { status: 403 }
    );
  }

  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json(
      { error: &quot;Invalid JSON body&quot; },
      { status: 400 }
    );
  }

  const parsed = createPostSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0].message },
      { status: 400 }
    );
  }

  const { title, subtitle, content, visibility, paywallIndex, published, coverImageUrl } =
    parsed.data;

  const baseSlug = title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, &quot;-&quot;)
    .replace(/^-|-$/g, &quot;&quot;);
  const slug = `${baseSlug}-${Date.now().toString(36)}`;

  const post = await prisma.post.create({
    data: {
      writerId: writer.id,
      title,
      subtitle,
      content: content as object,
      visibility,
      paywallIndex,
      published,
      publishedAt: published ? new Date() : null,
      coverImageUrl,
      slug,
    },
  });

  return NextResponse.json(post, { status: 201 });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="the-write-page">The write page</h3><p>The <code>/write</code> page is what writers will use to create and edit posts. It includes the rich text editor and the toolbar (you can find it in <code>src/components/editor/toolbar.tsx</code> in the <a href="https://github.com/whopio/whop-tutorials/tree/main/penstack">repo</a>) that allows writers to format their text as bold, italic, heading, list, blockquotes, images, and add paywall breaks.</p><p>To create the <code>/write</code> page, go to <code>src/app/write</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">&quot;use client&quot;;

import { Suspense, useState, useEffect, useCallback } from &quot;react&quot;;
import { useRouter, useSearchParams } from &quot;next/navigation&quot;;
import type { JSONContent } from &quot;@tiptap/core&quot;;
import { Editor } from &quot;@/components/editor/editor&quot;;
import { UploadZone } from &quot;@/components/ui/upload-zone&quot;;
import type { PostVisibility } from &quot;@/generated/prisma/browser&quot;;

const VISIBILITY_OPTIONS: { value: PostVisibility; label: string; description: string }[] = [
  { value: &quot;FREE&quot;, label: &quot;Free&quot;, description: &quot;Visible to everyone&quot; },
  { value: &quot;PAID&quot;, label: &quot;Paid&quot;, description: &quot;Subscribers only&quot; },
  { value: &quot;PREVIEW&quot;, label: &quot;Preview&quot;, description: &quot;Free preview with paywall&quot; },
];

export default function WritePage() {
  return (
    &lt;Suspense fallback={&lt;div className=&quot;flex min-h-[60vh] items-center justify-center&quot;&gt;&lt;p className=&quot;text-gray-500&quot;&gt;Loading...&lt;/p&gt;&lt;/div&gt;}&gt;
      &lt;WritePageInner /&gt;
    &lt;/Suspense&gt;
  );
}

function WritePageInner() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const postId = searchParams.get(&quot;postId&quot;);

  const [title, setTitle] = useState(&quot;&quot;);
  const [subtitle, setSubtitle] = useState(&quot;&quot;);
  const [coverImageUrl, setCoverImageUrl] = useState&lt;string | null&gt;(null);
  const [content, setContent] = useState&lt;JSONContent | undefined&gt;(undefined);
  const [visibility, setVisibility] = useState&lt;PostVisibility&gt;(&quot;FREE&quot;);
  const [saving, setSaving] = useState(false);
  const [loading, setLoading] = useState(!!postId);

  useEffect(() =&gt; {
    if (!postId) return;

    fetch(`/api/posts/${postId}`)
      .then((res) =&gt; {
        if (!res.ok) throw new Error(&quot;Failed to load post&quot;);
        return res.json();
      })
      .then((post) =&gt; {
        setTitle(post.title);
        setSubtitle(post.subtitle ?? &quot;&quot;);
        setCoverImageUrl(post.coverImageUrl);
        setContent(post.content as JSONContent);
        setVisibility(post.visibility);
      })
      .catch(() =&gt; {
        router.push(&quot;/dashboard&quot;);
      })
      .finally(() =&gt; setLoading(false));
  }, [postId, router]);

  const save = useCallback(
    async (publish: boolean) =&gt; {
      if (!title.trim()) return;
      setSaving(true);

      try {
        let paywallIndex: number | undefined;
        if (content?.content) {
          const idx = content.content.findIndex(
            (node) =&gt; node.type === &quot;paywallBreak&quot;
          );
          if (idx !== -1) paywallIndex = idx;
        }

        const body = {
          title: title.trim(),
          subtitle: subtitle.trim() || undefined,
          content,
          visibility,
          paywallIndex,
          published: publish,
          coverImageUrl: coverImageUrl ?? undefined,
        };

        const url = postId ? `/api/posts/${postId}` : &quot;/api/posts&quot;;
        const method = postId ? &quot;PATCH&quot; : &quot;POST&quot;;

        const res = await fetch(url, {
          method,
          headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
          body: JSON.stringify(body),
        });

        if (!res.ok) {
          const data = await res.json();
          alert(data.error ?? &quot;Something went wrong&quot;);
          return;
        }

        router.push(&quot;/dashboard&quot;);
      } finally {
        setSaving(false);
      }
    },
    [title, subtitle, content, visibility, coverImageUrl, postId, router]
  );

  if (loading) {
    return (
      &lt;div className=&quot;flex min-h-[60vh] items-center justify-center&quot;&gt;
        &lt;p className=&quot;text-gray-500&quot;&gt;Loading...&lt;/p&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;div className=&quot;mx-auto max-w-3xl px-4 py-8&quot;&gt;
      &lt;div className=&quot;mb-6&quot;&gt;
        &lt;UploadZone
          endpoint=&quot;coverImageUploader&quot;
          onUploadComplete={(url) =&gt; setCoverImageUrl(url)}
          label=&quot;Cover image&quot;
        /&gt;
      &lt;/div&gt;

      &lt;input
        type=&quot;text&quot;
        placeholder=&quot;Post title&quot;
        value={title}
        onChange={(e) =&gt; setTitle(e.target.value)}
        className=&quot;w-full border-0 bg-transparent font-serif text-4xl font-bold placeholder-gray-300 focus:outline-none focus:ring-0&quot;
      /&gt;

      &lt;input
        type=&quot;text&quot;
        placeholder=&quot;Add a subtitle...&quot;
        value={subtitle}
        onChange={(e) =&gt; setSubtitle(e.target.value)}
        className=&quot;mt-2 w-full border-0 bg-transparent text-xl text-gray-600 placeholder-gray-300 focus:outline-none focus:ring-0&quot;
      /&gt;

      &lt;div className=&quot;mt-6&quot;&gt;
        &lt;Editor
          initialContent={content}
          onChange={setContent}
        /&gt;
      &lt;/div&gt;

      &lt;div className=&quot;mt-8 flex flex-wrap items-center gap-4 border-t border-gray-200 pt-6&quot;&gt;
        &lt;div className=&quot;flex items-center gap-2&quot;&gt;
          &lt;label htmlFor=&quot;visibility&quot; className=&quot;text-sm font-medium text-gray-700&quot;&gt;
            Visibility:
          &lt;/label&gt;
          &lt;select
            id=&quot;visibility&quot;
            value={visibility}
            onChange={(e) =&gt; setVisibility(e.target.value as PostVisibility)}
            className=&quot;input w-auto&quot;
          &gt;
            {VISIBILITY_OPTIONS.map((opt) =&gt; (
              &lt;option key={opt.value} value={opt.value}&gt;
                {opt.label} &#x2014; {opt.description}
              &lt;/option&gt;
            ))}
          &lt;/select&gt;
        &lt;/div&gt;

        &lt;div className=&quot;ml-auto flex gap-3&quot;&gt;
          &lt;button
            onClick={() =&gt; save(false)}
            disabled={saving || !title.trim()}
            className=&quot;btn-secondary&quot;
          &gt;
            {saving ? &quot;Saving...&quot; : &quot;Save draft&quot;}
          &lt;/button&gt;
          &lt;button
            onClick={() =&gt; save(true)}
            disabled={saving || !title.trim()}
            className=&quot;btn-primary&quot;
          &gt;
            {saving ? &quot;Publishing...&quot; : &quot;Publish&quot;}
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="verification">Verification</h3><p>Test the full editor-to-database loop:</p><ol><li>Navigate to <code>/write</code>, create a post with a few paragraphs, insert a paywall break, and add content below it</li><li>Set visibility to &quot;Preview&quot; and publish</li><li>Check the database. <code>published: true</code>, <code>visibility: &quot;PREVIEW&quot;</code>, and the content JSON contains a <code>paywallBreak</code> node at the correct position</li><li>Save a second post as a draft. Verify it appears in your dashboard but not on your public publication page</li><li>Edit the draft from the dashboard. <code>/write</code> loads with all fields populated</li></ol><p>The editor is connected end-to-end. Next, we&apos;ll turn this stored content into rendered articles with server-side paywall enforcement.</p><h2 id="part-4-publication-pages-and-content-rendering">Part 4: Publication pages and content rendering</h2><p>Now that we&apos;ve built the writer side of the project, let&apos;s move on to the reader side. In this section, we&apos;re going to create pages where audiences discover writers and read their content.</p><p>When a non-subscriber visits a preview post (article with a paywall and a preview at the start), we want to prevent them from manually removing the paywall and seeing the rest of the article.</p><p>So, we want to slice the content on the server-side, not client. Here&apos;s how the article pages implement the access check (from <code>src/app/[writer]/[slug]/page.tsx</code>):</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">let hasAccess = true;
let paywallIndex: number | undefined;

if (post.visibility !== &quot;FREE&quot;) {
  if (!user) {
    hasAccess = false;
  } else {
    hasAccess = await canAccessPaidContent(user.id, post.writerId);
  }

  if (!hasAccess &amp;&amp; post.visibility === &quot;PREVIEW&quot; &amp;&amp; post.paywallIndex != null) {
    paywallIndex = post.paywallIndex;
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="rendering-the-tiptap-json">Rendering the Tiptap JSON</h3><p>As we mentioned before, Tiptap stores the articles as JSON, not HTML. This allows us to use a recursive renderer that reads the JSON tree, giving us control over how each element renders, especially the paywall break node.</p><p>Let&apos;s go to <code>src/components/post</code> and create a file called <code>post-content.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">post-content.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 { PaywallGate } from &quot;./paywall-gate&quot;;

interface PostContentProps {
  content: unknown;
  paywallIndex?: number | null;
  hasAccess?: boolean;
  writerName?: string;
  writerHandle?: string;
  price?: number;
}

export function PostContent({
  content,
  paywallIndex,
  hasAccess,
  writerName = &quot;&quot;,
  writerHandle = &quot;&quot;,
  price = 0,
}: PostContentProps) {
  const doc = content as { type: string; content?: unknown[] };
  const nodes = doc?.content ?? [];

  let visibleNodes = nodes;
  let showPaywall = false;

  if (paywallIndex != null &amp;&amp; !hasAccess) {
    visibleNodes = nodes.slice(0, paywallIndex);
    showPaywall = true;
  }

  return (
    &lt;div&gt;
      &lt;div
        className=&quot;prose prose-lg max-w-none&quot;
        dangerouslySetInnerHTML={{
          __html: renderNodes(visibleNodes),
        }}
      /&gt;
      {showPaywall &amp;&amp; (
        &lt;PaywallGate
          writerName={writerName}
          writerHandle={writerHandle}
          price={price}
        /&gt;
      )}
    &lt;/div&gt;
  );
}

function renderNodes(nodes: unknown[]): string {
  return nodes.map(renderNode).join(&quot;&quot;);
}

function renderNode(node: unknown): string {
  if (!node || typeof node !== &quot;object&quot;) return &quot;&quot;;
  const n = node as Record&lt;string, unknown&gt;;

  switch (n.type) {
    case &quot;paragraph&quot;:
      return `&lt;p&gt;${renderChildren(n)}&lt;/p&gt;`;
    case &quot;heading&quot;: {
      const level = (n.attrs as Record&lt;string, unknown&gt;)?.level ?? 2;
      return `&lt;h${level}&gt;${renderChildren(n)}&lt;/h${level}&gt;`;
    }
    case &quot;bulletList&quot;:
      return `&lt;ul&gt;${renderChildren(n)}&lt;/ul&gt;`;
    case &quot;orderedList&quot;:
      return `&lt;ol&gt;${renderChildren(n)}&lt;/ol&gt;`;
    case &quot;listItem&quot;:
      return `&lt;li&gt;${renderChildren(n)}&lt;/li&gt;`;
    case &quot;blockquote&quot;:
      return `&lt;blockquote&gt;${renderChildren(n)}&lt;/blockquote&gt;`;
    case &quot;codeBlock&quot;:
      return `&lt;pre&gt;&lt;code&gt;${renderChildren(n)}&lt;/code&gt;&lt;/pre&gt;`;
    case &quot;image&quot;: {
      const attrs = n.attrs as Record&lt;string, unknown&gt;;
      const src = attrs?.src ?? &quot;&quot;;
      const alt = attrs?.alt ?? &quot;&quot;;
      return `&lt;img src=&quot;${escapeHtml(String(src))}&quot; alt=&quot;${escapeHtml(String(alt))}&quot; /&gt;`;
    }
    case &quot;horizontalRule&quot;:
      return `&lt;hr /&gt;`;
    case &quot;hardBreak&quot;:
      return `&lt;br /&gt;`;
    case &quot;paywallBreak&quot;:
      return &quot;&quot;;
    case &quot;text&quot;: {
      let text = escapeHtml(String(n.text ?? &quot;&quot;));
      const marks = n.marks as Array&lt;Record&lt;string, unknown&gt;&gt; | undefined;
      if (marks) {
        for (const mark of marks) {
          switch (mark.type) {
            case &quot;bold&quot;:
              text = `&lt;strong&gt;${text}&lt;/strong&gt;`;
              break;
            case &quot;italic&quot;:
              text = `&lt;em&gt;${text}&lt;/em&gt;`;
              break;
            case &quot;underline&quot;:
              text = `&lt;u&gt;${text}&lt;/u&gt;`;
              break;
            case &quot;strike&quot;:
              text = `&lt;s&gt;${text}&lt;/s&gt;`;
              break;
            case &quot;code&quot;:
              text = `&lt;code&gt;${text}&lt;/code&gt;`;
              break;
            case &quot;link&quot;: {
              const href = (mark.attrs as Record&lt;string, unknown&gt;)?.href ?? &quot;&quot;;
              text = `&lt;a href=&quot;${escapeHtml(String(href))}&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;${text}&lt;/a&gt;`;
              break;
            }
          }
        }
      }
      return text;
    }
    default:
      return renderChildren(n);
  }
}

function renderChildren(node: Record&lt;string, unknown&gt;): string {
  const children = node.content as unknown[] | undefined;
  if (!children) return &quot;&quot;;
  return renderNodes(children);
}

function escapeHtml(text: string): string {
  return text
    .replace(/&amp;/g, &quot;&amp;amp;&quot;)
    .replace(/&lt;/g, &quot;&amp;lt;&quot;)
    .replace(/&gt;/g, &quot;&amp;gt;&quot;)
    .replace(/&quot;/g, &quot;&amp;quot;&quot;);
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="the-paywall-gate">The paywall gate</h3><p>When an unsubscribed reader hits the content boundary (the paywall gate), they should see a gate with a fade gradient that creates the impression the article continues but fades into the paywall.</p><p>To create this, go to <code>src/components/post</code> and create a file called <code>paywall-gate.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">paywall-gate.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 { Lock } from &quot;lucide-react&quot;;
import Link from &quot;next/link&quot;;
import { formatPrice } from &quot;@/lib/utils&quot;;

export interface PaywallGateProps {
  writerName: string;
  writerHandle: string;
  price?: number | null;
}

export function PaywallGate({
  writerName,
  writerHandle,
  price,
}: PaywallGateProps) {
  return (
    &lt;div className=&quot;relative mt-8&quot;&gt;
      &lt;div className=&quot;pointer-events-none absolute -top-24 left-0 right-0 h-24 bg-gradient-to-t from-white to-transparent&quot; /&gt;

      &lt;div className=&quot;rounded-xl border border-gray-200 bg-gradient-to-br from-gray-50 to-white p-8 text-center shadow-sm&quot;&gt;
        &lt;div className=&quot;mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-amber-100&quot;&gt;
          &lt;Lock className=&quot;h-6 w-6 text-amber-700&quot; /&gt;
        &lt;/div&gt;
        &lt;h3 className=&quot;font-serif text-xl font-bold text-gray-900&quot;&gt;
          This content is for paid subscribers
        &lt;/h3&gt;
        &lt;p className=&quot;mt-2 text-sm text-gray-500&quot;&gt;
          Subscribe to {writerName}
          {price ? ` for ${formatPrice(price)}/month` : &quot;&quot;} to unlock this
          post and all premium content.
        &lt;/p&gt;
        &lt;Link href={`/${writerHandle}`} className=&quot;btn-primary mt-6 inline-flex&quot;&gt;
          Subscribe to read
        &lt;/Link&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>One thing we should note is that the CTA button redirects readers to the publication page rather than a checkout page, allowing us to expose the full publication to the reader.</p><h3 id="the-article-pages">The article pages</h3><p>The article pages live in the <code>/[writer]/[slug]</code> route and renders the page content and paywall breaks we just configured.</p><p>Using a slug, it pulls the post&apos;s content, determines which content the user should see, and displays either the full content (subscriber), partial content via a paywall gate (preview post), or the entire content (free post).</p><p>To create the article pages, go to <code>/src/app/[writer]/[slug]</code> and create a page 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 type { Metadata } from &quot;next&quot;;
import { notFound } from &quot;next/navigation&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { getPostBySlug } from &quot;@/services/post-service&quot;;
import { canAccessPaidContent } from &quot;@/services/subscription-service&quot;;
import { isLikedByUser } from &quot;@/services/post-service&quot;;
import { PostContent } from &quot;@/components/post/post-content&quot;;
import { LikeButton } from &quot;@/components/post/like-button&quot;;
import { PaywallGate } from &quot;@/components/post/paywall-gate&quot;;
import { formatDate, estimateReadingTime } from &quot;@/lib/utils&quot;;

interface ArticlePageProps {
  params: Promise&lt;{ writer: string; slug: string }&gt;;
}

export async function generateMetadata({
  params,
}: ArticlePageProps): Promise&lt;Metadata&gt; {
  const { writer, slug } = await params;
  const post = await getPostBySlug(writer, slug);

  if (!post) {
    return { title: &quot;Post not found | Penstack&quot; };
  }

  return {
    title: `${post.title} | Penstack`,
    description: post.subtitle ?? `By ${post.writer.name}`,
    openGraph: {
      title: post.title,
      description: post.subtitle ?? `By ${post.writer.name}`,
      type: &quot;article&quot;,
      ...(post.coverImageUrl ? { images: [post.coverImageUrl] } : {}),
    },
  };
}

export default async function ArticlePage({ params }: ArticlePageProps) {
  const { writer: writerHandle, slug } = await params;
  const post = await getPostBySlug(writerHandle, slug);

  if (!post) notFound();

  const user = await requireAuth({ redirect: false });

  let hasAccess = true;
  let paywallIndex: number | undefined;

  if (post.visibility !== &quot;FREE&quot;) {
    if (!user) {
      hasAccess = false;
    } else {
      hasAccess = await canAccessPaidContent(user.id, post.writerId);
    }

    if (!hasAccess &amp;&amp; post.visibility === &quot;PREVIEW&quot; &amp;&amp; post.paywallIndex != null) {
      paywallIndex = post.paywallIndex;
    }
  }

  const liked = user ? await isLikedByUser(user.id, post.id) : false;
  const readingTime = estimateReadingTime(post.content);

  return (
    &lt;article className=&quot;mx-auto max-w-3xl px-4 py-8&quot;&gt;
      {post.coverImageUrl &amp;&amp; (
        &lt;img
          src={post.coverImageUrl}
          alt={post.title}
          className=&quot;mb-8 aspect-[2/1] w-full rounded-xl object-cover&quot;
        /&gt;
      )}

      &lt;header className=&quot;mb-8&quot;&gt;
        &lt;h1 className=&quot;font-serif text-4xl font-bold leading-tight&quot;&gt;
          {post.title}
        &lt;/h1&gt;
        {post.subtitle &amp;&amp; (
          &lt;p className=&quot;mt-3 text-xl text-gray-600&quot;&gt;{post.subtitle}&lt;/p&gt;
        )}
        &lt;div className=&quot;mt-4 flex items-center gap-3 text-sm text-gray-500&quot;&gt;
          &lt;a
            href={`/${post.writer.handle}`}
            className=&quot;flex items-center gap-2 font-medium text-gray-900 hover:underline&quot;
          &gt;
            {post.writer.avatarUrl &amp;&amp; (
              &lt;img
                src={post.writer.avatarUrl}
                alt={post.writer.name}
                className=&quot;h-8 w-8 rounded-full object-cover&quot;
              /&gt;
            )}
            {post.writer.name}
          &lt;/a&gt;
          &lt;span aria-hidden=&quot;true&quot;&gt;&amp;middot;&lt;/span&gt;
          &lt;time dateTime={post.publishedAt?.toISOString()}&gt;
            {post.publishedAt ? formatDate(post.publishedAt) : &quot;Draft&quot;}
          &lt;/time&gt;
          &lt;span aria-hidden=&quot;true&quot;&gt;&amp;middot;&lt;/span&gt;
          &lt;span&gt;{readingTime} min read&lt;/span&gt;
        &lt;/div&gt;
      &lt;/header&gt;

      {hasAccess ? (
        &lt;PostContent content={post.content} /&gt;
      ) : post.visibility === &quot;PREVIEW&quot; &amp;&amp; paywallIndex != null ? (
        &lt;PostContent
          content={post.content}
          paywallIndex={paywallIndex}
          writerName={post.writer.name}
          writerHandle={post.writer.handle}
          price={post.writer.monthlyPriceInCents ?? undefined}
        /&gt;
      ) : (
        &lt;PaywallGate
          writerName={post.writer.name}
          writerHandle={post.writer.handle}
          price={post.writer.monthlyPriceInCents}
        /&gt;
      )}

      &lt;footer className=&quot;mt-10 flex items-center justify-between border-t border-gray-200 pt-6&quot;&gt;
        &lt;LikeButton
          postId={post.id}
          initialLiked={liked}
          initialCount={post._count.likes}
          isLoggedIn={!!user}
        /&gt;
        &lt;a
          href={`/${post.writer.handle}`}
          className=&quot;text-sm font-medium text-[var(--brand-600)] hover:underline&quot;
        &gt;
          More from {post.writer.name}
        &lt;/a&gt;
      &lt;/footer&gt;
    &lt;/article&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="writer-publication-page">Writer publication page</h3><p>The <code>[writer]</code> route (see the <code>src/app/[writer]/page.tsx</code> page in the <a href="https://github.com/whopio/whop-tutorials/tree/main/penstack">repo</a>) uses a handle to select the writer, fetches their shared content, and checks whether the current user follows the writer and is subscribed to them.</p><p>The page contains a <code>WriterHeader</code> element at the top (avatar, name, bio, follower/subscriber/post counts, follow and subscribe buttons) and below it, a <code>PostCard</code> element for each shared content (title, subtitle, cover image thumbnail, date, reading time, like/view counts).</p><p>If the writer has a <code>whopChatChannelId</code>, a <code>WriterChat</code> section appears at the bottom. Access is gated by <code>chatPublic</code> so subscriber-only chat is enforced.</p><p>See <code>src/components/writer/writer-header.tsx</code> and <code>src/components/post/post-card.tsx</code> in the <a href="https://github.com/whopio/whop-tutorials/tree/main/penstack">repo</a>.</p><h3 id="like-buttons-in-articles">Like buttons in articles</h3><p>To add the like button to the article pages, go to <code>src/components/post</code> and create a file called <code>like-button.tsx</code> with the content.</p><p>The <code>isLoggedIn</code> prop lets us redirect unauthenticated users to the login page (with a <code>returnTo</code> URL) instead of silently failing:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">like-button.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">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { Heart } from &quot;lucide-react&quot;;
import { formatCount } from &quot;@/lib/utils&quot;;

interface LikeButtonProps {
  postId: string;
  initialLiked: boolean;
  initialCount: number;
  isLoggedIn?: boolean;
}

export function LikeButton({
  postId,
  initialLiked,
  initialCount,
  isLoggedIn,
}: LikeButtonProps) {
  const [liked, setLiked] = useState(initialLiked);
  const [count, setCount] = useState(initialCount);

  async function handleToggle() {
    if (!isLoggedIn) {
      window.location.href = `/api/auth/login?returnTo=${window.location.pathname}`;
      return;
    }

    setLiked(!liked);
    setCount((c) =&gt; (liked ? c - 1 : c + 1));

    try {
      const res = await fetch(`/api/posts/${postId}/like`, {
        method: &quot;POST&quot;,
      });
      if (!res.ok) throw new Error(&quot;Failed&quot;);
      const data = await res.json();
      setLiked(data.liked);
      setCount(data.count);
    } catch {
      setLiked(liked);
      setCount(count);
    }
  }

  return (
    &lt;button
      onClick={handleToggle}
      className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors ${
        liked
          ? &quot;bg-red-50 text-red-600&quot;
          : &quot;bg-gray-100 text-gray-600 hover:bg-gray-200&quot;
      }`}
    &gt;
      &lt;Heart
        className={`h-4 w-4 ${liked ? &quot;fill-red-500 text-red-500&quot; : &quot;&quot;}`}
      /&gt;
      {formatCount(count)}
    &lt;/button&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="follow-button">Follow button</h3><p>The follow mechanism (see <code>src/components/writer/writer-header.tsx</code> in the <a href="https://github.com/whopio/whop-tutorials/tree/main/penstack">repo</a>) mirrors the like pattern: POST to <code>/api/writers/[id]/follow</code>, revert on failure. If the user is not logged in, they are redirected to the login page with a <code>returnTo</code> URL pointing back to the writer&apos;s page.</p><p>Following is a free relationship distinct from subscribing. When a user follows a writer, the server creates a Follow record and a <code>NEW_FOLLOWER</code> notification.</p><h2 id="part-5-payments-subscriptions-and-kyc">Part 5: Payments, subscriptions, and KYC</h2><p>Writers can publish articles and edit them, readers can interact with the articles, and subscribe to the writers. Now, let&apos;s take on one of the most important parts of our project - payments, subscriptions, and KYC.</p><p>Luckily for us, this will be quite easy since we&apos;ll be using the Whop Payments Network infrastructure for all three. Subscribers will pay the writers directly, the platform will take a 10% cut, and the entire flow will be handled without our project ever touching a credit card.</p><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text"><b><strong style="white-space: pre-wrap;">Important note:</strong></b> The <code spellcheck="false" style="white-space: pre-wrap;">WHOP_API_KEY</code> must be a <b><strong style="white-space: pre-wrap;">company API key</strong></b> from your company&apos;s Settings &gt; API Keys page on Whop. Not the app API key from Developer &gt; Apps. Both use the <code spellcheck="false" style="white-space: pre-wrap;">apik_</code> prefix, so you can&apos;t tell them apart by looking at the key.</div></div><p>We&apos;ll use Whop&apos;s Direct Charge model where payments go directly to the writer&apos;s connected Whop account:</p><ol><li>Subscriber pays the monthly subscription fee</li><li>Whop Payments Network processes the charge as a Direct Charge</li><li>Writer&apos;s connected account receives the 90% minus processing fees</li><li>Our platform gets 10% application fee</li><li>Whop fires a webhook and our project creates a Subscription record</li></ol><p>Keep in mind that the 10% fee is defined in the <code>src/constants/config.ts</code> file:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">config.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">export const PLATFORM_FEE_PERCENT = 10;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="connected-accounts-and-kyc">Connected accounts and KYC</h3><p>Before our writers can start posting paid articles and receive payments, they must verify their identity. We do this by prompting the writers to click the &quot;Enable Paid Subscriptions&quot; button which creates a Whop account for them and redirect the writer to a Whop hosted KYC page. This way we don&apos;t have to deal with storing and delivering any KYC information.</p><p>To do this, let&apos;s go to <code>src/app/api/writers/[id]/kyc</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 &quot;next/server&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;
import { whop } from &quot;@/lib/whop&quot;;

export async function POST(
  _request: NextRequest,
  { params }: { params: Promise&lt;{ id: string }&gt; }
) {
  const { id } = await params;

  const user = await requireAuth({ redirect: false });
  if (!user) {
    return NextResponse.json({ error: &quot;Not authenticated&quot; }, { status: 401 });
  }

  const limited = rateLimit(`kyc:${user.id}`, {
    interval: 60_000,
    maxRequests: 5,
  });
  if (limited) return limited;

  const writer = await prisma.writer.findUnique({
    where: { id },
    include: { user: { select: { email: true } } },
  });
  if (!writer) {
    return NextResponse.json({ error: &quot;Writer not found&quot; }, { status: 404 });
  }
  if (writer.userId !== user.id) {
    return NextResponse.json(
      { error: &quot;Not your publication&quot; },
      { status: 403 }
    );
  }

  if (writer.kycCompleted) {
    return NextResponse.json({ error: &quot;KYC already completed&quot; }, { status: 409 });
  }

  let companyId = writer.whopCompanyId;

  if (!companyId) {
    const company = await whop.companies.create({
      title: writer.name,
      parent_company_id: process.env.WHOP_COMPANY_ID!,
      email: writer.user.email,
    });

    companyId = company.id;

    await prisma.writer.update({
      where: { id },
      data: { whopCompanyId: companyId },
    });
  }

  const setupCheckout = await whop.checkoutConfigurations.create({
    company_id: companyId,
    mode: &quot;setup&quot;,
    redirect_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings`,
  });

  return NextResponse.json({ url: setupCheckout.purchase_url });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="pricing-and-inline-plan-creation">Pricing and inline plan creation</h3><p>After writers complete the KYC, they can set a subscription price. The setting route (in <code>src/app/api/writers/[id]/route.ts</code>) validates the price with Zod (minimum $1.00, maximum $1,000.00) and stores it as <code>monthlyPriceInCents</code> on the Writer record.</p><p>When a reader clicks the &quot;Subscribe&quot; button, the author&apos;s ID is directed to the checkout route and used to create a Whop checkout configuration, after which the reader is redirected to a hosted checkout URL. Once the payment is complete, a subscription record is created via webhook.</p><p>To do this, go to <code>src/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 &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;
import { whop } from &quot;@/lib/whop&quot;;
import { PLATFORM_FEE_PERCENT } from &quot;@/constants/config&quot;;

const checkoutSchema = z.object({
  writerId: z.string().min(1),
});

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

  const limited = rateLimit(`checkout:${user.id}`, {
    interval: 60_000,
    maxRequests: 10,
  });
  if (limited) return limited;

  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json(
      { error: &quot;Invalid JSON body&quot; },
      { status: 400 }
    );
  }

  const parsed = checkoutSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0].message },
      { status: 400 }
    );
  }

  const { writerId } = parsed.data;

  const writer = await prisma.writer.findUnique({ where: { id: writerId } });
  if (!writer) {
    return NextResponse.json({ error: &quot;Writer not found&quot; }, { status: 404 });
  }
  if (!writer.kycCompleted) {
    return NextResponse.json(
      { error: &quot;Writer has not completed KYC&quot; },
      { status: 400 }
    );
  }
  if (!writer.whopCompanyId) {
    return NextResponse.json(
      { error: &quot;Writer does not have a connected account&quot; },
      { status: 400 }
    );
  }

  const existingSub = await prisma.subscription.findUnique({
    where: { userId_writerId: { userId: user.id, writerId } },
  });
  if (existingSub &amp;&amp; existingSub.status === &quot;ACTIVE&quot;) {
    return NextResponse.json(
      { error: &quot;You are already subscribed to this writer&quot; },
      { status: 409 }
    );
  }

  const priceInCents = writer.monthlyPriceInCents ?? 0;
  const priceInDollars = priceInCents / 100;
  const applicationFee = Math.round(priceInCents * PLATFORM_FEE_PERCENT) / 10000;

  const checkout = await whop.checkoutConfigurations.create({
    plan: {
      company_id: writer.whopCompanyId,
      currency: &quot;usd&quot;,
      renewal_price: priceInDollars,
      billing_period: 30,
      plan_type: &quot;renewal&quot;,
      release_method: &quot;buy_now&quot;,
      application_fee_amount: applicationFee,
      product: {
        external_identifier: `penstack-writer-${writer.id}`,
        title: `${writer.name} Subscription`,
      },
    },
    redirect_url: `${process.env.NEXT_PUBLIC_APP_URL}/${writer.handle}`,
    metadata: {
      userId: user.id,
      writerId: writer.id,
    },
  });

  return NextResponse.json({ url: checkout.purchase_url });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="updating-the-sdk-constructor">Updating the SDK constructor</h3><p>Before writing the webhook handler, update the <code>getWhop()</code> function in <code>src/lib/whop.ts</code> to include the webhook secret by adding <code>webhookKey</code> to the constructor:</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">_whop = new Whop({
  appID: process.env.WHOP_APP_ID!,
  apiKey: process.env.WHOP_API_KEY!,
  webhookKey: btoa(process.env.WHOP_WEBHOOK_SECRET!),
  ...(process.env.WHOP_SANDBOX === &quot;true&quot; &amp;&amp; {
    baseURL: &quot;https://sandbox-api.whop.com/api/v1&quot;,
  }),
});</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Create the webhook on the company&apos;s Developer page (not the app&apos;s Webhooks tab).</p><h3 id="the-webhook-handler">The webhook handler</h3><p>The webhook handler is where payment state materializes in your database. If it&apos;s broken, payments succeed on Whop&apos;s side but your app never knows, subscribers pay but can&apos;t access content.</p><p>The handler must meet three requirements: <strong>signature verification</strong> (reject tampered payloads), <strong>idempotency</strong> (process each event exactly once), and <strong>correct event routing</strong> (map each event type to the right database update).</p><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text"><b><strong style="white-space: pre-wrap;">Important note:</strong></b> Event names use <b><strong style="white-space: pre-wrap;">dots</strong></b> (<code spellcheck="false" style="white-space: pre-wrap;">payment.succeeded</code>, <code spellcheck="false" style="white-space: pre-wrap;">membership.activated</code>), not underscores.</div></div><p>Go to <code>src/app/api/webhooks/whop</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&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 &quot;next/server&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { whop } from &quot;@/lib/whop&quot;;

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

  let webhookData: { type: string; data: Record&lt;string, unknown&gt;; id?: string };
  try {
    webhookData = (await whop.webhooks.unwrap(rawBody, { headers })) as unknown as {
      type: string;
      data: Record&lt;string, unknown&gt;;
      id?: string;
    };
  } catch (err) {
    console.error(&quot;Webhook unwrap error:&quot;, err);
    return NextResponse.json(
      { error: &quot;Invalid webhook signature&quot; },
      { status: 401 }
    );
  }

  const eventId = webhookData.id ?? (webhookData.data.id as string);
  const event = webhookData.type;
  const data = webhookData.data;

  const existing = await prisma.webhookEvent.findUnique({
    where: { id: eventId },
  });
  if (existing) {
    return NextResponse.json({ received: true });
  }

  try {
    switch (event) {
      case &quot;payment.succeeded&quot;:
        await handlePaymentSucceeded(data);
        break;
      case &quot;payment.failed&quot;:
        await handlePaymentFailed(data);
        break;
      case &quot;membership.activated&quot;:
        await handleMembershipActivated(data);
        break;
      case &quot;membership.deactivated&quot;:
        await handleMembershipDeactivated(data);
        break;
      default:
        break;
    }

    await prisma.webhookEvent.create({
      data: { id: eventId, eventType: event },
    });
  } catch (error) {
    console.error(`Webhook handler error for ${event}:`, error);
    return NextResponse.json(
      { error: &quot;Internal webhook processing error&quot; },
      { status: 500 }
    );
  }

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

async function handlePaymentSucceeded(data: Record&lt;string, unknown&gt;) {
  const membershipId = data.membership_id as string | undefined;
  if (!membershipId) return;

  const subscription = await prisma.subscription.findUnique({
    where: { whopMembershipId: membershipId },
    include: { writer: true },
  });
  if (!subscription) return;

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

  await prisma.notification.create({
    data: {
      userId: subscription.writer.userId,
      type: &quot;PAYMENT_RECEIVED&quot;,
      title: &quot;Payment received&quot;,
      message: &quot;A subscriber payment was successfully processed.&quot;,
      writerId: subscription.writerId,
    },
  });
}

async function handlePaymentFailed(data: Record&lt;string, unknown&gt;) {
  const membershipId = data.membership_id as string | undefined;
  if (!membershipId) return;

  const subscription = await prisma.subscription.findUnique({
    where: { whopMembershipId: membershipId },
    include: { writer: true },
  });
  if (!subscription) return;

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

  await prisma.notification.create({
    data: {
      userId: subscription.writer.userId,
      type: &quot;PAYMENT_FAILED&quot;,
      title: &quot;Payment failed&quot;,
      message: &quot;A subscriber payment failed to process.&quot;,
      writerId: subscription.writerId,
    },
  });
}

async function handleMembershipActivated(data: Record&lt;string, unknown&gt;) {
  const membershipId = data.id as string;
  const userId = (data.metadata as Record&lt;string, unknown&gt;)?.userId as
    | string
    | undefined;
  const writerId = (data.metadata as Record&lt;string, unknown&gt;)?.writerId as
    | string
    | undefined;
  const currentPeriodEnd = data.current_period_end as string | undefined;

  if (!userId || !writerId) return;

  const subscription = await prisma.subscription.upsert({
    where: { userId_writerId: { userId, writerId } },
    update: {
      status: &quot;ACTIVE&quot;,
      whopMembershipId: membershipId,
      currentPeriodEnd: currentPeriodEnd
        ? new Date(currentPeriodEnd)
        : undefined,
      cancelledAt: null,
    },
    create: {
      userId,
      writerId,
      status: &quot;ACTIVE&quot;,
      whopMembershipId: membershipId,
      currentPeriodEnd: currentPeriodEnd
        ? new Date(currentPeriodEnd)
        : undefined,
    },
  });

  const writer = await prisma.writer.findUnique({ where: { id: writerId } });
  if (writer) {
    await prisma.notification.create({
      data: {
        userId: writer.userId,
        type: &quot;NEW_SUBSCRIBER&quot;,
        title: &quot;New subscriber&quot;,
        message: &quot;Someone just subscribed to your publication!&quot;,
        writerId,
      },
    });
  }

  return subscription;
}

async function handleMembershipDeactivated(data: Record&lt;string, unknown&gt;) {
  const membershipId = data.id as string;

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

  await prisma.subscription.update({
    where: { id: subscription.id },
    data: {
      status: &quot;CANCELLED&quot;,
      cancelledAt: new Date(),
    },
  });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="subscription-status-checking">Subscription status checking</h3><p>This function lives in the subscription service created in Part 4 (<code>src/services/subscription-service.ts</code>). It checks whether a user can access a writer&apos;s paid content, and runs on every paid post page load.</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">subscription-service.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">export async function canAccessPaidContent(
  userId: string,
  writerId: string
) {
  const sub = await prisma.subscription.findUnique({
    where: { userId_writerId: { userId, writerId } },
  });
  if (!sub) return false;
  if (sub.status !== &quot;ACTIVE&quot; &amp;&amp; sub.status !== &quot;CANCELLED&quot;) return false;

  if (sub.status === &quot;CANCELLED&quot; &amp;&amp; sub.currentPeriodEnd) {
    return sub.currentPeriodEnd &gt; new Date();
  }

  return sub.status === &quot;ACTIVE&quot;;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Cancelled subscribers keep access until <code>currentPeriodEnd</code> passes. They&apos;ve already paid for the current cycle, so we don&apos;t want to revoke access early.</p><h3 id="checkpoint-first-payment-processed">Checkpoint: first payment processed</h3><p>Test the complete payment flow in the Whop sandbox (<code>WHOP_SANDBOX=true</code>):</p><ol><li>Complete KYC in writer settings</li><li>Set a monthly price (like $5.00)</li><li>In an incognito window, log in as a different user and subscribe using a test card</li><li>Check application logs for webhook receipt and confirm a Subscription record exists with status <code>ACTIVE</code></li><li>Publish a PAID post. The subscribed user sees full content, a non-subscriber sees the paywall</li><li>Cancel the subscription and verify access persists until <code>currentPeriodEnd</code></li></ol><p>In the next part, we add the features like explore, notification, and chat that directly affects engagement and churn.</p><h2 id="part-6-explore-notifications-and-chat">Part 6: Explore, notifications, and chat</h2><p>In the project&apos;s current state, the only way for readers to find publications is if they know the link addresses, and this is a problem. We will solve this with a explore section on our homepage, set up a notification system to keep readers engaged, and add embedded chats that users can utilise in publication profiles.</p><p>The explore page will feature two distinct sections serving two different purposes. One will be a trending publications section (a list of authors with high engagement) and beneath it, a reverse chronological list of all publications&apos; posts.</p><h3 id="the-trending-algorithm">The trending algorithm</h3><p>The trending section ranks writers by a score computed from three signals:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title"></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">score = followers * 1 + subscribers * 3 + recent_posts_14d * 2</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Subscribers are weighted at 3x because a paid subscription is the strongest engagement signal. Recent posts carry 2x to ensure active writers surface above dormant ones. Followers sit at 1x as a baseline. The 14-day window for &quot;recent posts&quot; is defined in <code>src/constants/config.ts</code> as <code>TRENDING_WINDOW_DAYS</code>.</p><p>The new posts feed uses cursor-based pagination. When users click the &quot;Load more&quot; button, the client sends the post ID they see on the screen to the server, and the server sends the next batch.</p><p>Go to <code>src/services</code> and create a file called <code>explore-service.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">explore-service.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 { prisma } from &quot;@/lib/prisma&quot;;
import {
  POSTS_PER_PAGE,
  TRENDING_WRITERS_COUNT,
  TRENDING_WINDOW_DAYS,
  TRENDING_WEIGHTS,
} from &quot;@/constants/config&quot;;
import type { PublicationCategory } from &quot;@/generated/prisma/client&quot;;

export async function getTrendingWriters(limit = TRENDING_WRITERS_COUNT) {
  const windowStart = new Date();
  windowStart.setDate(windowStart.getDate() - TRENDING_WINDOW_DAYS);

  const writers = await prisma.writer.findMany({
    include: {
      user: { select: { displayName: true, avatarUrl: true } },
      _count: { select: { followers: true, subscriptions: true } },
      posts: {
        where: { published: true, publishedAt: { gte: windowStart } },
        select: { id: true },
      },
    },
  });

  const scored = writers.map((writer) =&gt; {
    const score =
      writer._count.followers * TRENDING_WEIGHTS.followers +
      writer._count.subscriptions * TRENDING_WEIGHTS.subscribers +
      writer.posts.length * TRENDING_WEIGHTS.recentPosts;
    return { ...writer, trendingScore: score };
  });

  scored.sort((a, b) =&gt; b.trendingScore - a.trendingScore);
  return scored.slice(0, limit).map(({ posts, ...rest }) =&gt; rest);
}

export async function getRecentPosts(
  opts: { cursor?: string; limit?: number; category?: PublicationCategory } = {}
) {
  const { cursor, limit = POSTS_PER_PAGE, category } = opts;

  const posts = await prisma.post.findMany({
    where: {
      published: true,
      ...(category ? { writer: { category } } : {}),
    },
    orderBy: { publishedAt: &quot;desc&quot; },
    take: limit + 1,
    ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
    include: {
      writer: {
        include: {
          user: { select: { displayName: true, avatarUrl: true } },
        },
      },
      _count: { select: { likes: true } },
    },
  });

  const hasMore = posts.length &gt; limit;
  const items = hasMore ? posts.slice(0, limit) : posts;
  const nextCursor = hasMore ? items[items.length - 1].id : null;

  return { items, nextCursor };
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>The <code>PostFeed</code> client component (see <code>src/components/explore/post-feed.tsx</code> in the <a href="https://github.com/whopio/whop-tutorials/tree/main/penstack">repo</a>) manages cursor state and appends results on each &quot;Load more&quot; click. The server renders the first page; subsequent pages are fetched client-side. The button disappears when <code>nextCursor</code> is null.</p><h3 id="category-filtering">Category filtering</h3><p>To deliver the articles that actually interest individual writers, we&apos;re going to include a category filter that updates the URL to <code>/?category=TECHNOLOGY</code>, making filtered views shareable.</p><p>Using URL params instead of component state means the server re-renders with filtered data on each navigation, and users can share filtered links directly.</p><p>Go to <code>src/components/explore</code> and create a file called <code>category-filter.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">category-filter.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">&quot;use client&quot;;

import { useRouter, useSearchParams } from &quot;next/navigation&quot;;
import { PublicationCategory } from &quot;@/generated/prisma/browser&quot;;
import { CATEGORY_LABELS } from &quot;@/constants/categories&quot;;

export function CategoryFilter() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const active = searchParams.get(&quot;category&quot;);

  function handleSelect(category: string | null) {
    const params = new URLSearchParams(searchParams.toString());
    if (category) {
      params.set(&quot;category&quot;, category);
    } else {
      params.delete(&quot;category&quot;);
    }
    router.push(`/?${params.toString()}`);
  }

  return (
    &lt;div className=&quot;flex gap-2 overflow-x-auto pb-2 scrollbar-none&quot;&gt;
      &lt;button
        onClick={() =&gt; handleSelect(null)}
        className={`shrink-0 rounded-full px-4 py-1.5 text-sm font-medium ${
          !active ? &quot;bg-gray-900 text-white&quot; : &quot;bg-gray-100 text-gray-600&quot;
        }`}
      &gt;
        All
      &lt;/button&gt;
      {Object.values(PublicationCategory).map((cat) =&gt; (
        &lt;button
          key={cat}
          onClick={() =&gt; handleSelect(cat)}
          className={`shrink-0 rounded-full px-4 py-1.5 text-sm font-medium ${
            active === cat ? &quot;bg-gray-900 text-white&quot; : &quot;bg-gray-100 text-gray-600&quot;
          }`}
        &gt;
          {CATEGORY_LABELS[cat]}
        &lt;/button&gt;
      ))}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="notification-system">Notification system</h3><p>Our project has five notification types: new post, new subscriber, new follower, and payment received/failed. To create this service, go to <code>src/services</code> and create a file called <code>notification-service.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">notification-service.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 { prisma } from &quot;@/lib/prisma&quot;;
import type { NotificationType } from &quot;@/generated/prisma/client&quot;;

export async function notifyFollowers(
  writerId: string,
  type: NotificationType,
  title: string,
  message: string,
  refs?: { postId?: string; writerId?: string }
) {
  const followers = await prisma.follow.findMany({
    where: { writerId },
    select: { userId: true },
  });
  if (followers.length === 0) return;

  await prisma.notification.createMany({
    data: followers.map((f) =&gt; ({
      userId: f.userId,
      type,
      title,
      message,
      postId: refs?.postId,
      writerId: refs?.writerId,
    })),
  });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="embedded-whop-chat">Embedded Whop chat</h3><p>Live chats are one of the biggest engagement drivers in these types of projects, and it allows writers to form a community much more easily.</p><p>Rather than building WebSocket infrastructure, message storage, moderation, and presence indicators, we embed Whop&apos;s chat components directly.</p><p>The chat needs an access token, so we create a rate-limited endpoint that returns the user&apos;s Whop OAuth token.</p><p>Go to <code>src/app/api/token</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&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 &quot;next/server&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;

export async function GET() {
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: &quot;Not authenticated&quot; }, { status: 401 });
  }

  const limited = rateLimit(`token:${session.userId}`, {
    interval: 60_000,
    maxRequests: 30,
  });
  if (limited) return limited;

  return NextResponse.json({ accessToken: session.accessToken ?? null });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then, go to <code>src/components/chat</code> and create a file called <code>writer-chat.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">writer-chat.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">&quot;use client&quot;;

import { useEffect, useState, type CSSProperties, type FC, type ReactNode } from &quot;react&quot;;
import { Elements } from &quot;@whop/embedded-components-react-js&quot;;
import { loadWhopElements } from &quot;@whop/embedded-components-vanilla-js&quot;;

let ChatElement: FC&lt;{ options: { channelId: string }; style?: CSSProperties }&gt; | undefined;
let ChatSession: FC&lt;{ token: () =&gt; Promise&lt;string&gt;; children: ReactNode }&gt; | undefined;

try {
  const mod = require(&quot;@whop/embedded-components-react-js&quot;);
  ChatElement = mod.ChatElement;
  ChatSession = mod.ChatSession;
} catch {
}

interface WriterChatProps {
  channelId: string;
  className?: string;
}

async function getToken(): Promise&lt;string&gt; {
  const res = await fetch(&quot;/api/token&quot;);
  const data = await res.json();
  return data.accessToken;
}

export function WriterChat({ channelId, className }: WriterChatProps) {
  const [elements, setElements] =
    useState&lt;Awaited&lt;ReturnType&lt;typeof loadWhopElements&gt;&gt;&gt;(null);

  useEffect(() =&gt; {
    loadWhopElements().then(setElements);
  }, []);

  if (!elements || !ChatElement || !ChatSession) {
    return (
      &lt;div className={className}&gt;
        &lt;div className=&quot;flex h-[500px] items-center justify-center rounded-xl border border-gray-200 bg-gray-50 text-gray-500&quot;&gt;
          &lt;p&gt;Chat is loading...&lt;/p&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;Elements elements={elements}&gt;
      &lt;ChatSession token={getToken}&gt;
        &lt;div className={className}&gt;
          &lt;ChatElement
            options={{ channelId }}
            style={{ height: &quot;500px&quot;, width: &quot;100%&quot;, borderRadius: &quot;12px&quot;, overflow: &quot;hidden&quot; }}
          /&gt;
        &lt;/div&gt;
      &lt;/ChatSession&gt;
    &lt;/Elements&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>For TypeScript to accept these imports, we need a type augmentation.</p><p>Go to <code>src/types</code> and create a file called <code>whop-chat.d.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop-chat.d.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 type { CSSProperties, FC, ReactNode } from &quot;react&quot;;

declare module &quot;@whop/embedded-components-react-js&quot; {
  export interface ChatElementOptions {
    channelId: string;
    deeplinkToPostId?: string;
    onEvent?: (event: { type: string; detail: Record&lt;string, unknown&gt; }) =&gt; void;
  }

  export const ChatElement: FC&lt;{ options: ChatElementOptions; style?: CSSProperties }&gt;;
  export const ChatSession: FC&lt;{ token: () =&gt; Promise&lt;string&gt;; children: ReactNode }&gt;;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>The <code>channelId</code> comes from the writer&apos;s <code>whopChatChannelId</code> field. The <code>chatPublic</code> boolean controls access: when false, only subscribers see the chat section on the writer&apos;s profile page.</p><h3 id="the-writer-analytics-dashboard">The writer analytics dashboard</h3><p>Since writers in our project can receive payments and actually run a platform of their own, we need to provide them with information about their performance. The dashboard (see <code>src/app/dashboard/page.tsx</code> in the <a href="https://github.com/whopio/whop-tutorials/tree/main/penstack">repo</a>) shows four stat cards: subscribers, followers, total views, total posts - followed by a table of all posts with status, visibility, view count, and like count.<br>Add the following function to <code>src/services/writer-service.ts</code>:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">writer-service.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">export async function getWriterStats(writerId: string) {
  const [subscriberCount, followerCount, totalViews, postCount] =
    await Promise.all([
      prisma.subscription.count({ where: { writerId, status: &quot;ACTIVE&quot; } }),
      prisma.follow.count({ where: { writerId } }),
      prisma.post.aggregate({
        where: { writerId, published: true },
        _sum: { viewCount: true },
      }),
      prisma.post.count({ where: { writerId, published: true } }),
    ]);

  return {
    subscribers: subscriberCount,
    followers: followerCount,
    totalViews: totalViews._sum.viewCount ?? 0,
    totalPosts: postCount,
  };
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h2 id="part-7-demo-mode-polish-and-production-readiness">Part 7: Demo mode, polish, and production readiness</h2><p>Our platform is now almost entirely ready. Authors can share content, readers can subscribe, payments are processed via Direct Charge, and notifications inform all users of important actions. There are a few things you need to pay attention to before completing the project.</p><h3 id="demo-mode">Demo mode</h3><p>The subscribe button uses a hybrid approach. Writers who have completed KYC and have a connected Whop account (<code>whopCompanyId</code> + <code>kycCompleted</code>) get a real Whop sandbox checkout.</p><p>Readers are redirected to a hosted checkout page, and a subscription record is created via webhook after payment. Since we&apos;re already using Whop Sandbox keys throughout this tutorial, no real money is involved.</p><p>For seeded demo writers (created by the seed script, without a connected Whop account), the subscribe button shows a <code>DemoModal</code> that explains the writer hasn&apos;t completed payment setup. Clicking &quot;Confirm subscription&quot; creates a mock subscription via <code>/api/demo/subscribe</code> without touching the payment network.</p><p>The <code>SubscribeButton</code> component receives a <code>hasCheckout</code> prop from the server:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title"></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">hasCheckout={!!writer.whopCompanyId &amp;&amp; !!writer.kycCompleted}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>When <code>hasCheckout</code> is true, it calls <code>/api/checkout</code> (real Whop checkout). When false, it opens the demo modal. This way the demo infrastructure (<code>src/lib/demo.ts</code>, <code>src/components/demo/</code>, <code>src/app/api/demo/</code>, <code>prisma/seed.ts</code>) is only used as a fallback for writers without payment setup.</p><h3 id="rate-limiting-reference">Rate limiting reference</h3><p>Nearly every API route uses the in-memory rate limiter we built in Part 1. The webhook endpoint is excluded since Whop controls call frequency.</p>
<!--kg-card-begin: html-->
<table>
<tr><th>Route</th><th>Key pattern</th><th>Max requests</th><th>Window</th></tr>
<tr><td><code>GET /api/auth/login</code></td><td><code>auth:login</code> (global)</td><td>10</td><td>60s</td></tr>
<tr><td><code>GET /api/posts</code></td><td><code>posts:list</code> (global)</td><td>60</td><td>60s</td></tr>
<tr><td><code>POST /api/posts</code></td><td><code>posts:create:{userId}</code></td><td>10</td><td>60s</td></tr>
<tr><td><code>POST /api/posts/[id]/like</code></td><td><code>like:{userId}</code></td><td>30</td><td>60s</td></tr>
<tr><td><code>POST /api/writers</code></td><td><code>writers:create:{userId}</code></td><td>5</td><td>60s</td></tr>
<tr><td><code>PATCH /api/writers/[id]</code></td><td><code>writer:update:{userId}</code></td><td>20</td><td>60s</td></tr>
<tr><td><code>POST /api/writers/[id]/kyc</code></td><td><code>kyc:{userId}</code></td><td>5</td><td>60s</td></tr>
<tr><td><code>POST /api/checkout</code></td><td><code>checkout:{userId}</code></td><td>10</td><td>60s</td></tr>
<tr><td><code>POST /api/follow</code></td><td><code>follow:{userId}</code></td><td>30</td><td>60s</td></tr>
<tr><td><code>GET /api/notifications</code></td><td><code>notifications:{userId}</code></td><td>30</td><td>60s</td></tr>
<tr><td><code>GET /api/token</code></td><td><code>token:{userId}</code></td><td>30</td><td>60s</td></tr>
<tr><td><code>POST /api/demo/subscribe</code></td><td><code>demo:subscribe:{userId}</code></td><td>10</td><td>60s</td></tr>
</table>

<!--kg-card-end: html-->
<h3 id="security-and-performance">Security and performance</h3><p>Our session cookies are set to <code>SameSite: Lax</code>. This prevents malicious websites from sending requests to our site on behalf of users who have logged into our project. Additionally, because Tiptap stores shares as JSON rather than HTML and we use the <code>escapeHtml</code> function, malicious users cannot use scripts as share content.</p><p>All routes that write or read user data use <code>requireAuth()</code>. The only exceptions are the public feed (<code>/api/posts</code>), public profiles (<code>/api/writers/[id]</code>), and the webhook endpoint (which verifies Whop&apos;s signature instead).</p><p>For the sake of performance, we use the Next.js&apos; <code>Image</code> component for all uploaded images to get automatic format conversion, resizing, and lazy loading. The Tiptap editor is also dynamically imported so users never download the editor code:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title"></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">const Editor = dynamic(() =&gt; import(&quot;@/components/editor/post-editor&quot;), { ssr: false });</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="switching-from-sandbox-to-live-whop-keys">Switching from sandbox to live Whop keys</h3><p>Throughout this tutorial we&apos;ve used the Whop sandbox keys (from sandbox.whop.com) so we could test payments without moving real money. To go live, you need to get new keys from Whop.com:</p><ol><li>Go to whop.com, open your whop&apos;s dashboard, and navigate to the Developer page</li><li>Create a new app (or use an existing one) and grab the App ID, API Key, Company ID, Client ID, and Client Secret</li><li>Set the OAuth redirect URL to your production domain: <code>https://your-app.vercel.app/api/auth/callback</code></li><li>Create a company-level webhook pointing to <code>https://your-app.vercel.app/api/webhooks/whop</code> and copy the new webhook secret</li><li>Update your Vercel environment variables with the live keys (<code>WHOP_APP_ID</code>, <code>WHOP_API_KEY</code>, <code>WHOP_COMPANY_ID</code>, <code>WHOP_WEBHOOK_SECRET</code>, <code>WHOP_CLIENT_ID</code>, <code>WHOP_CLIENT_SECRET</code>)</li><li>Remove <code>WHOP_SANDBOX=true</code> from your environment variables (or leave it unset)<br></li></ol><p>Once the sandbox variable is gone, the <code>src/lib/whop.ts</code> SDK client automatically points to the live Whop API and OAuth endpoints instead of the sandbox ones.</p><h3 id="deployment-checklist">Deployment checklist</h3><h4 id="production">Production</h4><ol><li>All environment variables set in Vercel: <code>WHOP_APP_ID</code>, <code>WHOP_API_KEY</code>, <code>WHOP_COMPANY_ID</code>, <code>WHOP_WEBHOOK_SECRET</code>, <code>WHOP_CLIENT_ID</code>, <code>WHOP_CLIENT_SECRET</code>, <code>DATABASE_URL</code>, <code>DIRECT_URL</code>, <code>UPLOADTHING_TOKEN</code>, <code>SESSION_SECRET</code>, <code>NEXT_PUBLIC_APP_URL</code></li><li>Schema pushed: <code>npx prisma db push</code></li><li>Webhook URL configured in Whop: <code>https://your-app.vercel.app/api/webhooks/whop</code></li><li>OAuth redirect URL in Whop: <code>https://your-app.vercel.app/api/auth/callback</code></li><li>Uploadthing callback URL configured for production domain</li></ol><h4 id="demo-optional">Demo (optional)</h4><ol><li>Create a second Vercel project from the same repository</li><li>Configure a separate Supabase database (never share the production database)</li><li>Push schema and run seed: <code>npx prisma db push &amp;&amp; npm run db:seed</code></li><li>Seeded writers use the demo subscribe fallback; real writers who complete KYC get sandbox checkout</li></ol><h3 id="verification-1">Verification</h3><p>Before calling the platform complete:</p><ul><li>Rate limiting rejects excessive requests (429 response)</li><li>Subscribe buttons redirect to Whop checkout for KYC&apos;d writers, or show demo modal for seeded writers</li><li>The webhook handler creates subscription records after successful payments</li><li>All environment variables are set and the build succeeds on Vercel</li></ul><h2 id="what-weve-built-and-whats-next">What we&apos;ve built and what&apos;s next</h2><p>Over seven parts, we built a functional Substack clone where:</p><ul><li>Users can become writers and create publications</li><li>Post preview, paid, or free articles</li><li>Readers can follow and subscribe to writers</li><li>Preview and paid articles are kept safe from unsubscribed readers</li><li>Readers can leave likes on articles</li><li>Publication profiles have embedded Whop chats</li></ul><p>The full source code is available at <a href="https://github.com/whopio/whop-tutorials/tree/main/penstack">https://github.com/whopio/whop-tutorials/tree/main/penstack</a>. The live demo is running at <a href="https://penstack-fresh.vercel.app/" rel="noopener nofollow">https://penstack-fresh.vercel.app/</a>.</p><h2 id="build-your-own-platform-with-whop-payments-network">Build your own platform with Whop Payments Network</h2><p>In this project, we used <a href="https://network.whop.com/">Whop Payments Network</a>, the Whop API, and the Whop infrastructure to easily solve some of the most challenging parts of building a fully functional project that can actually move money and offer a safe experience to the users.</p><p>This Substack clone is one of the many platform examples you can build with Whop. You can check out our other build with Whop guides in our <a href="https://whop.com/blog/t/tutorials/" rel="noreferrer">tutorials</a> category and learn more about the entire Whop infrastructure in our <a href="https://docs.whop.com/">developer documentation</a>.</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