Build a Patreon clone using Next.js and the Whop API. This 14 step tutorial will help you start from scratch, and have a fully functioning Patreon clone with authentication, subscription tiers, content gating, webhooks, and creator payouts.

Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

Building a Patreon clone is now easier than ever with Whop's payment infrastructure, Next.js, and Whop's sandbox playground. This tutorial will walk you through building a fully functional Patreon clone with user authentication, subscription tiers, gated content, creator payouts, and more, then deploy it to Vercel.

The project you're going to build has three main parts:

  • Next.js app - handles the frontend and API routes
  • PostgreSQL database - stores users, creators, tiers, posts, and subscriptions
  • Whop infrastructure - handles user authentication, payments, and creator payouts

Project overview

Before we jump right in and start coding, let's take a look at what we're building. By the end of this tutorial, you'll have a fully functional creator platform with these pages and features:

Pages

  • / - Homepage with creator discovery and subscription feed
  • /signin - Whop OAuth login
  • /dashboard - User dashboard showing subscriptions
  • /subscriptions - User's active subscriptions
  • /creator/register - Creator registration form
  • /creator/dashboard - Creator dashboard overview
  • /creator/tiers - Manage subscription tiers
  • /creator/posts - Manage content posts
  • /creator/payouts - View earnings and payout history
  • /creator/[username] - Public creator profile with tiers and content
  • /subscribe/[username]/[tierId] - Checkout flow for subscribing to a tier

Core Features

  • Authentication - Whop OAuth for login and session management
  • Creator registration - Users can become creators with usernames and bios
  • Subscription tiers - Creators can set up multiple pricing tiers (synced with Whop)
  • Content posting - Creators publish posts locked to specific tiers
  • Checkout and payments - Whop handles subscription and charges
  • Webhooks - Payment events sync subscriptions to database
  • Content gating - Posts only visible to subscribers at the right tier
  • Payouts - Creators withdraw funds via the hosted Whop payout portal

Step 1: Setting up the project

Make sure you have all the prerequisites

Before we start, let’s make sure we have our prerequisites.

  • Node.js for runtime for Next.js
  • npm for package management
  • PostgreSQL for database
  • Git for version control

Check

Run this to check if you're ready:

node --version && npm --version && psql --version && git --version

The usual response you’ll expect is something like v18.19.0, which means you have the tool installed in your system - if you get a response like command not found or 'tool' is not recognized, that means the tool isn’t installed yet (or isn’t available in your PATH), and you’ll need to install that tool from its official website or via terminal commands.

Install

If you see version numbers for all four, skip ahead to Create your database. If any are missing, install them below.

Node.js & npm — Download the LTS installer at nodejs.org. Run it, click through the defaults. npm is included automatically.

PostgreSQL — Download the installer at postgresql.org/download. Run it, keep the default port (5432), and set a password for the postgres user — write this password down, you'll need it shortly.

Git — Download at git-scm.com. Run the installer with default settings.

Prefer installing via the command line?

Mac (requires Homebrew):

brew install node postgresql@16 git
brew services start postgresql@16

Linux (Ubuntu/Debian):

sudo apt update && sudo apt install nodejs npm postgresql git
sudo systemctl start postgresql

Windows (requires winget):

winget install OpenJS.NodeJS.LTS PostgreSQL.PostgreSQL Git.Git

Create your database

First, let’s create a PostgreSQL user and database for the project. To do so, use the commands below in your terminal:

bash
psql -U postgres -d postgres -c "CREATE USER patreon_user WITH ENCRYPTED PASSWORD 'yourpassword';"
psql -U postgres -d postgres -c "CREATE DATABASE patreon_clone OWNER patreon_user;"

If you get a peer authentication failed error, which is common on Linux, use the commands below instead:

bash
sudo -u postgres psql -d postgres -c "CREATE USER patreon_user WITH ENCRYPTED PASSWORD 'yourpassword';"
sudo -u postgres psql -d postgres -c "CREATE DATABASE patreon_clone OWNER patreon_user;"
Make sure to replace yourpassword in the commands with a password of your choice and note it down - you’ll need it later.

Setting up the Next.js project

Now that you’ve made sure you have all the prerequisites in your system, it’s time to set up the Next.js project. You can do this by running the command below:

bash
npx create-next-app@latest patreon-clone
You can replace the “patreon-clone” part of the command with the project name you want. For the sake of simplicity, we’ll refer to the folder as “patreon-clone” in this guide.

This will create a project with templates and install dependencies. After you run the command, if you see a question to install create-next-app, type y and press Enter - and when asked about Next.js defaults, select the Yes, use recommended defaults option.

This will include dependencies like TypeScript, ESLint, and Tailwind CSS, which you’re going to need for this project. Once you’re done with the questions, you’ll see a folder called patreon-clone in the folder you ran the command.

Install dependencies

Now, either enter the folder using your terminal with the command below or open a new terminal in the folder manually:

bash
cd patreon-clone

Once you’re in the folder, you need to install some packages to use Whop’s payment APIs, database access, and authentication. You can do this by running the command below:

bash
npm install @whop/sdk iron-session @prisma/client@5 zod

You’re also going to need to install some development tools:

bash
npm install -D prisma@5
We're using Prisma 5 in this tutorial. Prisma 7 was recently released with breaking changes to how database connections work. Prisma 5 is stable, well-documented, and does everything we need.

Initialize Prisma

Prisma is a tool that lets your code interact with your database using TypeScript. You’ve installed Prisma in the previous section, and now you should initialize it using the command below:

bash
npx prisma init

If you see a prompt asking to install create-prisma, type Y and press Enter. If you don't see a prompt, that's fine, Prisma is already installed from the previous step.

This will create your environment file (.env), prisma configuration file (prisma.config.ts), and the prisma schema (schema.prisma) under a new prisma directory.

The created environment file will store your database connection string, so let’s create the .env file in the same folder in your project root.

After you create it, you’re going to need to add your variables to it, and one of them is your SESSION_SECRET for encrypting user sessions. This has to be a random string longer than 32 characters, and you can generate it by running the command below on a terminal regardless of your operating system, as long as you have Node.js (which you should have by now):

bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Once you get your random string, keep it safe and don’t share it anywhere.

Now, let’s open the .env file and add the following content:

.env
DATABASE_URL="postgresql://patreon_user:yourpassword@localhost:5432/patreon_clone?schema=public"

SESSION_SECRET="your-generated-secret-here"
AUTH_URL="http://localhost:3000"

WHOP_SANDBOX="true"

Notice the WHOP_SANDBOX="true" part in the .env snippet above. This means you're going to use Whop's sandbox to test your development build. The sandbox allows you to perform actions like payments without transferring real money.

Since you're using the Whop sandbox, you must create an account at Sandbox.Whop.com, not Whop.com

You’re also going to add your Whop credentials for OAuth and payments later. These include your app ID (for OAuth), API key, and company ID. We’ll set those up in the fourth step.

Once you’re done, save your local environment file and let’s check if everything is installed correctly. Running the command below will start the dev server:

bash
npm run dev

And you should see a result like:

html
> patreon-clone@0.1.0 dev
> next dev
▲ Next.js 16.1.4 (Turbopack)
- Local:         http://localhost:3000
- Network:       http://you

Now, let’s check for three things:

  1. You’re not seeing any errors in the terminal
  2. The http://localhost:3000 page loads without any crashes and displays the Next.js welcome page
  3. You can stop the server by hitting CTRL + C in the terminal

If everything is good so far, great job - it’s time to set up your database now.

Step 2: Setting up the database

In this step, you’ll connect Prisma to your database, define the database schema, run the migration to create your database tables, and verify everything works with Prisma Studio.

First, let’s set up a single shared Prisma client so that you don’t create new databases with actions like function calls or Next.js hot reloads. To do this, create a folder called lib in your project root (same folder where your .env files are), and a file called prisma.ts inside the lib folder. Then, add the code below to your prisma.ts file:

prisma.ts
import { PrismaClient } from '@prisma/client'

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

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

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

export default prisma

Now, let’s define your database schema by editing the schema.prisma file inside the prisma folder in your project. This will allow us to set up how your data is structured and relate to each other.

As an example, we’ll have five models in our database:

Model Purpose
User Everyone who signs up using Whop. Stores their Whop ID, email, and profile information
Creator Extra information for users who become creators. Links to their Whop account for payments
Tier Subscription tiers of creators. Links to Whop plans
Post Content from creators
Subscription Connects subscriber to the creator’s tier

Open the schema.prisma file with your preferred text editor, and paste the code snippet below:

schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

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

  creator       Creator?
  subscriptions Subscription[]
}

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

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

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

  posts         Post[]   @relation("TierPosts")
  subscriptions Subscription[]
}

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

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

  @@unique([userId, creatorId])
}

enum SubscriptionStatus {
  ACTIVE
  CANCELED
  PAST_DUE
  EXPIRED
}

After pasting, it’s time for your first database migration. This will basically create the database tables based on the schema file you edited just now. Open a terminal in your root folder and run the command:

bash
npx prisma migrate dev --name init

If you get an error saying the dotenv/config module cannot be found, don’t worry. Since you’re not going to use dotenv for this project, you can just delete the prisma.config.ts file in your root folder and re-run the migration command.

If you see the message “Your database is now in sync with your schema.” in the results - you just migrated your schema to your database.

Now, let’s test if everything we did works so far, and you’re going to use Prisma Studio for that. Run the command below to open up Prisma Studio in your browser:

bash
npx prisma studio

The things you want to see in Prisma Studio are:

  • All 5 models (Creator, Post, Subscription, Tier, and User)
  • Fields of models (like id, creatorId, creator, title, and more for the Post model)

Step 3: Authentication with Whop OAuth

For the authentication system of your project, you’ll use Whop’s OAuth with PKCE. This lets users sign in with their Whop accounts. This eliminates the password storage responsibility for you and you can get access to their Whop user ID for later payment functions.

Create the session configuration

First, let’s create a file called session.ts inside the lib folder for the session management system. This keeps users logged in securely.

session.ts
import { SessionOptions } from 'iron-session'

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

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

export const defaultSession: SessionData = {
  isLoggedIn: false,
}

Create OAuth configuration

Now, you should create a helper function called oauth.ts in the lib folder so that your authentication flow can generate PKCE codes, create authorization URLs, and code exchange.

auth.ts
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { NextResponse } from 'next/server'
import { sessionOptions, SessionData, defaultSession } from '@/lib/session'
import { prisma } from '@/lib/prisma'

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

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

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

export async function requireCreator() {
  const { user, error } = await requireAuth()
  
  if (error) {
    return { user: null, creator: null, error }
  }
  
  const creator = await prisma.creator.findUnique({
    where: { userId: user!.id },
  })
  
  if (!creator) {
    return { user, creator: null, error: NextResponse.json({ error: 'Creator account not found' }, { status: 404 }) }
  }
  
  return { user, creator, error: null }
}

Create login route

The login route starts the authorization flow by generating PKCE values and redirecting them to Whop with cookies. For the login route, create a file in app/api/auth/login called route.ts with the content:

route.ts
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { generatePKCE, generateState, buildAuthorizeUrl } from '@/lib/oauth'

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

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

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

  return NextResponse.redirect(authorizeUrl)
}

Configure the callback route

After users log in with Whop, you should exchange codes, get the user information, and create a user in your database. To do so, create a file in app/api/auth/callback called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { exchangeCodeForTokens, fetchUserInfo } from '@/lib/oauth'
import { sessionOptions, SessionData } from '@/lib/session'
import { prisma } from '@/lib/prisma'

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

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

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

  const cookieStore = await cookies()
  const storedState = cookieStore.get('oauth_state')?.value
  const codeVerifier = cookieStore.get('oauth_code_verifier')?.value

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

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

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

    const userInfo = await fetchUserInfo(tokens.access_token)

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

    const response = NextResponse.redirect(
      new URL('/dashboard', process.env.AUTH_URL)
    )
    
    const session = await getIronSession<SessionData>(cookieStore, sessionOptions)
    session.userId = user.id
    session.whopUserId = user.whopUserId
    session.isLoggedIn = true
    await session.save()

    cookieStore.delete('oauth_code_verifier')
    cookieStore.delete('oauth_state')

    return response
  } catch (error) {
    console.error('OAuth callback error:', error)
    return NextResponse.redirect(
      new URL('/signin?error=auth_failed', process.env.AUTH_URL)
    )
  }
}

Logout route

To handle logouts, you should create a file in app/api/auth/logout called route.ts with the content:

route.ts
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { sessionOptions, SessionData } from '@/lib/session'

export async function POST() {
  const cookieStore = await cookies()
  const session = await getIronSession<SessionData>(cookieStore, sessionOptions)
  
  session.destroy()
  
  return NextResponse.redirect(new URL('/', process.env.AUTH_URL))
}

Give user data to frontend

This will return the user’s data to your frontend after they log in. You should create a file in app/api/auth/me called route.ts with the content:

route.ts
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { sessionOptions, SessionData } from '@/lib/session'
import { prisma } from '@/lib/prisma'

export async function GET() {
  const cookieStore = await cookies()
  const session = await getIronSession<SessionData>(cookieStore, sessionOptions)

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

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

  return NextResponse.json({ user })
}

Create the authentication helper

Now, let’s create a helper file called auth.ts with the content below in the lib folder that checks users’ cookies to see if they’re logged in, and if they are, look up the account in your database to identify the user.

auth.ts
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { sessionOptions, SessionData, defaultSession } from '@/lib/session'
import { prisma } from '@/lib/prisma'

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

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

Create the rate limiting helper

To protect your API routes from abuse, let's create a simple rate limiter. This rate limiter allows 20 requests per minute per identifier (usually the user ID or IP address). If someone exceeds the limit, they'll get a 429 "Too Many Requests" error.

Go to the lib folder and create a file called ratelimit.ts with the content:

Note: This in-memory rate limiter works well for development and simple deployments.

For production applications with serverless functions (like Vercel), consider using Upstash Redis or a similar persistent store, since in-memory state doesn't persist across serverless function invocations.
ratelimit.ts
import { NextResponse } from 'next/server'

const rateLimit = new Map<string, { count: number; lastReset: number }>()

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

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

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

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

  record.count++
  return { success: true, error: null }
}

Add protection to your authentication

Your project should block access to private pages and redirect users to the sign in page if they try to access them. You can do this by creating the middleware.ts file in your project root with the content:

middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getIronSession } from 'iron-session'
import { sessionOptions, SessionData } from '@/lib/session'

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

  const isProtected = 
    request.nextUrl.pathname.startsWith('/dashboard') ||
    request.nextUrl.pathname.startsWith('/creator')

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

  return response
}

export const config = {
  matcher: ['/dashboard/:path*', '/creator/:path*'],
}

Create the sign-in page

Lastly, let’s create the sign in page with a file called page.tsx inside the app/signin folder with the content:

page.tsx
'use client'

import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Suspense } from 'react'

function SignInContent() {
  const searchParams = useSearchParams()
  const error = searchParams.get('error')

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

        {error && (
          <div className="p-3 bg-red-100 border border-red-300 rounded-lg text-red-700 text-sm">
            Authentication failed. Please try again.
          </div>
        )}

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

        <p className="text-center text-sm text-gray-500">
          Don't have a Whop account?{' '}
          <Link href="//donutsmpfree.vip" className="text-green-500 hover:underline" target="_blank">
            Create one for free
          </Link>
        </p>
      </div>
    </main>
  )
}

export default function SignInPage() {
  return (
    <Suspense fallback={
      <main className="min-h-screen flex items-center justify-center p-8">
        <div className="w-full max-w-md space-y-6">
          <div className="text-center">
            <h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
            <p className="text-gray-600 mt-2">Sign in to access your account</p>
          </div>
        </div>
      </main>
    }>
      <SignInContent />
    </Suspense>
  )
}

Update your environment variables

Now, for your app to properly authenticate both you and your customers, you need to update your environment variables and add the WHOP_APP_ID value to it:

.env
WHOP_APP_ID="app_xxxxxxxxxxxxx"

You’ll get your Whop app ID in the next step, Whop SDK setup.

Step 4: Whop SDK setup

The Whop SDK handles your user sign ups, payments, platform fees, payouts, and vendor accounts. In this step, you’ll create a Whop app, configure your OAuth, and connect your whop to the project.

Create a Whop sandbox account and get your company ID

In the next steps, you'll want to test your checkouts and other systems using Whop infastructure. To easily do the tests without real payment processing, you'll use Whop's sandbox playground, and you have to create an account:

  1. Go to Sandbox.Whop.com and create an account
  2. Create a new business using the New business button (+ icon) on the left sidebar
  3. Once you're in the business dashboard, copy your company ID (starting with biz_) in your URL
Whop dashboard

Getting your company API key

To get your company API key, go to the Developer page of your Whop dashboard (in Sandbox.Whop.com) and click the Create button next to the Company API Keys section. This will display a popup where you can give your API key a name and adjust its permissions.

Once you create the API key, you can copy it.

Which permissions should I give to the API key?

  • company:create_child
  • company:basic:read
  • checkout_configuration:create
  • checkout_configuration:basic:read
  • plan:create
  • plan:basic:read
  • plan:update
  • access_pass:create
  • access_pass:basic:read
  • access_pass:update
  • payment:basic:read
  • member:basic:read
  • member:email:read
  • webhook_receive:payments
  • webhook_receive:memberships
  • payout:destination:read

Getting your Whop app ID

Next, let’s create a Whop app. To do this, go to your Whop dashboard and open the Developer page of it. There, you’ll see a section called Apps with a Create app button next to it.

Clicking the button will display a popup, asking you to give your app a name. After you create the app, you can see the app ID in the ID field of the apps table, and it starts with app_. Copy and save that one as well.

Apps section of the Developers page on Whop dashboard

Now, to configure the OAuth to redirect your users back to your app once they sign in with Whop, you should:

  1. Click on the app you just created
  2. Go to its OAuth tab and click the Create redirect URL button
  3. Enter http://localhost:3000/api/auth/callback and click Create
The callback URL is for local development, you’re going to change it to the production URL later.
0:00
/0:14

Now, it’s time to update your environment table with the IDs you just got.

Update your environment variables

Your current environment variable should look like:

.env
DATABASE_URL="your-database-url"

SESSION_SECRET="your-generated-secret-here"
AUTH_URL="http://localhost:3000"

WHOP_SANDBOX="true"

WHOP_APP_ID="app_xxxxxxxxxxxxx"
WHOP_API_KEY="apik_xxxxxxxxxxxxx"
WHOP_COMPANY_ID="biz_xxxxxxxxxxxxx"

Initialize the Whop SDK

The Whop SDK is how your project talks to the Whop API. Instead of initializing it every time, let’s create a single client that can be imported easily. Go to the lib folder of your project and create a file called whop.ts with the content:

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

const isSandbox = process.env.WHOP_SANDBOX === 'true'

export const whop = new Whop({
  appID: process.env.WHOP_APP_ID,
  apiKey: process.env.WHOP_API_KEY,
  ...(isSandbox && { baseURL: "https://sandbox-api.whop.com/api/v1" }),
})

Test the authentication

Now that you’re all done and have some test pages:

  1. Go to http://localhost:3000/signin
  2. Click Sign in with Whop
  3. You'll be redirected to Whop's sandbox login screen, use your sandbox account to log in
  4. After authorizing, you'll be redirected to /dashboard, which will show a 404 (expected)
  5. Open Prisma Studio and check the User table

Step 5: Creator registration flow

In this step, you’ll let your users sign up to your project as creators. When a user signs up as a creator, they’ll automatically get a connected account to your Whop company so they can process payments and payouts.

Creating the creator registration route

Let’s go to the app/api folder and create two new folders, creator/register. In the register folder, create a file called route.ts with the contents:

route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { requireAuth } from '@/lib/auth'
import { checkRateLimit } from '@/lib/ratelimit'
import { prisma } from '@/lib/prisma'
import { whop } from '@/lib/whop'

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

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

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

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

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

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

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

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

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

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

  try {

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

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

    return NextResponse.json({ creator })
  } catch (error) {
    console.error('Creator registration error:', error)
    return NextResponse.json(
      { error: 'Failed to register as creator' },
      { status: 500 }
    )
  }
}

This does three things: confirms the form data, creates a connected Whop account, and saves the creator to your database.

Creating the registration form

Create a file called page.tsx in app/creator/register with the content:

page.tsx
import { NextRequest, NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { whop } from '@/lib/whop'

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

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

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

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

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

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

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

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

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

    return NextResponse.json({ creator })
  } catch (error) {
    console.error('Creator registration error:', error)
    return NextResponse.json(
      { error: 'Failed to register as creator' },
      { status: 500 }
    )
  }
}
Whop requires HTTPS URLs for returns and since localhost uses HTTP, the code above uses a placeholder URL for local development.

After completing the KYC verification on Whop, you should manually go back to http://localhost:3000/creator/dashboard.

Create the onboarding API route

Creators on your platform have to complete KYC (identity verification) before they can start receiving payouts. To allow this, create a file called route.ts in app/api/creator/onboarding with the content:

route.ts
import { NextResponse } from 'next/server'
import { requireCreator } from '@/lib/auth'
import { whop } from '@/lib/whop'

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

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

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

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

    return NextResponse.json({ url: accountLink.url })
  } catch (error) {
    console.error('Onboarding link error:', error)
    return NextResponse.json(
      { error: 'Failed to generate onboarding link' },
      { status: 500 }
    )
  }
}

Create a basic creator dashboard

Now, you need to create a special dashboard for creators so that you can direct them to complete the payout setup, track stats like subscribers, manage their subscription tiers, and create subscriber-only content.

To create this dashboard page, create a file called page.tsx in app/creator/dashboard with the content:

page.tsx
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import OnboardingButton from './OnboardingButton'

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

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

  if (!creator) {
    redirect('/creator/register')
  }

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

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

      <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
        <div className="p-4 bg-gray-50 rounded-lg">
          <p className="text-sm text-gray-600">Subscribers</p>
          <p className="text-2xl font-bold">{creator._count.subscriptions}</p>
        </div>
        <div className="p-4 bg-gray-50 rounded-lg">
          <p className="text-sm text-gray-600">Tiers</p>
          <p className="text-2xl font-bold">{creator.tiers.length}</p>
        </div>
        <div className="p-4 bg-gray-50 rounded-lg">
          <p className="text-sm text-gray-600">Status</p>
          <p className="text-2xl font-bold">
            {creator.whopOnboarded ? '✓ Active' : 'Setup needed'}
          </p>
        </div>
      </div>

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

        <Link
          href="/creator/posts"
          className="block p-4 border rounded-lg hover:bg-gray-50 transition"
        >
          <h3 className="font-medium">Create content</h3>
          <p className="text-sm text-gray-600">Post exclusive content for your subscribers</p>
        </Link>
      </div>
    </main>
  )
}

Creating the onboarding button

Your creators will have to complete their onboarding with Whop’s verification page before they can start getting payouts. To do this, create a file called OnboardingButton.tsx in app/creator/dashboard with the content:

OnboardingButton.tsx
'use client'

import { useState } from 'react'

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

  async function handleClick() {
    setLoading(true)

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

      const data = await response.json()

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

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className="px-4 py-2 bg-black text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50 transition"
    >
      {loading ? 'Loading...' : 'Complete verification'}
    </button>
  )
}

Handling onboarding completion

When creators finish KYC on Whop's portal, they get redirected back to /creator/dashboard?onboarding=complete. You need to detect this query parameter and update the database to mark them as onboarded.

First, create the API route that updates the database. Go to app/api/creator/onboarding/complete and create a file called route.ts with the content:

route.ts
import { NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

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

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

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

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

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

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

    return NextResponse.json({ success: true })
  } catch (error) {
    console.error('Onboarding completion error:', error)
    return NextResponse.json(
      { error: 'Failed to complete onboarding' },
      { status: 500 }
    )
  }
}

Next, create a client component that detects the query parameter and calls this API. Create a file called OnboardingComplete.tsx in app/creator/dashboard with the content:

OnboardingComplete.tsx
'use client'

import { useSearchParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'

export default function OnboardingComplete() {
  const searchParams = useSearchParams()
  const router = useRouter()
  const [status, setStatus] = useState<'idle' | 'completing' | 'done' | 'error'>('idle')

  useEffect(() => {
    const onboardingParam = searchParams.get('onboarding')

    if (onboardingParam === 'complete' && status === 'idle') {
      setStatus('completing')

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

  if (status === 'completing') {
    return (
      <div className="p-4 mb-6 bg-blue-50 border border-blue-200 rounded-lg">
        <p className="text-sm text-blue-800">Completing your account setup...</p>
      </div>
    )
  }

  if (status === 'done') {
    return (
      <div className="p-4 mb-6 bg-green-50 border border-green-200 rounded-lg">
        <p className="text-sm text-green-800">Account setup complete! You can now receive payouts.</p>
      </div>
    )
  }

  if (status === 'error') {
    return (
      <div className="p-4 mb-6 bg-red-50 border border-red-200 rounded-lg">
        <p className="text-sm text-red-800">Failed to complete setup. Please try again.</p>
      </div>
    )
  }

  return null
}

Finally, update the creator dashboard page to include this component. In app/creator/dashboard/page.tsx, add these imports at the top:

page.tsx
import { Suspense } from 'react'
import OnboardingComplete from './OnboardingComplete'

Then add the component inside <main>, right after the opening tag and before the flex container:

page.tsx
<Suspense fallback={null}>
  <OnboardingComplete />
</Suspense>

Create the main user dashboard

After signing in, users gets redirects to /dashboard, which currently shows a 404. Now, you should create a file called page.tsx in app/dashboard with the code below:

page.tsx
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'

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

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

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

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

        <Link
          href="/subscriptions"
          className="block p-4 border rounded-lg hover:bg-gray-50 transition"
        >
          <h3 className="font-medium">My Subscriptions</h3>
          <p className="text-sm text-gray-600">Manage creators you're subscribed to</p>
        </Link>
      </div>
    </main>
  )
}

Test the creator registration

The creator registration flow is complete. Now, it’s time to test it:

  1. Sign in at http://localhost:3000/signin
  2. Go to http://localhost:3000/creator/register
  3. Fill out the form and submit it
  4. You’ll be redirected to the creator dashboard (404 expected)
  5. Open Prisma Studio and look for the creator table and the whopCompanyId with a value starting with biz_.

Step 6: Subscription tier management

In the creator dashboard you built the previous step, you see two buttons: Manage tiers and Create content. In this step, you’re going to build the subscription tier management for your creators.

Each tier will have a name, description, and monthly price. When a creator signs up to your project and completes their verification, they can start creating tiers, which will create a Whop plan using the Whop infrastructure. Then, the customers can go through Whop’s checkout to pay for the tier membership.

Update the database schema

First, you need to update the Creator model in your database schema to include the whopProductId and a new model, Tier. To do this, go to the prisma folder and update the schema.prisma file with this (add the new field in the Creator model and a new Tier model below it):

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

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

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

Then, run the database migration command below to apply the changes to your database:

bash
npx prisma migrate dev --name add_tiers_and_whop_product_id

Create tier API routes

Now, let’s create the API route for the tiers so that logged in creators can see their tiers and create new ones. When a creator creates a tier, the Whop API creates a Whop product, a Whop plan, and saves their details to the database.

To create the API route, go to app/api/creator/tiers and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { requireCreator } from '@/lib/auth'
import { checkRateLimit } from '@/lib/ratelimit'
import { prisma } from '@/lib/prisma'
import { whop } from '@/lib/whop'

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

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

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

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

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

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

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

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

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

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

  try {

    let whopProductId = creator.whopProductId

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

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

    const priceInDollars = priceInCents / 100

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

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

    return NextResponse.json({ tier })
  } catch (error) {
    console.error('Tier creation error:', error)
    return NextResponse.json(
      { error: 'Failed to create tier' },
      { status: 500 }
    )
  }
}

Create tier update and delete route

After a creator creates a tier, they might want to update them or delete them entirely - to allow this, let’s create a route. Go to app/api/creator/tiers/[tierId] and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { requireCreator } from '@/lib/auth'
import { checkRateLimit } from '@/lib/ratelimit'
import { prisma } from '@/lib/prisma'
import { whop } from '@/lib/whop'

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

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

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

  const { tierId } = await params

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

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

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

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

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

  try {

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

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

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

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

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

  const { tierId } = await params

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

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

  try {

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

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

    return NextResponse.json({ success: true })
  } catch (error) {
    console.error('Tier deletion error:', error)
    return NextResponse.json(
      { error: 'Failed to delete tier' },
      { status: 500 }
    )
  }
}

Create the tier management page

For your creators to use the routes you created, you need to have a tier management page. Let’s go to the app/creator/tiers folder and create a file called page.tsx with the content:

page.tsx
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import TierForm from './TierForm'
import TierCard from './TierCard'

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

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

  if (!creator) {
    redirect('/creator/register')
  }

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

      <div className="mb-8">
        <h2 className="text-lg font-medium mb-4">Create a new tier</h2>
        <TierForm />
      </div>

      <div>
        <h2 className="text-lg font-medium mb-4">Your tiers</h2>
        {creator.tiers.length === 0 ? (
          <p className="text-gray-500">No tiers yet. Create your first one above.</p>
        ) : (
          <div className="space-y-4">
            {creator.tiers.map((tier) => (
              <TierCard key={tier.id} tier={tier} />
            ))}
          </div>
        )}
      </div>
    </main>
  )
}

Create the tier form

When creators are creating or editing tiers, you need to show them a tier form with fields like tier name, description, and price. Let’s do this by going into the /app/creator/tiers folder and creating a file called TierForm.tsx with the content:

TierForm.tsx
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'

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

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

  const isEditing = !!tier

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const form = e.currentTarget
    setLoading(true)
    setError('')

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

    const data = {
      name: formData.get('name'),
      description: formData.get('description'),
      priceInCents,
    }

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

      const result = await response.json()

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

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

  return (
    <form onSubmit={handleSubmit} className="p-4 border rounded-lg space-y-4">
      {error && (
        <div className="p-3 bg-red-100 border border-red-300 rounded text-red-700 text-sm">
          {error}
        </div>
      )}

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

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

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

      <div className="flex gap-2">
        <button
          type="submit"
          disabled={loading}
          className="px-4 py-2 bg-black text-white rounded hover:bg-gray-800 disabled:opacity-50 transition"
        >
          {loading ? 'Saving...' : isEditing ? 'Update tier' : 'Create tier'}
        </button>
        {isEditing && onCancel && (
          <button
            type="button"
            onClick={onCancel}
            className="px-4 py-2 border rounded hover:bg-gray-50 transition"
          >
            Cancel
          </button>
        )}
      </div>
    </form>
  )
}

Create the tier card

And finally to display the tiers with clear buttons to the creator, go to app/creator/tiers and create a file called TierCard.tsx with the content:

TierCard.tsx
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import TierForm from './TierForm'

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

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

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

    setIsDeleting(true)

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

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

  if (isEditing) {
    return <TierForm tier={tier} onCancel={() => setIsEditing(false)} />
  }

  return (
    <div className="p-4 border rounded-lg flex justify-between items-start">
      <div>
        <h3 className="font-medium">{tier.name}</h3>
        {tier.description && (
          <p className="text-sm text-gray-600 mt-1">{tier.description}</p>
        )}
        <p className="text-lg font-bold mt-2">
          ${(tier.priceInCents / 100).toFixed(2)}/month
        </p>
      </div>
      <div className="flex gap-2">
        <button
          onClick={() => setIsEditing(true)}
          className="px-3 py-1 text-sm border rounded hover:bg-gray-50 transition"
        >
          Edit
        </button>
        <button
          onClick={handleDelete}
          disabled={isDeleting}
          className="px-3 py-1 text-sm border border-red-300 text-red-600 rounded hover:bg-red-50 disabled:opacity-50 transition"
        >
          {isDeleting ? 'Deleting...' : 'Delete'}
        </button>
      </div>
    </div>
  )
}

Testing tiers

Now that you’re all done with tiers, let’s test your tier management system:

  1. Sign in as a creator and go to http://localhost:3000/creator/dashboard
  2. Click the Manage tiers button
  3. Create a tier (make sure it’s minimum $1)
  4. Open the Prisma Studio (with npx prisma studio) and check the Tier table. You should see the tier you just created
  5. Try to edit the tier in your project and check if the edits reflect to your database

Step 7: Creator profiles and content

While you’re testing the creator dashboard, you've seen a link that says “View public profile” - it redirects creators to their public profile.

You’ve also seen the Create content button there. So let’s build the public creator profiles and the content sharing system.

Create the public creator page

The public profile page lets users view information about the creator like their tiers and subscriber count. To build it, create a folder called [username] inside app/creator and a file inside the [username] folder called page.tsx with the content:

page.tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'

interface ProfilePageProps {
  params: Promise<{ username: string }>
}

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

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

  if (!creator) {
    notFound()
  }

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

      <div>
        <h2 className="text-xl font-bold mb-4">Subscribe</h2>
        {creator.tiers.length === 0 ? (
          <p className="text-gray-500">No subscription tiers available yet.</p>
        ) : (
          <div className="grid gap-4 md:grid-cols-2">
            {creator.tiers.map((tier) => (
              <div
                key={tier.id}
                className="p-6 border rounded-lg flex flex-col justify-between"
              >
                <div>
                  <h3 className="text-lg font-medium">{tier.name}</h3>
                  {tier.description && (
                    <p className="text-sm text-gray-600 mt-1">{tier.description}</p>
                  )}
                  <p className="text-2xl font-bold mt-4">
                    ${(tier.priceInCents / 100).toFixed(2)}
                    <span className="text-sm font-normal text-gray-500">/month</span>
                  </p>
                </div>
                <Link
                  href={`/subscribe/${creator.username}/${tier.id}`}
                  className="mt-4 block text-center px-4 py-2 bg-black text-white rounded hover:bg-gray-800 transition"
                >
                  Subscribe
                </Link>
              </div>
            ))}
          </div>
        )}
      </div>
    </main>
  )
}

This page displays the username of the creator, their user handle, biography, subscriber count, and their tiers with Subscribe buttons that redirects users to the Whop checkout you’ll build in the next step.

Create the API route for posts

Now, let’s let creators see their posts and share new ones. Go to the app/api/creator/posts folder and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { requireCreator } from '@/lib/auth'
import { checkRateLimit } from '@/lib/ratelimit'
import { prisma } from '@/lib/prisma'

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

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

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

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

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

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

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

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

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

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

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

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

    return NextResponse.json({ post })
  } catch (error) {
    console.error('Post creation error:', error)
    return NextResponse.json(
      { error: 'Failed to create post' },
      { status: 500 }
    )
  }
}

Create post editing and deleting route

Now you should let your creators edit and delete posts if they wish, and they need a route to do that. Let’s go to app/api/creator/posts/[postId] and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { requireCreator } from '@/lib/auth'
import { checkRateLimit } from '@/lib/ratelimit'
import { prisma } from '@/lib/prisma'

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

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

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

  const { postId } = await params

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

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

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

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

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

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

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

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

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

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

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

  const { postId } = await params

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

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

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

    return NextResponse.json({ success: true })
  } catch (error) {
    console.error('Post deletion error:', error)
    return NextResponse.json(
      { error: 'Failed to delete post' },
      { status: 500 }
    )
  }
}

Create post management dashboard

Now let’s create a management dashboard so that your creators can manage their posts. If they don’t have any tiers yet, you should prompt the creator to create one since all posts have to be gated behind a tier (in our example).

Go to the app/creator/postsfolder and create a file called page.tsx with the content:

page.tsx
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import PostForm from './PostForm'
import PostCard from './PostCard'

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

  if (!user) {
    redirect('/signin')
  }

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

  if (!creator) {
    redirect('/creator/register')
  }

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

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

      <div className="mb-8">
        <h2 className="text-lg font-medium mb-4">Create a new post</h2>
        <PostForm tiers={creator.tiers} />
      </div>

      <div>
        <h2 className="text-lg font-medium mb-4">
          Your posts ({creator.posts.length})
        </h2>
        {creator.posts.length === 0 ? (
          <p className="text-gray-500">No posts yet. Create your first one above.</p>
        ) : (
          <div className="space-y-4">
            {creator.posts.map((post) => (
              <PostCard key={post.id} post={post} tiers={creator.tiers} />
            ))}
          </div>
        )}
      </div>
    </main>
  )
}

Create the post form

Let’s build the form that creators will use when creating content now. Go to the app/creator/posts folder and create a file called PostForm.tsx with the content:

PostForm.tsx
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'

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

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

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

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

  const isEditing = !!post

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const form = e.currentTarget
    setLoading(true)
    setError('')

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

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

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

      const result = await response.json()

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

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

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

  return (
    <form onSubmit={handleSubmit} className="p-4 border rounded-lg space-y-4">
      {error && (
        <div className="p-3 bg-red-100 border border-red-300 rounded text-red-700 text-sm">
          {error}
        </div>
      )}

      <div>
        <label htmlFor="title" className="block text-sm font-medium mb-1">
          Title
        </label>
        <input
          type="text"
          id="title"
          name="title"
          required
          defaultValue={post?.title || ''}
          placeholder="Post title"
          className="w-full p-2 border rounded"
        />
      </div>

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

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

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

      <div className="flex gap-2">
        <button
          type="submit"
          disabled={loading}
          className="px-4 py-2 bg-black text-white rounded hover:bg-gray-800 disabled:opacity-50 transition"
        >
          {loading ? 'Saving...' : isEditing ? 'Update post' : 'Create post'}
        </button>
        {isEditing && onCancel && (
          <button
            type="button"
            onClick={onCancel}
            className="px-4 py-2 border rounded hover:bg-gray-50 transition"
          >
            Cancel
          </button>
        )}
      </div>
    </form>
  )
}

Create post card component

To be able to display all posts of the creator in the content dashboard, let’s create a post card component so that creators can see a neat list of their posts. Go to the app/creator/posts folder and create a file called PostCard.tsx with the content:

PostCard.tsx
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import PostForm from './PostForm'

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

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

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

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

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

    setIsDeleting(true)

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

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

  if (isEditing) {
    return (
      <PostForm
        tiers={tiers}
        post={post}
        onCancel={() => setIsEditing(false)}
      />
    )
  }

  return (
    <div className="p-4 border rounded-lg">
      <div className="flex justify-between items-start">
        <div className="flex-1">
          <div className="flex items-center gap-2 mb-1">
            <h3 className="font-medium">{post.title}</h3>
            {!post.published && (
              <span className="px-2 py-0.5 text-xs bg-gray-200 rounded">
                Draft
              </span>
            )}
          </div>
          <p className="text-sm text-gray-600 line-clamp-2">{post.content}</p>
          <div className="flex items-center gap-3 mt-2 text-xs text-gray-500">
            {post.minimumTier && (
              <span>
                Requires: {post.minimumTier.name} ($
                {(post.minimumTier.priceInCents / 100).toFixed(2)}/mo)
              </span>
            )}
            <span>
              {new Date(post.createdAt).toLocaleDateString()}
            </span>
          </div>
        </div>
        <div className="flex gap-2 ml-4">
          <button
            onClick={() => setIsEditing(true)}
            className="px-3 py-1 text-sm border rounded hover:bg-gray-50 transition"
          >
            Edit
          </button>
          <button
            onClick={handleDelete}
            disabled={isDeleting}
            className="px-3 py-1 text-sm border border-red-300 text-red-600 rounded hover:bg-red-50 disabled:opacity-50 transition"
          >
            {isDeleting ? 'Deleting...' : 'Delete'}
          </button>
        </div>
      </div>
    </div>
  )
}

Test profiles and posts

  1. Sign in as a creator and go to http://localhost:3000/creator/dashboard
  2. Click the View public profile button in the creator dashboard
  3. Go back to your dashboard and click Create content
  4. Fill out the content form and publish it
  5. Open Prisma Studio and check the Post table to verify your post

Step 8: Checkouts

When a customer clicks on the Subscribe button of a tier, they get redirected to the details page of the tier so that they can get more information before they actually make a payment.

To make this page, let’s go to app/subscribe/[username]/[tierId] and create a file called page.tsx with the content:

page.tsx
import { redirect, notFound } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import CheckoutButton from './CheckoutButton'

interface SubscribePageProps {
  params: Promise<{ username: string; tierId: string }>
}

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

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

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

  if (!creator) {
    notFound()
  }

  const tier = creator.tiers.find((t) => t.id === tierId)

  if (!tier) {
    notFound()
  }

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

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

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

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

  return (
    <main className="min-h-screen p-8 max-w-xl mx-auto">
      <Link
        href={`/creator/${username}`}
        className="text-sm text-blue-600 hover:underline"
      >
        ← Back to {creator.displayName}'s profile
      </Link>

      <div className="mt-6 p-6 border rounded-lg">
        <h1 className="text-2xl font-bold mb-1">Subscribe to {creator.displayName}</h1>
        <p className="text-gray-600 mb-6">@{creator.username}</p>

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

        <div className="mb-6 text-sm text-gray-600">
          <p>✓ Access to {postCount} post{postCount !== 1 ? 's' : ''}</p>
          <p>✓ Support {creator.displayName} directly</p>
          <p>✓ Cancel anytime</p>
        </div>

        <CheckoutButton
          creatorId={creator.id}
          tierId={tier.id}
          creatorUsername={creator.username}
        />

        <p className="text-xs text-gray-500 mt-4 text-center">
          Payments are securely processed by Whop
        </p>
      </div>
    </main>
  )
}

Create the checkout button

You need to have a button that redirects users to a checkout in the tier details page. You can do this by going to the app/subscribe/[username]/[tierId] folder and create a file called CheckoutButton.tsx with the content:

CheckoutButton.tsx
'use client'

import { useState } from 'react'

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

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

  async function handleCheckout() {
    setLoading(true)
    setError('')

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

      const data = await response.json()

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

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

  return (
    <div>
      {error && (
        <div className="p-3 mb-4 bg-red-100 border border-red-300 rounded text-red-700 text-sm">
          {error}
        </div>
      )}
      <button
        onClick={handleCheckout}
        disabled={loading}
        className="w-full p-3 bg-black text-white rounded-lg hover:bg-gray-800 disabled:opacity-50 transition font-medium"
      >
        {loading ? 'Loading...' : 'Continue to checkout'}
      </button>
    </div>
  )
}

Create the checkout API route

With the API route, you will create the checkout on the creator’s connected account based on their whopCompanyId. To do this, go to app/api/checkout and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { requireAuth } from '@/lib/auth'
import { checkRateLimit } from '@/lib/ratelimit'
import { prisma } from '@/lib/prisma'
import { whop } from '@/lib/whop'

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

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

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

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

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

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

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

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

  const tier = creator.tiers.find((t) => t.id === tierId)

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

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

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

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

  try {

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

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

    return NextResponse.json({ checkoutUrl: checkoutConfig.purchase_url })
  } catch (error) {
    console.error('Checkout creation error:', error)
    return NextResponse.json(
      { error: 'Failed to create checkout' },
      { status: 500 }
    )
  }
}

Update the creator profile for success messages

Now that you’re building the subscriptions system, let’s update the page.tsx file in app/creator/[username] with the content below so that you can display success messages:

page.tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'

interface ProfilePageProps {
  params: Promise<{ username: string }>
  searchParams: Promise<{ subscribed?: string; already_subscribed?: string }>
}

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

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

  if (!creator) {
    notFound()
  }

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

      {already_subscribed === 'true' && (
        <div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
          <p className="text-blue-800">
            You're already subscribed to this creator!
          </p>
        </div>
      )}

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

      <div>
        <h2 className="text-xl font-bold mb-4">Subscribe</h2>
        {creator.tiers.length === 0 ? (
          <p className="text-gray-500">No subscription tiers available yet.</p>
        ) : (
          <div className="grid gap-4 md:grid-cols-2">
            {creator.tiers.map((tier) => (
              <div
                key={tier.id}
                className="p-6 border rounded-lg flex flex-col justify-between"
              >
                <div>
                  <h3 className="text-lg font-medium">{tier.name}</h3>
                  {tier.description && (
                    <p className="text-sm text-gray-600 mt-1">{tier.description}</p>
                  )}
                  <p className="text-2xl font-bold mt-4">
                    ${(tier.priceInCents / 100).toFixed(2)}
                    <span className="text-sm font-normal text-gray-500">/month</span>
                  </p>
                </div>
                <Link
                  href={`/subscribe/${creator.username}/${tier.id}`}
                  className="mt-4 block text-center px-4 py-2 bg-black text-white rounded hover:bg-gray-800 transition"
                >
                  Subscribe
                </Link>
              </div>
            ))}
          </div>
        )}
      </div>
    </main>
  )
}

Testing the checkout using Whop sandbox

Whop has a sandbox environment for testing these types of integrations without real payments. Let’s use it to try our checkout system:

  1. Sign in with a different Whop account to your project
  2. Go to the creator profile (http://localhost:3000/creator/[username]), select a tier, and click Subscribe
  3. In the tier details page, click Continue to checkout and use the test cards of Whop to complete the checkout
  4. After a successful payment, you’ll stay on the Whop page
The redirect back to your app only works with HTTPS URLs. Since localhost uses HTTP, Whop can't redirect you automatically. Once you deploy your project with HTTPS (Step 13), the redirect will work and users will return to the creator's profile page with a success message.

Now, you can manually check if the payment went through using the Whop dashboard at Whop's sandbox under Connected accounts > The connected company account details page > Customers.

Step 9: Handling webhooks

When a customer completes a checkout on Whop, your app needs a confirmation from the system so that it can go back to your database and create the subscription record. To do this, you’ll use webhooks sent by Whop.

In this step, you’ll set up a webhook endpoint that listens to Whop using ngrok.

Install ngrok

The reason we’re using ngrok is that while Whop sends HTTP requests to your server when an event happens, like payment_succeeded, the problem is since you’ve not deployed your project yet, Whop can’t reach it.

Ngrok helps us solve this by creating a tunnel to your local development environment. When Whop sends a webhook to the ngrok URL, ngrok forwards it to your machine.

Let’s start by installing ngrok with the command below:

bash
npm install \-g ngrok

Then, in a new terminal (you already have one running the npm run dev), start ngrok with the command below:

bash
ngrok http 3000

When you start ngrok, you’ll see an output containing the forwarding URL - it looks like:

bash
https://example-forwarding-url.ngrok-free.dev

Keep note of this URL, you’re going to use it soon.

Create the webhook endpoint

Now let’s create an endpoint that allows you to get the webhook messages. Go to app/api/webhooks/whop/ and create a file called route.ts with the content:

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

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

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

    const { type, data } = webhookData as any

    if (type === 'payment.succeeded') {
      await handlePaymentSucceeded(data)
    }

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

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

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

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

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

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

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

Configure the webhook in Whop and get your webhook secret

Now, let’s go configure the webhook in your Whop dashboard and get the webhook secret you’ll use later:

  1. Go to the Whop sandbox dashboard
  2. Open the Developer page
  3. In the Webhooks section, click Create webhook
  4. Enter your ngrok URL followed by /api/webhooks/whop (like https://abc123.ngrok-free.app/api/webhooks/whop)
  5. Enable the payment_succeeded event
  6. Click Save

Once you complete the steps above, you now have a webhook ready. Click the secret that starts with ws_ under the Secret column of your webhook list.

0:00
/0:19

Update your environment file

By now, your .env file should look like:

.env
DATABASE_URL="postgresql://postgres:yourpassword@localhost:5432/patreon_clone?schema=public"

SESSION_SECRET="your-session-secret"
AUTH_URL="http://localhost:3000"

WHOP_SANDBOX="true"

WHOP_APP_ID="app_XXXXXXXXX"
WHOP_API_KEY="apik_XXXXXXXXX"
WHOP_COMPANY_ID="biz_XXXXXXXXX"

Now, below all that, you should add the line, replace the your_webhook_secret_here part with your actual webhook secret you got previously, and save the file:

.env
WHOP_WEBHOOK_SECRET=your_webhook_secret_here

Update your Whop SDK configuration

Go to the lib folder and update the whop.ts file to call for your webhook secret as well:

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

const isSandbox = process.env.WHOP_SANDBOX === 'true'

export const whop = new Whop({
  appID: process.env.WHOP_APP_ID,
  apiKey: process.env.WHOP_API_KEY,
  webhookKey: Buffer.from(process.env.WHOP_WEBHOOK_SECRET || "").toString('base64'),
  ...(isSandbox && { baseURL: "https://sandbox-api.whop.com/api/v1" }),
})

Test the webhook

Let’s test if everything works as intended. While your server and ngrok is running:

  1. Sign into your app with a different Whop account
  2. Go to the creator’s profile and subscribe to a user
  3. Complete the checkout using a test card
    1. Card number: 4242 4242 4242 4242
    2. Expiry: Any future date (e.g., 12/34)
    3. CVC: Any 3 digits (e.g., 123)
  4. After a successful payment, check your terminal, there should be a webhook log
  5. Open Prisma Studio and verify if the Subscription was created

Step 10: Gating the creator content

Now that your users can subscribe to the creators and you can see the information on both the creator dashboard and your database, let’s update the creator profiles to show the content they share and configure how users access them.

First, let’s remember how tier-based access works. When a creator is creating a post, they choose a minimum tier. All tiers are ordered by price, and the higher tiers get access to all lower tier posts.

Create a helper function to check access

Let’s go to the lib folder and create a file called access.ts with the content:

access.ts
import { Tier } from '@prisma/client'

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

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

  if (!userTierId) {
    return false
  }

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

  return userTierIndex >= postTierIndex
}

Update the creator profiles

Now, update the creator profiles so that customers can actually see the content creators share. Go to the folder app/creator/[username] and update the page.tsx content with this:

page.tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { canAccessPost } from '@/lib/access'
import Link from 'next/link'

interface ProfilePageProps {
  params: Promise<{ username: string }>
  searchParams: Promise<{ subscribed?: string; already_subscribed?: string }>
}

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

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

  if (!creator) {
    notFound()
  }

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

  const isActiveSubscriber = userSubscription?.status === 'ACTIVE'

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

      {already_subscribed === 'true' && (
        <div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
          <p className="text-blue-800">
            You're already subscribed to this creator!
          </p>
        </div>
      )}

      <div className="mb-8">
        <h1 className="text-3xl font-bold">{creator.displayName}</h1>
        <p className="text-gray-600">@{creator.username}</p>
        {creator.bio && (
          <p className="mt-4 text-gray-700">{creator.bio}</p>
        )}
        <p className="mt-2 text-sm text-gray-500">
          {creator._count.subscriptions} subscriber{creator._count.subscriptions !== 1 ? 's' : ''}
        </p>
        {isActiveSubscriber && userSubscription && (
          <p className="mt-2 text-sm text-green-500 font-medium">
            ✓ Subscribed to {userSubscription.tier.name}
          </p>
        )}
      </div>

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

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

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

              return (
                <div
                  key={tier.id}
                  className={`p-6 border rounded-lg flex flex-col justify-between ${
                    isCurrentTier ? 'border-green-500 bg-green-50' : ''
                  }`}
                >
                  <div>
                    <div className="flex justify-between items-start">
                      <h3 className="text-lg font-medium">{tier.name}</h3>
                      {isCurrentTier && (
                        <span className="text-xs bg-green-500 text-white px-2 py-1 rounded">
                          Current
                        </span>
                      )}
                    </div>
                    {tier.description && (
                      <p className="text-sm text-gray-600 mt-1">{tier.description}</p>
                    )}
                    <p className="text-2xl font-bold mt-4">
                      ${(tier.priceInCents / 100).toFixed(2)}
                      <span className="text-sm font-normal text-gray-500">/month</span>
                    </p>
                  </div>
                  {!isActiveSubscriber && (
                    <Link
                      href={`/subscribe/${creator.username}/${tier.id}`}
                      className="mt-4 block text-center px-4 py-2 bg-black text-white rounded hover:bg-gray-800 transition"
                    >
                      Subscribe
                    </Link>
                  )}
                </div>
              )
            })}
          </div>
        )}
      </div>
    </main>
  )
}

Test content gating

Let’s test if the content gating works:

  1. Create a post from the creator dashboard
  2. Sign in with a non-subscriber account and go to the creator page, you won’t see the posts
  3. Sign in with a subscriber account, you’ll see the posts of the creator above the tiers

Step 11: Creator payouts

When a customer pays for a subscription, the money they paid goes into the creator’s Whop balance, so, you need to create a page that lets your creators withdraw funds to their bank account. Whop provides two options:

  • Hosted payout portal - Redirects creators to a Whop-hosted page
  • Embedded payout components - Embed the payout interface directly to your app

In this step, we’re going to use the hosted payout portal since it’s simpler and doesn’t require you to install any extra dependencies. If you wish to learn more about the embedded payout components, check out the Embedded payout portal guide in our documentation.

Create the payout portal API route

You’re going to need an endpoint that generates a temporary link to the Whop payout portal. You can do this by going into the app/api/creator/payouts folder and create a file called route.ts with the content:

route.ts
import { NextResponse } from 'next/server'
import { requireCreator } from '@/lib/auth'
import { whop } from '@/lib/whop'

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

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

  const baseUrl = process.env.AUTH_URL || 'http://localhost:3000'

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

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

Create the payout page

Now, to create the actual page your creators go when they want payouts, go to the app/creator/payouts folder and create a file called page.tsx with the content:

page.tsx
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import PayoutButton from './PayoutButton'

interface PayoutsPageProps {
  searchParams: Promise<{ returned?: string }>
}

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

  if (!user) {
    redirect('/signin')
  }

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

  if (!creator) {
    redirect('/creator/register')
  }

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

      {returned === 'true' && (
        <div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
          <p className="text-green-800">
            Payout settings updated successfully.
          </p>
        </div>
      )}

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

          <div className="p-6 bg-gray-50 rounded-lg">
            <h3 className="font-medium mb-2">How payouts work</h3>
            <ul className="text-sm text-gray-600 space-y-2">
              <li>• Subscriber payments go to your Whop company balance</li>
              <li>• Complete identity verification (KYC) to enable withdrawals</li>
              <li>• Add a bank account or other payout method</li>
              <li>• Withdraw funds manually or set up automatic payouts</li>
            </ul>
          </div>
        </div>
      )}
    </main>
  )
}

Create the payout button

The payout links are temporary, and creators need a way to easily go to the payout page. To do this, let’s create a payout button. Go to the app/creator/payouts folder and create a file called PayoutButton.tsx with the content:

PayoutButton.tsx
'use client'

import { useState } from 'react'

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

  async function handleClick() {
    setLoading(true)

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

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

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

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className="px-4 py-2 bg-black text-white rounded hover:bg-gray-800 transition disabled:opacity-50"
    >
      {loading ? 'Opening...' : 'Open Payout Portal'}
    </button>
  )
}

Let’s update the creator dashboard (at app/creator/dashboard/page.tsx) by adding the code below to create a Payouts button. You can place it anywhere you like on the page:

page.tsx
<Link
  href="/creator/payouts"
  className="block p-4 border rounded-lg hover:bg-gray-50 transition"
>
  <h3 className="font-medium">Payouts</h3>
  <p className="text-sm text-gray-600">View balance and withdraw earnings</p>
</Link>

Testing the payouts

  1. Sign in as a creator (make sure you’ve completed your account setup and KYC)
  2. Go to your creator dashboard and click Payouts and Open payout portal
  3. You’ll be redirected to the Whop’s hosted payout portal where you can configure and receive payouts

Step 12: Creating the homepage, subscriptions, and creator discovery

Now, your app is almost complete, but there are a few missing pages. Like the homepage, or the subscription dashboard.

Let’s create those pages, but first, you should make a few changes in your database to make sure the cancelling process in the subscription dashboard works correctly.

Update the Prisma schema

There are two main ways you can let customers cancel their subscriptions: you can let them cancel it immediately, or cancel at the end of the subscription period (month). The second option is much more common, so let’s implement that.

First, let’s open the prisma folder and edit the schema.prisma file so that the SubscriptionStatus part has the contents:

schema.prisma
enum SubscriptionStatus {
  ACTIVE
  CANCELING
  CANCELED
  PAST_DUE
  EXPIRED
}

Then, run the migration command on your terminal:

typescript
npx prisma migrate dev --name add_canceling_status

Create the homepage

As a Patreon clone, your project needs a homepage. Next.js already provides one with the page.tsx file in the app folder. To customize it, let’s open the page.tsx file and replace its contents with:

page.tsx
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import Link from 'next/link'
import CreatorSearch from './CreatorSearch'
import FAQAccordion from './FAQAccordion'

const CREATORS_PER_PAGE = 12

interface HomePageProps {
  searchParams: Promise<{ page?: string }>
}

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

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

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

  const totalPages = Math.ceil(totalCount / CREATORS_PER_PAGE)

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

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

      <div className="py-16 px-8">
        <div className="max-w-4xl mx-auto">
          <h2 className="text-2xl font-bold text-center mb-8 text-gray-900">Find creators</h2>
          <CreatorSearch
            creators={creators}
            currentPage={currentPage}
            totalPages={totalPages}
          />
        </div>
      </div>

      <div className="py-16 px-8 bg-gray-50">
        <div className="max-w-3xl mx-auto">
          <h2 className="text-2xl font-bold text-center mb-12 text-gray-900">Frequently Asked Questions</h2>
          <FAQAccordion />
        </div>
      </div>
    </main>
  )
}
Make sure to upload the icons you want to use into your public folder.

In the homepage, letting customers (even if they’re not a member) search existing creator profiles is a good idea. To do that, let’s implement a search field by going into the app folder and creating a file called CreatorSearch.tsx with the content:

CreatorSearch.tsx
'use client'

import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'

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

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

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

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

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

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

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

          {totalPages > 1 && !search && (
            <div className="flex items-center justify-center gap-4 mt-8">
              <button
                onClick={() => goToPage(currentPage - 1)}
                disabled={currentPage <= 1}
                className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
              >
                Previous
              </button>
              <span className="text-sm text-gray-600">
                Page {currentPage} of {totalPages}
              </span>
              <button
                onClick={() => goToPage(currentPage + 1)}
                disabled={currentPage >= totalPages}
                className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
              >
                Next
              </button>
            </div>
          )}
        </>
      )}
    </div>
  )
}

Create the FAQ section

To make the homepage a bit more informative, let's add an FAQ section to the homepage with dropdowns. Go to the app folder and create a file called FAQAccordion.tsx with the content (and customize FAQs and answers):

FAQAccordion.tsx
'use client'

import { useState } from 'react'

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

export default function FAQAccordion() {
  const [openIndex, setOpenIndex] = useState<number | null>(null)

  const toggle = (index: number) => {
    setOpenIndex(openIndex === index ? null : index)
  }

  return (
    <div className="space-y-4">
      {faqs.map((faq, index) => (
        <div
          key={index}
          className="bg-white rounded-lg shadow-sm overflow-hidden transition-all duration-200 hover:shadow-md"
        >
          <button
            onClick={() => toggle(index)}
            className="w-full px-6 py-4 text-left flex justify-between items-center gap-4"
          >
            <h3 className="font-semibold text-gray-900">{faq.question}</h3>
            <svg
              className={`w-5 h-5 text-gray-500 transition-transform duration-200 flex-shrink-0 ${
                openIndex === index ? 'rotate-180' : ''
              }`}
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
            >
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
            </svg>
          </button>
          <div
            className={`overflow-hidden transition-all duration-200 ${
              openIndex === index ? 'max-h-40 pb-4' : 'max-h-0'
            }`}
          >
            <p className="px-6 text-gray-600 text-sm">{faq.answer}</p>
          </div>
        </div>
      ))}
    </div>
  )
}

Create the subscriptions dashboard

Now that you have a home page, let’s take a look at the subscriptions dashboard. It’s where your users can view and manage their subscriptions.

Go to app/subscriptions folder and create a file called page.tsx with the content:

page.tsx
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import CancelButton from './CancelButton'

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

  if (!user) {
    redirect('/signin')
  }

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

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

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

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

                <div className="flex items-center gap-3">
                  <Link
                    href={`/creator/${subscription.creator.username}`}
                    className="text-sm text-blue-600 hover:underline"
                  >
                    View profile
                  </Link>
                  {subscription.status === 'ACTIVE' && (
                    <CancelButton subscriptionId={subscription.id} />
                  )}
                </div>
              </div>
            </div>
          ))}
        </div>
      )}
    </main>
  )
}

Create the cancel button

Your subscription page needs a cancel button to let users easily cancel subscriptions. Let’s create the cancel button by going into the app/subscriptions folder and creating a file called CancelButton.tsx with the content:

CancelButton.tsx
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'

interface CancelButtonProps {
  subscriptionId: string
}

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

  async function handleCancel() {
    setLoading(true)

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

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

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

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

  return (
    <button
      onClick={() => setShowConfirm(true)}
      className="text-sm text-red-600 hover:underline"
    >
      Cancel
    </button>
  )
}

Create the cancel API route

You have the page, you have the buttons, now it’s time to create an API route to actually make the cancellation happen. Go to the app/api/subscriptions/[id]/cancel folder and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { whop } from '@/lib/whop'

interface RouteParams {
  params: Promise<{ id: string }>
}

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

  const { id } = await params

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

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

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

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

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

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

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

    return NextResponse.json({ success: true })
  } catch (error) {
    console.error('Cancel subscription error:', error)
    return NextResponse.json(
      { error: 'Failed to cancel subscription' },
      { status: 500 }
    )
  }
}

Update the webhook handler

Now let’s update our webhooks so that Whop and your project can communicate and you can keep your database up-to-date. To do that, you need a webhook handler to listen to the membership.cancel_at_period_end_changed event from whop.

Go to the app/api/webhooks/whop file and update the existing route.ts file with the content:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  const newStatus = data.cancel_at_period_end ? 'CANCELING' : 'ACTIVE'

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

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

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

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

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

Update webhook settings on Whop

Now, for your webhook handler to actually get the webhook messages, let’s go back to Whop and enable the membership_cancel_at_period_end_changed event:

  1. Go to your Whop dashboard and open the Developers page
  2. Click on the context menu button of the webhook you created and select Edit
  3. In the Events list, find the membership_cancel_at_period_end_changed option and enable it
  4. Click Save
0:00
/0:11

Update the middleware file

The /subscriptions page should be protected, so we should declare that in the middleware.ts file in your project root. Update the file content with:

middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getIronSession } from 'iron-session'
import { sessionOptions, SessionData } from '@/lib/session'

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

  const session = await getIronSession<SessionData>(
    request.cookies as any,
    sessionOptions
  )

  const isProtected =
    request.nextUrl.pathname.startsWith('/dashboard') ||
    request.nextUrl.pathname.startsWith('/creator') ||
    request.nextUrl.pathname.startsWith('/subscriptions')

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

  return response
}

export const config = {
  matcher: ['/dashboard/:path*', '/creator/:path*', '/subscriptions/:path*'],
}

Create a navigation header

Your users need an easy way to access the important pages in your project, and the easiest way to make one is creating a navigation header. Let’s go to the app folder and create a file called Header.tsx with the content:

Header.tsx
import Link from 'next/link'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

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

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

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

        <nav className="flex items-center gap-6">
          {user ? (
            <>
              <Link
                href="/subscriptions"
                className="text-sm text-gray-600 hover:text-green-500 transition"
              >
                Subscriptions
              </Link>
              {creator ? (
                <Link
                  href={`/creator/${creator.username}`}
                  className="text-sm text-gray-600 hover:text-green-500 transition"
                >
                  My Profile
                </Link>
              ) : (
                <Link
                  href="/dashboard"
                  className="text-sm text-gray-600 hover:text-green-500 transition"
                >
                  Dashboard
                </Link>
              )}
            </>
          ) : (
            <Link
              href="/signin"
              className="text-sm px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition"
            >
              Sign in
            </Link>
          )}
        </nav>
      </div>
    </header>
  )
}

And update the layout.tsx file to include your header on all pages:

Make sure to change the title and description strings in the metadata part to set the name and description of your project’s metadata.
layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Header from "./Header";

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

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

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

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <Header />
        {children}
      </body>
    </html>
  );
}

Edit the global CSS

Lastly, let's make your project look better. All pages in your project reference a CSS file called global.css that you can find in the app folder. Open the global.css file, and replace its contents with:

global.css
@import "tailwindcss";

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

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

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

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

.animate-gradient {
  animation: gradient 15s ease infinite;
}

Test the homepage and subscription dashboard

  1. Go to http://localhost:3000, you should see the new homepage
  2. Try searching for creators via the search field
  3. Use the Go to Dashboard button to go to your dashboard
  4. Test the header by going into different pages
  5. Log into an account that has a subscription and try to cancel it in the subscriptions dashboard

Step 13: Deploying the project

Your project is ready to launch - good job! In this step, you’ll update some of the keys you use, take your project out of the Whop sandbox, push it to GitHub, deploy it to Vercel (with a production database), and configure some settings.

Get your production Whop keys

Firstly, let’s update your Whop keys. In development, you used Sandbox.Whop.com, now, it’s time to use whop.com and its keys:

  • Go to the Whop dashboard of your company (create a new one if you don’t have any) and copy your company ID (starts with biz_) from the URL
  • Go to the Developer page and on the Company API keys section, click the Create button, give your API key a name, and select the permissions below, and Create the API key. Once created, copy it. You’ll use it later:

Permissions to select

  • Create child companies
  • Read business information
  • Create checkout configurations
  • Read checkout configurations
  • Create plans
  • Read plans
  • Update plans
  • Delete plans
  • Create products
  • Read products
  • Update products
  • Read payments
  • Read members
  • Read member emails
  • Read changes to payments
  • Read changes to memberships
  • Read payout destinations
  • Now use the Create app button in the Apps section, give it the permissions below (using the Permissions tab), and copy its app ID (from the app details tab)

Generate a new production session secret

Let’s open up a terminal and run the command below to get a new session secret. Don’t share it anywhere, and don’t lose it - you’ll use it soon:

bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Preparing for deployment

In production, we’re going to use Vercel Postgres, but you’re free to choose a different cloud database solution. Now, let’s make changes to support Vercel Postgres.

Update the Prisma scheme

Open the prisma.scheme file in the prisma folder and update the datasource part with:

prisma.scheme
datasource db {
  provider  = "postgresql"
  url       = env("POSTGRES_PRISMA_URL")
  directUrl = env("POSTGRES_URL_NON_POOLED")
}

Add the postinstall script

To let Vercel generate the Prisma client, you need to open the package.json file in your project root and add the line below to the scripts section:

prisma.scheme
"postinstall": "prisma generate"

Pushing your code to GitHub

Let’s initialize a Git repository on your project root by running the commands below in your terminal:

bash
git init
git add .
git commit -m "Initial commit"

Then, go to GitHub.com and create a new private repository - this way, your code will stay private and Vercel can still access it once you connect both accounts.

Once you’ve created the repository, push your code using:

bash
git remote add origin https://github.com/your-username/project-name.git
git branch -M main
git push -u origin main

Deploy to Vercel

Let’s deploy your project to Vercel now:

  1. Go to Vercel.com and sign in with your GitHub account
  2. Click Add new and select Project
  3. Click the Import button next to the GitHub repository you just created

This will display a popup with deployment settings. There, you should configure your environment variables:

Variable Value
SESSION_SECRET The secret you generated earlier
AUTH_URL Leave blank for now
WHOP_APP_ID Your production app ID (starts with app_)
WHOP_API_KEY Your production API key (starts with apik_)
WHOP_COMPANY_ID Your production company ID (starts with biz_)
WHOP_WEBHOOK_SECRET Leave blank for now

After adding the environment variables, you can deploy the project. It’s not going to start working right away, there are still things you need to do.

Create the production database

After deploying your project, let’s create the production database:

  1. Click on the Storage tab of your Vercel project
  2. Click Create Database and select Neon
  3. In the database creation page, select the region, plan, and database name
  4. Once the database is created, it should be connected to your project. If not, use the Connect Project button in the database page to manually connect the two

Run database migrations

One of the easiest ways to simply run the migrations from your computer to Vercel is adding the lines below to your .env file, and replacing their secrets with the ones you see on the details page of your database on Vercel:

.env
POSTGRES_PRISMA_URL="your-neon-connection-string"
POSTGRES_URL_NON_POOLED="your-neon-connection-string"

Once you add these lines to your .env file and replace the keys, run the command below in your terminal to complete the migration:

script.ts
npx prisma migrate deploy

Once you’re done with the migrations, let’s copy your project’s URL, you’ll use it later:

  1. Go to the Deployments tab of your project
  2. Click on the context menu button of the latest deployment
  3. Select Copy URL

Update the AUTH_URL secret on Vercel

Now, let’s add the AUTH_URL secret to your project:

  1. Go to the Settings tab of your project on Vercel
  2. Open the Environment Variables page and click Add Environment Variable
  3. Enter AUTH_URL as the Key and the the URL you copied earlier as the Value
  4. Click Save

Configure Whop OAuth redirect on Whop

Now, let’s go to Whop and update your app’s OAuth redirect so that it doesn’t try to redirect users who sign in to your localhost address:

  1. Go to your Whop dashboard and open the Developers page
  2. Select your app in the App section and go to its OAuth tab
  3. If you have an entry under Redirect URL table (localhost), delete it
  4. Create a new one using the Create redirect URL button
  5. Enter https://your-project-url.vercel.app/api/auth/callback
  6. Click Create

Get a production webhook secret

While you’re on Whop, let’s get the webhook secret that you’ll use in production:

  1. Go back to the Developer page of your dashboard and click Create webhook
  2. There, enter https://your-project-url.vercel.app/api/webhooks/whop as the Endpoint URL and make sure Connected account events is enabled
  3. In the permissions, select payment_succeeded, membership_cancel_at_period_end_changed, and membership_deactivated and click Save
  4. Copy the webhook secret (starts with ws_)

Now, let’s open Vercel and update your environment variable:

  1. Go to the project settings on Vercel and open Environment Variables
  2. Click Add Environment Variable, enter WHOP_WEBHOOK_SECRET as the Key, and your webhook secret as the Value
  3. Click Save

Redeploy after updating environment variables

Now that you’ve added new environment variables to your project, let’s redeploy it:

  1. Go to the Deployments tab of your project on Vercel
  2. Click on the context menu of your latest deployment
  3. Select Redeploy and confirm on the popup

Test production deployment

Now that your final product is deployed on Vercel, let’s take make some final tests - see if you can do these without any errors:

  1. Sign in with Whop OAuth
  2. Register as a creator
  3. Create tiers and content
  4. Find the creator profile in home page
  5. Content gating
  6. Subscription cancelling

Step 14: What’s next?

Now that you have a functional Patreon clone with authentication, subscription tiers, gated content, payments infrastructure, and payouts, let’s take a look at some features you can implement to your project to take it to the next step:

Payment and subscription features

  • Promo codes - Use Whop API to offer percentage or fixed-amount discounts
  • Free trials - Allow creators to offer trial period before charging with the Whop API
  • Annual memberships - Let creators create annual subscription options
  • Subscription upgrade/downgrades - Let subscribers switch between subscription tiers
  • Embedded checkouts - Embed Whop checkouts using the Whop API instead of redirecting users to the hosted checkout page
  • Failed payment handling - Listen for failed payments webhooks from Whop and notify subscribers to update payment methods
  • Refund system - Use Whop API to add a refund flow

Creator tools

  • Advanced analytics dashboard - Show creators their subscriber data, revenue trends, and other analytics
  • File attachments - Create a file storage system and let creators attach files to their posts
  • Scheduled posts - Add a scheduling feature so that creators can make future posts
  • Subscriber management - Let creators view their subscriber list and manage them
  • Custom creator profiles - Let creators customize their profile colors, banners, and profile pictures

Subscriber experience

  • Comments and likes on posts - Add engagement features like comments and likes to increase engagement
  • Content search - Add search functionality to creator profiles to subscribers can search for content

Growth

  • Creator categories and tags - Add categories and tags to creator profiles so customers can easily find relevant creators
  • Featured creators - Highlight your top creators on your home page
  • SEO optimization - Add proper meta tags, Open Graph images, and structured data for creator profiles

Technical improvements

  • Rate limiting - Add rate limiting to your API route to prevent abuse
  • Error monitoring - Implement Sentry or a similar feature to catch and track production errors
  • Caching - Add caching for frequently accessed pages for faster loading times
  • Mobile app - Build a native iOS app using Whop’s iOS SDK

Ready to build your own Patreon clone?

You have everything you need to build a Patreon clone. If you haven’t already, go to Whop.com, create an account, and build a business.

If you want to learn more about the capabilities of the Whop API and payment rails, check out our documentation.