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.
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 gitbrew services start postgresql@16
Linux (Ubuntu/Debian):
sudo apt update && sudo apt install nodejs npm postgresql gitsudo 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:
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:
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;"
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:
npx create-next-app@latest patreon-clone
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:
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:
npm install @whop/sdk iron-session @prisma/client@5 zod
You’re also going to need to install some development tools:
npm install -D prisma@5
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:
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):
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:
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.
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:
npm run dev
And you should see a result like:
> 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:
- You’re not seeing any errors in the terminal
- The http://localhost:3000 page loads without any crashes and displays the Next.js welcome page
- 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:
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:
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:
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:
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.
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.
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:
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:
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:
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:
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.
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:
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.
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:
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:
'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:
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:
- Go to Sandbox.Whop.com and create an account
- Create a new business using the New business button (+ icon) on the left sidebar
- Once you're in the business dashboard, copy your company ID (starting with
biz_) in your URL

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.

Now, to configure the OAuth to redirect your users back to your app once they sign in with Whop, you should:
- Click on the app you just created
- Go to its OAuth tab and click the Create redirect URL button
- Enter
http://localhost:3000/api/auth/callbackand click Create
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:
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:
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:
- Go to
http://localhost:3000/signin - Click Sign in with Whop
- You'll be redirected to Whop's sandbox login screen, use your sandbox account to log in
- After authorizing, you'll be redirected to
/dashboard, which will show a 404 (expected) - 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:
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:
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 }
)
}
}
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:
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:
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:
'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:
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:
'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:
import { Suspense } from 'react'
import OnboardingComplete from './OnboardingComplete'
Then add the component inside <main>, right after the opening tag and before the flex container:
<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:
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:
- Sign in at
http://localhost:3000/signin - Go to
http://localhost:3000/creator/register - Fill out the form and submit it
- You’ll be redirected to the creator dashboard (404 expected)
- Open Prisma Studio and look for the creator table and the
whopCompanyIdwith a value starting withbiz_.
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):
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:
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:
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:
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:
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:
'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:
'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:
- Sign in as a creator and go to
http://localhost:3000/creator/dashboard - Click the Manage tiers button
- Create a tier (make sure it’s minimum $1)
- Open the Prisma Studio (with
npx prisma studio) and check the Tier table. You should see the tier you just created - 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:
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:
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:
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:
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:
'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:
'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
- Sign in as a creator and go to
http://localhost:3000/creator/dashboard - Click the View public profile button in the creator dashboard
- Go back to your dashboard and click Create content
- Fill out the content form and publish it
- 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:
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:
'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:
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:
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:
- Sign in with a different Whop account to your project
- Go to the creator profile (
http://localhost:3000/creator/[username]), select a tier, and click Subscribe - In the tier details page, click Continue to checkout and use the test cards of Whop to complete the checkout
- After a successful payment, you’ll stay on the Whop page
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:
npm install \-g ngrok
Then, in a new terminal (you already have one running the npm run dev), start ngrok with the command below:
ngrok http 3000
When you start ngrok, you’ll see an output containing the forwarding URL - it looks like:
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:
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:
- Go to the Whop sandbox dashboard
- Open the Developer page
- In the Webhooks section, click Create webhook
- Enter your ngrok URL followed by
/api/webhooks/whop(likehttps://abc123.ngrok-free.app/api/webhooks/whop) - Enable the
payment_succeededevent - 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.
Update your environment file
By now, your .env file should look like:
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:
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:
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:
- Sign into your app with a different Whop account
- Go to the creator’s profile and subscribe to a user
- Complete the checkout using a test card
- Card number:
4242 4242 4242 4242 - Expiry: Any future date (e.g.,
12/34) - CVC: Any 3 digits (e.g.,
123)
- Card number:
- After a successful payment, check your terminal, there should be a webhook log
- 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:
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:
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:
- Create a post from the creator dashboard
- Sign in with a non-subscriber account and go to the creator page, you won’t see the posts
- 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:
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:
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:
'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>
)
}
Add a link to the payout page to dashboard
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:
<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
- Sign in as a creator (make sure you’ve completed your account setup and KYC)
- Go to your creator dashboard and click Payouts and Open payout portal
- 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:
enum SubscriptionStatus {
ACTIVE
CANCELING
CANCELED
PAST_DUE
EXPIRED
}
Then, run the migration command on your terminal:
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:
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>
)
}
public folder.Create the creator search
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:
'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):
'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:
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:
'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:
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:
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:
- Go to your Whop dashboard and open the Developers page
- Click on the context menu button of the webhook you created and select Edit
- In the Events list, find the
membership_cancel_at_period_end_changedoption and enable it - Click Save
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:
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:
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:
title and description strings in the metadata part to set the name and description of your project’s metadata.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:
@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
- Go to
http://localhost:3000, you should see the new homepage - Try searching for creators via the search field
- Use the Go to Dashboard button to go to your dashboard
- Test the header by going into different pages
- 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:
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:
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:
"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:
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:
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:
- Go to Vercel.com and sign in with your GitHub account
- Click Add new and select Project
- 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:
- Click on the Storage tab of your Vercel project
- Click Create Database and select Neon
- In the database creation page, select the region, plan, and database name
- 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:
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:
npx prisma migrate deploy
Once you’re done with the migrations, let’s copy your project’s URL, you’ll use it later:
- Go to the Deployments tab of your project
- Click on the context menu button of the latest deployment
- Select Copy URL
Update the AUTH_URL secret on Vercel
Now, let’s add the AUTH_URL secret to your project:
- Go to the Settings tab of your project on Vercel
- Open the Environment Variables page and click Add Environment Variable
- Enter
AUTH_URLas the Key and the the URL you copied earlier as the Value - 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:
- Go to your Whop dashboard and open the Developers page
- Select your app in the App section and go to its OAuth tab
- If you have an entry under Redirect URL table (localhost), delete it
- Create a new one using the Create redirect URL button
- Enter
https://your-project-url.vercel.app/api/auth/callback - Click Create
Get a production webhook secret
While you’re on Whop, let’s get the webhook secret that you’ll use in production:
- Go back to the Developer page of your dashboard and click Create webhook
- There, enter
https://your-project-url.vercel.app/api/webhooks/whopas the Endpoint URL and make sure Connected account events is enabled - In the permissions, select
payment_succeeded,membership_cancel_at_period_end_changed, andmembership_deactivatedand click Save - Copy the webhook secret (starts with
ws_)
Now, let’s open Vercel and update your environment variable:
- Go to the project settings on Vercel and open Environment Variables
- Click Add Environment Variable, enter
WHOP_WEBHOOK_SECRETas the Key, and your webhook secret as the Value - Click Save
Redeploy after updating environment variables
Now that you’ve added new environment variables to your project, let’s redeploy it:
- Go to the Deployments tab of your project on Vercel
- Click on the context menu of your latest deployment
- 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:
- Sign in with Whop OAuth
- Register as a creator
- Create tiers and content
- Find the creator profile in home page
- Content gating
- 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.