You can easily build a Substack clone by using the Whop Payments Network, its infrastructure, and other services like Supabase and Vercel. In this guide, we will walk you through each step.
Building a platform like Substack is easier than you think thanks to the Whop Payments Network, its other infrastructure solutions (like user authentication and embedded chats), Supabase, and Vercel - which are the services we're going to use in this tutorial.
In the steps below, you'll build Penstack. A full publishing platform where writers create publications, write articles with a rich text editor (complete with a paywall break), monetize through paid subscriptions, and engage their readers through embedded chat.
You can preview the finished product demo here and find the full codebase in this GitHub repository.
Project overview
Before we dive deep into the code of the project, let's take a general look at what we're going to build. The project will have:
- A rich text editor with a custom paywall break node. Writers will be able to place the break wherever they want, and the server slices content at that point for non-subscribers
- Writer onboarding where authenticated users can become a writer, set a publication name, handle, bio, and category (from a list)
- KYC and payment setup via the Whop Payments Network. Writer will complete their identity verification and connect their account to receive payouts
- Paid subscriptions with Direct Charges where subscribers pay the writer directly. 90% goes to the writer's connected account, 10% is retained as a platform fee
- Explore page with a trending algorithm that surfaces popular publications and recent posts
- An embedded Whop chat for publication profiles where readers can chat with each other
- Notification system for new posts, subscribers, followers, and payment events
- Analytics dashboard for writers where they can see subscriber counts, post performance, and revenue
Tech stack
- Next.js (App Router) - Server Components + API routes + Vercel deploy in one
- Whop OAuth 2.1 + PKCE - sign-in, tokens, identity
- Whop Payments Network (Direct Charges) - connected accounts, recurring billing, KYC built-in
- Supabase (PostgreSQL via Vercel) - cloud-only, Vercel auto-populates connection strings
- Prisma - type-safe queries, declarative schema, migrations
- Zod - runtime validation at system boundaries
- Tiptap - extensible ProseMirror wrapper with custom paywall break node
- Uploadthing - type-safe uploads
- iron-session - encrypted cookies, no session store, no Redis, no JWTs
- Whop Embedded Components - pre-built chat UI
- Vercel -
vercel.tsfor type-safe config
Pages
src/app/- pages and API routessrc/components/- editor, chat, dashboard, explore, post, settings, writer, and shared UI componentssrc/constants/- app config and categoriessrc/hooks/- custom React hookssrc/lib/- auth, session, env validation, Prisma, rate limiting, Whop SDK, uploads, utilitiessrc/services/- business logic (explore, notifications, posts, subscriptions, writers)src/types/- TypeScript type declarationssrc/middleware.ts- route protection
The payment flow
- Subscribers click the "Subscribe" button
- Our project creates a checkout session via the Whop API
- Subscriber completes the payment with a Whop-hosted checkout
- Whop Payments Network charges (90% to writer's connected Whop account and 10% to our platform)
- Whop fires webhooks
- Our project creates the subscription record and sends a notification
Important note: Writers must complete KYC before they can set a subscription price and start receiving payments. Until then, they can publish free content only.
Why Whop
On a publishing platform like this, we will encounter three infrastructure problems: payment system, user authentication, and community engagement. We will solve these with the following services:
- Whop Payments Network will solve all payment systems for us with simple integrations and collect payments from subscribers with Direct Charge
- Whop OAuth's simple "Sign in with Whop" button will allow users to easily join the project, saving us the trouble of storing passwords
- Whop embedded chats will be available on author profiles to enable interaction between authors and readers and keep reader communities active
Prerequisites
Before starting, you should have:
- Working familiarity with Next.js and React (we use the App Router and Server Components)
- A Whop developer account (free to create at whop.com)
- A Vercel account (free tier)
- A Supabase account (free tier)
Part 1: Scaffold, deploy, and authenticate
In this tutorial, we will start by laying the foundations in Vercel and then begin development, rather than transferring to Vercel after classic local development.
This way, we will have the OAuth redirect URL early on and can catch any issues immediately (because Vercel will not be able to build).
Create the project
Let's use the command below to scaffold a new Next.js app. We'll call our project "Penstack":
npx create-next-app@latest penstack --typescript --tailwind --eslint --app --src-dir --use-npm
cd penstack
Then, install the dependencies we'll use throughout the project:
npm install @whop/sdk @whop/embedded-components-react-js @whop/embedded-components-vanilla-js iron-session zod prisma @prisma/client @prisma/adapter-pg uploadthing @uploadthing/react @tiptap/core @tiptap/react @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-placeholder @tiptap/extension-underline @tiptap/extension-link @tiptap/pm lucide-react
Deploy before building
New Next.js projects will build without requiring any configuration, so you should transfer your project to a GitHub repository (use a private repo if you don't want your project to be open source) and connect it to Vercel.
Then add the project's URL as an environment variable in Vercel:
NEXT_PUBLIC_APP_URL=https://your-app.vercel.app
Supabase through the Vercel integration
Now, you should add Supabase through the Vercel Integrations marketplace instead of creating a project directly in the Supabase dashboard. Vercel automatically populates DATABASE_URL and DIRECT_URL as environment variables with connection pooling pre-configured through Supavisor.
Then, pull the variables to your local development using the command below:
vercel env pull .env.local
This is the pattern for every environment variable in this tutorial: add to Vercel first, then vercel env pull to sync locally.
Validate environment variables
Incomplete or incorrect environment variables should be presented as simple error messages, and we will use a Zod schema for this purpose. Go to src/lib and create a file called env.ts with the content:
import { z } from "zod";
const envSchema = z.object({
WHOP_APP_ID: z.string().startsWith("app_"),
WHOP_API_KEY: z.string().startsWith("apik_"),
WHOP_COMPANY_ID: z.string().startsWith("biz_"),
WHOP_WEBHOOK_SECRET: z.string().min(1),
WHOP_CLIENT_ID: z.string().min(1),
WHOP_CLIENT_SECRET: z.string().min(1),
DATABASE_URL: z.string().url(),
DIRECT_URL: z.string().url(),
UPLOADTHING_TOKEN: z.string().min(1),
SESSION_SECRET: z.string().min(32),
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_DEMO_MODE: z.string().optional(),
WHOP_SANDBOX: z.string().optional(),
});
let _env: z.infer<typeof envSchema> | null = null;
export function getEnv() {
if (!_env) {
_env = envSchema.parse(process.env);
}
return _env;
}
export const env = new Proxy({} as z.infer<typeof envSchema>, {
get(_target, prop: string) {
return getEnv()[prop as keyof z.infer<typeof envSchema>];
},
});
export function isDemoMode(): boolean {
return process.env.NEXT_PUBLIC_DEMO_MODE === "true";
}
Prisma setup
Initialize Prisma and install its config dependency:
npx prisma init
npm install -D dotenv
Prisma 7 creates prisma/schema.prisma and prisma.config.ts. Replace the schema with a minimal User model, just enough to store authenticated users. We'll expand it significantly in Part 2.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
whopUserId String @unique
email String?
username String?
displayName String?
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Now, let's update the prisma.config.ts file in your project root with the content below:
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DIRECT_URL"],
},
});
The url here is what the Prisma CLI uses for prisma db push and migrations. It must point at Supabase's session mode pooler (port 5432). The transaction mode pooler (port 6543) strips session-level state that schema operations depend on.
For your .env.local, you need two Supabase connection strings:
DATABASE_URL- the transaction mode pooler (port 6543), used by your app for queriesDIRECT_URL- the session mode pooler (port 5432), used by the Prisma CLI for schema operations
Both come from Supabase's connection pooler. Do not use the direct database connection (db.xxx.supabase.co) for DIRECT_URL.
Now, let's push the schema using the command:
npx prisma db push
Then create the Prisma client singleton at src/lib with a file called prisma.ts with the content below. Without the singleton pattern, Next.js hot-reloads would create a new database connection on each reload and exhaust the connection pool.
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter,
log: process.env.NODE_ENV === "development" ? ["query"] : [],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Set up your Whop app
During development, we'll use the sandbox environment of Whop, which allows us to simulate payments without moving real money. Follow the steps below to create a Whop app:
- Go to sandbox.whop.com, create a whop, and go to its dashboard
- In the dashboard, open the Developer page and find the Apps section
- Click the Create your first app button in the Apps section, give it a name, and click Create
- Get the App ID, API Key, Company ID, Client ID, and Client Secret of the app
- Go to the OAuth tab of the app and set the redirect URL to http://localhost:3000/api/auth/callback
When you're moving from development to production, you're going to have to repeat these steps outside the sandbox, in whop.com. We'll touch on this later at Part 7.
Next, add the line below to your .env.local file so that the OAuth and API calls are routed to the sandbox instead of the real environment:
WHOP_SANDBOX=true
Whop OAuth with PKCE
In this section, we're going to take a look at authenticating users through Whop's OAuth flow with PKCE and store sessions in cookies using iron-session.
Session configuration
Go to src/lib and create a file called session.ts with the content:
import { getIronSession, SessionOptions } from "iron-session";
import { cookies } from "next/headers";
export interface SessionData {
userId?: string;
whopUserId?: string;
accessToken?: string;
codeVerifier?: string;
}
const sessionOptions: SessionOptions = {
password: process.env.SESSION_SECRET!,
cookieName: "penstack_session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 days
},
};
export async function getSession() {
const cookieStore = await cookies();
return getIronSession<SessionData>(cookieStore, sessionOptions);
}
Whop SDK and PKCE generation
Go to src/lib and create a file called whop.ts with the content:
import Whop from "@whop/sdk";
let _whop: Whop | null = null;
export function getWhop(): Whop {
if (!_whop) {
_whop = new Whop({
appID: process.env.WHOP_APP_ID!,
apiKey: process.env.WHOP_API_KEY!,
...(process.env.WHOP_SANDBOX === "true" && {
baseURL: "https://sandbox-api.whop.com/api/v1",
}),
});
}
return _whop;
}
export const whop = new Proxy({} as Whop, {
get(_target, prop, receiver) {
return Reflect.get(getWhop(), prop, receiver);
},
});
const isSandbox = process.env.WHOP_SANDBOX === "true";
const whopDomain = isSandbox ? "sandbox.whop.com" : "whop.com";
const whopApiDomain = isSandbox ? "sandbox-api.whop.com" : "api.whop.com";
export const WHOP_OAUTH = {
authorizationUrl: `https://${whopApiDomain}/oauth/authorize`,
tokenUrl: `https://${whopApiDomain}/oauth/token`,
userInfoUrl: `https://${whopApiDomain}/oauth/userinfo`,
clientId: process.env.WHOP_CLIENT_ID!,
clientSecret: process.env.WHOP_CLIENT_SECRET!,
scopes: [
"openid",
"profile",
"email",
"chat:message:create",
"chat:read",
"dms:read",
"dms:message:manage",
"dms:channel:manage",
],
redirectUri: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback`,
};
export async function generatePKCE() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = base64UrlEncode(array);
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest("SHA-256", data);
const challenge = base64UrlEncode(new Uint8Array(digest));
return { verifier, challenge };
}
function base64UrlEncode(buffer: Uint8Array): string {
let binary = "";
for (const byte of buffer) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
Rate limiting
We want each route to pass a unique key (like auth:login or writers:create:{userID}) and a limit. If the caller is under the limit, it must return null and the route should continue. If over, it should return a 429 response that route sends back instantly.
Let's go to src/lib and create a file called rate-limit.ts with the content:
import { NextResponse } from "next/server";
interface RateLimitConfig {
interval: number; // ms
maxRequests: number;
}
const rateLimitMap = new Map<string, { count: number; lastReset: number }>();
export function rateLimit(
key: string,
config: RateLimitConfig = { interval: 60_000, maxRequests: 30 }
): NextResponse | null {
const now = Date.now();
const entry = rateLimitMap.get(key);
if (!entry || now - entry.lastReset > config.interval) {
rateLimitMap.set(key, { count: 1, lastReset: now });
return null;
}
if (entry.count >= config.maxRequests) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{
status: 429,
headers: {
"Retry-After": String(
Math.ceil((config.interval - (now - entry.lastReset)) / 1000)
),
},
}
);
}
entry.count++;
return null;
}
if (typeof globalThis !== "undefined") {
const CLEANUP_INTERVAL = 5 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [key, entry] of rateLimitMap.entries()) {
if (now - entry.lastReset > 10 * 60 * 1000) {
rateLimitMap.delete(key);
}
}
}, CLEANUP_INTERVAL).unref?.();
}
The login route
Now, go to src/app/api/auth/login and create a file called route.ts with the content below. This will generate a PKCE challenge, store the verifier in a cookie, and redirect to Whop's user authorization page.
It also accepts an optional ?returnTo= parameter so users who click Follow or Like while logged out land back on the same page after signing in:
import { NextRequest, NextResponse } from "next/server";
import { WHOP_OAUTH, generatePKCE } from "@/lib/whop";
function randomHex(bytes: number): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return Array.from(buf)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function GET(request: NextRequest) {
const returnTo = request.nextUrl.searchParams.get("returnTo");
const safeReturnTo =
returnTo && returnTo.startsWith("/") && !returnTo.startsWith("//")
? returnTo
: null;
const { verifier, challenge } = await generatePKCE();
const state = randomHex(16);
const nonce = randomHex(16);
const authUrl = new URL(WHOP_OAUTH.authorizationUrl);
authUrl.searchParams.set("client_id", WHOP_OAUTH.clientId);
authUrl.searchParams.set("redirect_uri", WHOP_OAUTH.redirectUri);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", WHOP_OAUTH.scopes.join(" "));
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("nonce", nonce);
const cookieValue = JSON.stringify({
codeVerifier: verifier,
state,
...(safeReturnTo ? { returnTo: safeReturnTo } : {}),
});
const response = NextResponse.redirect(authUrl.toString());
response.cookies.set("oauth_pkce", cookieValue, {
httpOnly: true,
secure: WHOP_OAUTH.redirectUri.startsWith("https"),
sameSite: "lax",
path: "/",
maxAge: 600, // 10 minutes
});
return response;
}
Callback configuration
When users log or sign up with Whop, it redirects them back with an authorization code, and we need a route that exchanges it for an access token using the PKCE verifier, fetches the user's profile, upserts them into the database, and establishes the session.
If the login was triggered with a returnTo URL (stored in the PKCE cookie), the user is redirected back to that page instead of the home page. To create this, go to src/app/api/auth/callback and create a file called route.ts with the content:
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { WHOP_OAUTH } from "@/lib/whop";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code");
const state = request.nextUrl.searchParams.get("state");
if (!code || !state) {
return NextResponse.json(
{ error: "Missing authorization code or state" },
{ status: 400 }
);
}
const pkceCookie = request.cookies.get("oauth_pkce");
if (!pkceCookie?.value) {
return NextResponse.json(
{ error: "Missing PKCE cookie. Please try logging in again." },
{ status: 400 }
);
}
let storedState: string;
let codeVerifier: string;
let returnTo: string | undefined;
try {
const parsed = JSON.parse(pkceCookie.value);
storedState = parsed.state;
codeVerifier = parsed.codeVerifier;
returnTo = parsed.returnTo;
} catch {
return NextResponse.json(
{ error: "Invalid PKCE cookie." },
{ status: 400 }
);
}
if (state !== storedState) {
return NextResponse.json(
{ error: "State mismatch — possible CSRF." },
{ status: 400 }
);
}
const tokenResponse = await fetch(WHOP_OAUTH.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: WHOP_OAUTH.redirectUri,
client_id: WHOP_OAUTH.clientId,
client_secret: WHOP_OAUTH.clientSecret,
code_verifier: codeVerifier,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
console.error("Token exchange failed:", error);
return NextResponse.json(
{
error: "Failed to exchange authorization code",
detail: error,
tokenUrl: WHOP_OAUTH.tokenUrl,
redirectUri: WHOP_OAUTH.redirectUri,
},
{ status: 502 }
);
}
const tokenData = await tokenResponse.json();
const accessToken: string = tokenData.access_token;
const userInfoResponse = await fetch(WHOP_OAUTH.userInfoUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!userInfoResponse.ok) {
console.error("User info fetch failed:", await userInfoResponse.text());
return NextResponse.json(
{ error: "Failed to fetch user info" },
{ status: 502 }
);
}
const userInfo = await userInfoResponse.json();
const user = await prisma.user.upsert({
where: { whopUserId: userInfo.sub },
update: {
email: userInfo.email ?? null,
username: userInfo.preferred_username ?? null,
displayName: userInfo.name ?? null,
avatarUrl: userInfo.picture ?? null,
},
create: {
whopUserId: userInfo.sub,
email: userInfo.email ?? null,
username: userInfo.preferred_username ?? null,
displayName: userInfo.name ?? null,
avatarUrl: userInfo.picture ?? null,
},
});
const session = await getSession();
session.userId = user.id;
session.whopUserId = user.whopUserId;
session.accessToken = accessToken;
await session.save();
const redirectPath =
returnTo && returnTo.startsWith("/") && !returnTo.startsWith("//")
? returnTo
: "/";
const response = NextResponse.redirect(new URL(redirectPath, request.url));
response.cookies.delete("oauth_pkce");
return response;
}
Logout route
Finally, we need a way for users to sign out. Go to src/app/api/auth/logout and create a file called route.ts with the content:
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
export async function GET() {
const session = await getSession();
session.destroy();
return NextResponse.redirect(new URL("/", process.env.NEXT_PUBLIC_APP_URL!));
}
This destroys the iron-session cookie and redirects the user back to the home page.
Using a single authentication function
In our project, all server components and API routes need to know who the users interacting with them are. Instead of performing session and database checks everywhere, let's use a single requireAuth function.
Go to src/lib and create a file called auth.ts with the content:
import { redirect } from "next/navigation";
import { getSession } from "./session";
import { prisma } from "./prisma";
export async function requireAuth(
options?: { redirect?: boolean }
): Promise<{
id: string;
whopUserId: string;
email: string | null;
username: string | null;
displayName: string | null;
avatarUrl: string | null;
} | null> {
const session = await getSession();
if (!session.userId) {
if (options?.redirect === false) return null;
redirect("/api/auth/login");
}
const user = await prisma.user.findUnique({
where: { id: session.userId },
});
if (!user) {
session.destroy();
if (options?.redirect === false) return null;
redirect("/api/auth/login");
}
return user;
}
export async function getWriterProfile(userId: string) {
return prisma.writer.findUnique({ where: { userId } });
}
export async function isAuthenticated(): Promise<boolean> {
const session = await getSession();
return !!session.userId;
}
Vercel configuration
Create vercel.ts at the project root. The key line is buildCommand. It runs prisma generate before next build so the Prisma client exists when Vercel builds your app.
const config = {
framework: "nextjs" as const,
buildCommand: "prisma generate && next build",
regions: ["iad1"],
headers: [
{
source: "/(.*)",
headers: [
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
],
},
],
};
export default config;
Check what we've done so far
Now that we've built the scaffolding and authentication, let's complete our first checkpoint. Deploy your changes to Vercel (push to your GitHub and Vercel should auto-build), visit your production URL and navigate to /api/auth/login.
You should be redirected to Whop's authorization page. After granting access, you land back on the home page.
With authentication working in production, you have a solid foundation. In Part 2, we'll build out the complete data model and the writer onboarding flow.
Part 2: Data models and writer onboarding
Now that our user verification system works, we must determine the data structures necessary for our project to function as a publishing platform.
In this section, we will look at the complete schema, file uploading, and the onboarding flow for regular users to become authors.
The complete data model
We will use a total of eight models in our project. Some details to note:
- Writers are separate from Users, not every user is a writer.
- Post.content is
Json, Tiptap (the editor we use) outputs JSON and storing it as such lets the server slice the node array atpaywallIndexfor preview posts so paid content can't be seen by unauthorized users. - Single paid tier per writer, all writers have a single plan and price.
- WebhookEvent stores processed event IDs for idempotency since webhooks can fire more than once.
Now, go to prisma and update the schema.prisma file content with:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// Enums
enum PublicationCategory {
TECHNOLOGY
BUSINESS
CULTURE
POLITICS
SCIENCE
HEALTH
FINANCE
SPORTS
FOOD
TRAVEL
MUSIC
ART
EDUCATION
OTHER
}
enum PostVisibility {
FREE
PAID
PREVIEW
}
enum SubscriptionStatus {
ACTIVE
PAST_DUE
CANCELLED
PAUSED
TRIALING
}
enum NotificationType {
NEW_POST
NEW_SUBSCRIBER
NEW_FOLLOWER
PAYMENT_RECEIVED
PAYMENT_FAILED
}
// Models
model User {
id String @id @default(cuid())
whopUserId String @unique
email String?
username String?
displayName String?
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
writer Writer?
subscriptions Subscription[]
follows Follow[]
likes Like[]
notifications Notification[]
}
model Writer {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
handle String
name String
bio String?
avatarUrl String?
bannerUrl String?
category PublicationCategory @default(OTHER)
whopCompanyId String?
whopProductId String?
whopPlanId String?
whopChatChannelId String?
kycCompleted Boolean @default(false)
monthlyPriceInCents Int?
chatPublic Boolean @default(true)
posts Post[]
subscriptions Subscription[]
followers Follow[]
@@index([handle])
@@index([category])
}
model Post {
id String @id @default(cuid())
writerId String
writer Writer @relation(fields: [writerId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slug String
title String
subtitle String?
coverImageUrl String?
content Json
visibility PostVisibility @default(FREE)
paywallIndex Int?
published Boolean @default(false)
publishedAt DateTime?
viewCount Int @default(0)
likes Like[]
@@unique([writerId, slug])
@@index([writerId, published, publishedAt])
@@index([published, publishedAt])
@@index([visibility])
}
model Subscription {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
writerId String
writer Writer @relation(fields: [writerId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status SubscriptionStatus @default(ACTIVE)
whopMembershipId String? @unique
currentPeriodEnd DateTime?
cancelledAt DateTime?
lastWebhookEventId String?
@@unique([userId, writerId])
@@index([writerId, status])
}
model Follow {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
writerId String
writer Writer @relation(fields: [writerId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, writerId])
@@index([writerId])
}
model Like {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
postId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, postId])
@@index([postId])
}
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type NotificationType
title String
message String
postId String?
writerId String?
read Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId, read, createdAt])
}
model WebhookEvent {
id String @id
eventType String
processedAt DateTime @default(now())
}
Then push the updated schema using the command:
npx prisma db push
Uploadthing for file uploads
We're going to use Uploadthing for avatars, banners, and cover images instead of Supabase Storage. Uploadthing lets us use type-safe file routes with built-in React upload components, which means less custom code.
Go to src/lib and create a file called uploadthing.ts with the content:
import { createUploadthing, type FileRouter } from "uploadthing/next";
import { getSession } from "./session";
const f = createUploadthing();
export const uploadRouter = {
avatarUploader: f({
image: { maxFileSize: "2MB", maxFileCount: 1 },
})
.middleware(async () => {
const session = await getSession();
if (!session.userId) throw new Error("Unauthorized");
return { userId: session.userId };
})
.onUploadComplete(async ({ metadata, file }) => {
return { url: file.ufsUrl, userId: metadata.userId };
}),
bannerUploader: f({
image: { maxFileSize: "4MB", maxFileCount: 1 },
})
.middleware(async () => {
const session = await getSession();
if (!session.userId) throw new Error("Unauthorized");
return { userId: session.userId };
})
.onUploadComplete(async ({ metadata, file }) => {
return { url: file.ufsUrl, userId: metadata.userId };
}),
coverImageUploader: f({
image: { maxFileSize: "4MB", maxFileCount: 1 },
})
.middleware(async () => {
const session = await getSession();
if (!session.userId) throw new Error("Unauthorized");
return { userId: session.userId };
})
.onUploadComplete(async ({ metadata, file }) => {
return { url: file.ufsUrl, userId: metadata.userId };
}),
editorImageUploader: f({
image: { maxFileSize: "4MB", maxFileCount: 1 },
})
.middleware(async () => {
const session = await getSession();
if (!session.userId) throw new Error("Unauthorized");
return { userId: session.userId };
})
.onUploadComplete(async ({ file }) => {
return { url: file.ufsUrl };
}),
} satisfies FileRouter;
export type UploadRouter = typeof uploadRouter;
To set up Uploadthing, create a project at uploadthing.com, copy your UPLOADTHING_TOKEN key and add it to your Vercel environment variables. Then, use the command below to pull the variables:
vercel env pull .env.local
Now, go to src/app/api/uploadthing and create a file called route.ts with the content:
import { createRouteHandler } from "uploadthing/next";
import { uploadRouter } from "@/lib/uploadthing";
export const { GET, POST } = createRouteHandler({
router: uploadRouter,
});
Writer onboarding flow
All users in the project can become writers by going through the onboarding flow. They can do this through a multi-step onboarding at /settings.
To create this, let's go to src/app/api/writers and create a file called route.ts with the content:
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { rateLimit } from "@/lib/rate-limit";
const createWriterSchema = z.object({
handle: z
.string()
.min(3)
.max(30)
.regex(
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
"Handle must be lowercase alphanumeric with optional hyphens"
),
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
category: z.enum([
"TECHNOLOGY",
"BUSINESS",
"CULTURE",
"POLITICS",
"SCIENCE",
"HEALTH",
"FINANCE",
"SPORTS",
"FOOD",
"TRAVEL",
"MUSIC",
"ART",
"EDUCATION",
"OTHER",
]),
avatarUrl: z.string().url().optional(),
bannerUrl: z.string().url().optional(),
});
export async function POST(request: NextRequest) {
const user = await requireAuth({ redirect: false });
if (!user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const limited = rateLimit(`writers:create:${user.id}`, {
interval: 60_000,
maxRequests: 5,
});
if (limited) return limited;
const existingWriter = await prisma.writer.findUnique({
where: { userId: user.id },
});
if (existingWriter) {
return NextResponse.json(
{ error: "You already have a publication" },
{ status: 409 }
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Invalid JSON body" },
{ status: 400 }
);
}
const parsed = createWriterSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0].message },
{ status: 400 }
);
}
const { handle, name, bio, category, avatarUrl, bannerUrl } = parsed.data;
const handleTaken = await prisma.writer.findFirst({ where: { handle } });
if (handleTaken) {
return NextResponse.json(
{ error: "Handle is already taken" },
{ status: 409 }
);
}
const writer = await prisma.writer.create({
data: {
userId: user.id,
handle,
name,
bio,
category,
avatarUrl,
bannerUrl,
},
});
return NextResponse.json(writer, { status: 201 });
}
The handles become the publication URL (/writer-handle) once the onboarding is complete, so the regex we use for handles (/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/) enforces URL-safe slugs.
The settings page and onboarding wizard are standard React components that collect these fields across four steps, use the Uploadthing components for avatar and banner uploads, and POST the collected data to this endpoint. See src/app/settings/page.tsx and src/components/settings/onboarding-wizard.tsx in the repo.
Publication categories
The explore page of the project uses a category filter and they're defined as both a Prisma enum (a fixed set of allowed values) and a constants file (a mapping from those values to readable labels for the UI).
To create this, go to src/constants and create a file called categories.ts with the content:
import { PublicationCategory } from "@/generated/prisma/client";
export const CATEGORY_LABELS: Record<PublicationCategory, string> = {
TECHNOLOGY: "Technology",
BUSINESS: "Business",
CULTURE: "Culture",
POLITICS: "Politics",
SCIENCE: "Science",
HEALTH: "Health",
FINANCE: "Finance",
SPORTS: "Sports",
FOOD: "Food",
TRAVEL: "Travel",
MUSIC: "Music",
ART: "Art",
EDUCATION: "Education",
OTHER: "Other",
};
export const CATEGORY_OPTIONS = Object.entries(CATEGORY_LABELS).map(
([value, label]) => ({ value: value as PublicationCategory, label })
);
App configuration constants
Several parts of the project reference shared constants like page sizes, trending algorithm weights, and pricing limits. Go to src/constants and create a file called config.ts with the content:
export const PLATFORM_FEE_PERCENT = 10;
export const MIN_PRICE_CENTS = 100;
export const MAX_PRICE_CENTS = 100_000;
export const POSTS_PER_PAGE = 10;
export const TRENDING_WRITERS_COUNT = 6;
export const TRENDING_WINDOW_DAYS = 14;
export const TRENDING_WEIGHTS = {
followers: 1,
subscribers: 3,
recentPosts: 2,
} as const;
Verify the onboarding flow
The build is done, so let's verify our writer onboarding flow:
- Sign in through Whop OAuth
- Navigate to
/settings. The onboarding wizard appears since you don't have a writer profile - Complete the four steps and submit
- Confirm the Writer record was created in Supabase with the correct
userIdreference - Visit
/{your-handle}, the publication page should be there
You now have authentication, a complete data model, file uploads, and writer onboarding. In Part 3, we'll build the rich text editor that writers use to create posts.
Part 3: The rich text editor
The text editor is one of the most important parts of the project because we need a text editor that authors can use easily but that still offers standard formatting options (so they can freely customise the articles they write).
In this section, we will set up the rich text editor Tiptap, add the custom paywall break extension, and set up the API calls to add articles to the database.
Paywall break extension configuration
One of the key features that will set our editor apart from other classic text editors is the paywall break extension. Authors will be able to insert this component wherever they wish in their text. Everything above the component will be readable by everyone, while the content below will be exclusive to subscribers.
To create the component file, go to /src/components/editor/extensions and create a file called paywall-break.ts with the content:
import { Node, mergeAttributes } from "@tiptap/core";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
paywallBreak: {
setPaywallBreak: () => ReturnType;
};
}
}
export const PaywallBreak = Node.create({
name: "paywallBreak",
group: "block",
atom: true,
parseHTML() {
return [{ tag: 'div[data-type="paywall-break"]' }];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-type": "paywall-break",
class: "paywall-break",
}),
"Content below is for paid subscribers only",
];
},
addCommands() {
return {
setPaywallBreak:
() =>
({ commands }) => {
return commands.insertContent({ type: this.name });
},
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-p": () => this.editor.commands.setPaywallBreak(),
};
},
});
Editor setup
Now, let's create our editor which has basic formatting, image uploads, and the paywall break. Go to src/components/editor and create a file called editor.tsx with the content:
"use client";
import { useRef } from "react";
import { useEditor, EditorContent, type JSONContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import UnderlineExt from "@tiptap/extension-underline";
import LinkExt from "@tiptap/extension-link";
import ImageExt from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import { PaywallBreak } from "./extensions/paywall-break";
import { Toolbar } from "./toolbar";
interface EditorProps {
initialContent?: JSONContent;
onChange: (content: JSONContent) => void;
editable?: boolean;
}
export function Editor({
initialContent,
onChange,
editable = true,
}: EditorProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const editor = useEditor({
extensions: [
StarterKit,
UnderlineExt,
LinkExt.configure({ openOnClick: false }),
ImageExt,
Placeholder.configure({ placeholder: "Start writing..." }),
PaywallBreak,
],
content: initialContent,
editable,
onUpdate: ({ editor }) => {
onChange(editor.getJSON());
},
});
if (!editor) return null;
function handleImageUpload() {
fileInputRef.current?.click();
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file || !editor) return;
const formData = new FormData();
formData.append("files", file);
try {
const res = await fetch("/api/uploadthing", {
method: "POST",
headers: { "x-uploadthing-package": "uploadthing" },
body: formData,
});
const data = await res.json();
if (data?.[0]?.ufsUrl) {
editor.chain().focus().setImage({ src: data[0].ufsUrl }).run();
}
} catch {
alert("Image upload failed. Please try again.");
}
e.target.value = "";
}
return (
<div className="rounded-lg border border-gray-200">
{editable && (
<Toolbar editor={editor} onImageUpload={handleImageUpload} />
)}
<div className="min-h-[400px] p-4">
<EditorContent editor={editor} />
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
}
The toolbar (see src/components/editor/toolbar.tsx in the repo) is a flat array of button definitions: bold, italic, underline, headings, lists, blockquote, code block, link, image, horizontal rule, and a paywall break toggle (lock icon). Each button calls the corresponding Tiptap chain command and highlights when active.
How the paywallIndex works
When a writer publishes a preview in our project, our server needs to understand how to split this content, which is why we use the paywallIndex field in our Post model. This allows us to configure where the server should split the post and which part should be shown to unsubscribed users.
Then, the /write page finds the paywallBreak section in the JSON content:
let paywallIndex: number | undefined;
if (content?.content) {
const idx = content.content.findIndex(
(node) => node.type === "paywallBreak"
);
if (idx !== -1) paywallIndex = idx;
}
The API for post creation
The POST handler we'll use should link the editor to the database, validate with Zod, generate a unique slug, and create the post record. To do this, go to src/app/api/posts and create a file called route.ts with the content:
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAuth, getWriterProfile } from "@/lib/auth";
import { rateLimit } from "@/lib/rate-limit";
import { POSTS_PER_PAGE } from "@/constants/config";
const createPostSchema = z.object({
title: z.string().min(1).max(200),
subtitle: z.string().max(400).optional(),
content: z.unknown(),
visibility: z.enum(["FREE", "PAID", "PREVIEW"]),
paywallIndex: z.number().int().min(0).optional(),
published: z.boolean(),
coverImageUrl: z.string().url().optional(),
});
export async function POST(request: NextRequest) {
const user = await requireAuth({ redirect: false });
if (!user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const limited = rateLimit(`posts:create:${user.id}`, {
interval: 60_000,
maxRequests: 10,
});
if (limited) return limited;
const writer = await getWriterProfile(user.id);
if (!writer) {
return NextResponse.json(
{ error: "You must be a writer to create posts" },
{ status: 403 }
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Invalid JSON body" },
{ status: 400 }
);
}
const parsed = createPostSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0].message },
{ status: 400 }
);
}
const { title, subtitle, content, visibility, paywallIndex, published, coverImageUrl } =
parsed.data;
const baseSlug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const slug = `${baseSlug}-${Date.now().toString(36)}`;
const post = await prisma.post.create({
data: {
writerId: writer.id,
title,
subtitle,
content: content as object,
visibility,
paywallIndex,
published,
publishedAt: published ? new Date() : null,
coverImageUrl,
slug,
},
});
return NextResponse.json(post, { status: 201 });
}
The write page
The /write page is what writers will use to create and edit posts. It includes the rich text editor and the toolbar (you can find it in src/components/editor/toolbar.tsx in the repo) that allows writers to format their text as bold, italic, heading, list, blockquotes, images, and add paywall breaks.
To create the /write page, go to src/app/write and create a file called page.tsx with the content:
"use client";
import { Suspense, useState, useEffect, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import type { JSONContent } from "@tiptap/core";
import { Editor } from "@/components/editor/editor";
import { UploadZone } from "@/components/ui/upload-zone";
import type { PostVisibility } from "@/generated/prisma/browser";
const VISIBILITY_OPTIONS: { value: PostVisibility; label: string; description: string }[] = [
{ value: "FREE", label: "Free", description: "Visible to everyone" },
{ value: "PAID", label: "Paid", description: "Subscribers only" },
{ value: "PREVIEW", label: "Preview", description: "Free preview with paywall" },
];
export default function WritePage() {
return (
<Suspense fallback={<div className="flex min-h-[60vh] items-center justify-center"><p className="text-gray-500">Loading...</p></div>}>
<WritePageInner />
</Suspense>
);
}
function WritePageInner() {
const router = useRouter();
const searchParams = useSearchParams();
const postId = searchParams.get("postId");
const [title, setTitle] = useState("");
const [subtitle, setSubtitle] = useState("");
const [coverImageUrl, setCoverImageUrl] = useState<string | null>(null);
const [content, setContent] = useState<JSONContent | undefined>(undefined);
const [visibility, setVisibility] = useState<PostVisibility>("FREE");
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!!postId);
useEffect(() => {
if (!postId) return;
fetch(`/api/posts/${postId}`)
.then((res) => {
if (!res.ok) throw new Error("Failed to load post");
return res.json();
})
.then((post) => {
setTitle(post.title);
setSubtitle(post.subtitle ?? "");
setCoverImageUrl(post.coverImageUrl);
setContent(post.content as JSONContent);
setVisibility(post.visibility);
})
.catch(() => {
router.push("/dashboard");
})
.finally(() => setLoading(false));
}, [postId, router]);
const save = useCallback(
async (publish: boolean) => {
if (!title.trim()) return;
setSaving(true);
try {
let paywallIndex: number | undefined;
if (content?.content) {
const idx = content.content.findIndex(
(node) => node.type === "paywallBreak"
);
if (idx !== -1) paywallIndex = idx;
}
const body = {
title: title.trim(),
subtitle: subtitle.trim() || undefined,
content,
visibility,
paywallIndex,
published: publish,
coverImageUrl: coverImageUrl ?? undefined,
};
const url = postId ? `/api/posts/${postId}` : "/api/posts";
const method = postId ? "PATCH" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
alert(data.error ?? "Something went wrong");
return;
}
router.push("/dashboard");
} finally {
setSaving(false);
}
},
[title, subtitle, content, visibility, coverImageUrl, postId, router]
);
if (loading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-gray-500">Loading...</p>
</div>
);
}
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-6">
<UploadZone
endpoint="coverImageUploader"
onUploadComplete={(url) => setCoverImageUrl(url)}
label="Cover image"
/>
</div>
<input
type="text"
placeholder="Post title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full border-0 bg-transparent font-serif text-4xl font-bold placeholder-gray-300 focus:outline-none focus:ring-0"
/>
<input
type="text"
placeholder="Add a subtitle..."
value={subtitle}
onChange={(e) => setSubtitle(e.target.value)}
className="mt-2 w-full border-0 bg-transparent text-xl text-gray-600 placeholder-gray-300 focus:outline-none focus:ring-0"
/>
<div className="mt-6">
<Editor
initialContent={content}
onChange={setContent}
/>
</div>
<div className="mt-8 flex flex-wrap items-center gap-4 border-t border-gray-200 pt-6">
<div className="flex items-center gap-2">
<label htmlFor="visibility" className="text-sm font-medium text-gray-700">
Visibility:
</label>
<select
id="visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as PostVisibility)}
className="input w-auto"
>
{VISIBILITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label} — {opt.description}
</option>
))}
</select>
</div>
<div className="ml-auto flex gap-3">
<button
onClick={() => save(false)}
disabled={saving || !title.trim()}
className="btn-secondary"
>
{saving ? "Saving..." : "Save draft"}
</button>
<button
onClick={() => save(true)}
disabled={saving || !title.trim()}
className="btn-primary"
>
{saving ? "Publishing..." : "Publish"}
</button>
</div>
</div>
</div>
);
}
Verification
Test the full editor-to-database loop:
- Navigate to
/write, create a post with a few paragraphs, insert a paywall break, and add content below it - Set visibility to "Preview" and publish
- Check the database.
published: true,visibility: "PREVIEW", and the content JSON contains apaywallBreaknode at the correct position - Save a second post as a draft. Verify it appears in your dashboard but not on your public publication page
- Edit the draft from the dashboard.
/writeloads with all fields populated
The editor is connected end-to-end. Next, we'll turn this stored content into rendered articles with server-side paywall enforcement.
Part 4: Publication pages and content rendering
Now that we've built the writer side of the project, let's move on to the reader side. In this section, we're going to create pages where audiences discover writers and read their content.
When a non-subscriber visits a preview post (article with a paywall and a preview at the start), we want to prevent them from manually removing the paywall and seeing the rest of the article.
So, we want to slice the content on the server-side, not client. Here's how the article pages implement the access check (from src/app/[writer]/[slug]/page.tsx):
let hasAccess = true;
let paywallIndex: number | undefined;
if (post.visibility !== "FREE") {
if (!user) {
hasAccess = false;
} else {
hasAccess = await canAccessPaidContent(user.id, post.writerId);
}
if (!hasAccess && post.visibility === "PREVIEW" && post.paywallIndex != null) {
paywallIndex = post.paywallIndex;
}
}
Rendering the Tiptap JSON
As we mentioned before, Tiptap stores the articles as JSON, not HTML. This allows us to use a recursive renderer that reads the JSON tree, giving us control over how each element renders, especially the paywall break node.
Let's go to src/components/post and create a file called post-content.tsx with the content:
import { PaywallGate } from "./paywall-gate";
interface PostContentProps {
content: unknown;
paywallIndex?: number | null;
hasAccess?: boolean;
writerName?: string;
writerHandle?: string;
price?: number;
}
export function PostContent({
content,
paywallIndex,
hasAccess,
writerName = "",
writerHandle = "",
price = 0,
}: PostContentProps) {
const doc = content as { type: string; content?: unknown[] };
const nodes = doc?.content ?? [];
let visibleNodes = nodes;
let showPaywall = false;
if (paywallIndex != null && !hasAccess) {
visibleNodes = nodes.slice(0, paywallIndex);
showPaywall = true;
}
return (
<div>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{
__html: renderNodes(visibleNodes),
}}
/>
{showPaywall && (
<PaywallGate
writerName={writerName}
writerHandle={writerHandle}
price={price}
/>
)}
</div>
);
}
function renderNodes(nodes: unknown[]): string {
return nodes.map(renderNode).join("");
}
function renderNode(node: unknown): string {
if (!node || typeof node !== "object") return "";
const n = node as Record<string, unknown>;
switch (n.type) {
case "paragraph":
return `<p>${renderChildren(n)}</p>`;
case "heading": {
const level = (n.attrs as Record<string, unknown>)?.level ?? 2;
return `<h${level}>${renderChildren(n)}</h${level}>`;
}
case "bulletList":
return `<ul>${renderChildren(n)}</ul>`;
case "orderedList":
return `<ol>${renderChildren(n)}</ol>`;
case "listItem":
return `<li>${renderChildren(n)}</li>`;
case "blockquote":
return `<blockquote>${renderChildren(n)}</blockquote>`;
case "codeBlock":
return `<pre><code>${renderChildren(n)}</code></pre>`;
case "image": {
const attrs = n.attrs as Record<string, unknown>;
const src = attrs?.src ?? "";
const alt = attrs?.alt ?? "";
return `<img src="${escapeHtml(String(src))}" alt="${escapeHtml(String(alt))}" />`;
}
case "horizontalRule":
return `<hr />`;
case "hardBreak":
return `<br />`;
case "paywallBreak":
return "";
case "text": {
let text = escapeHtml(String(n.text ?? ""));
const marks = n.marks as Array<Record<string, unknown>> | undefined;
if (marks) {
for (const mark of marks) {
switch (mark.type) {
case "bold":
text = `<strong>${text}</strong>`;
break;
case "italic":
text = `<em>${text}</em>`;
break;
case "underline":
text = `<u>${text}</u>`;
break;
case "strike":
text = `<s>${text}</s>`;
break;
case "code":
text = `<code>${text}</code>`;
break;
case "link": {
const href = (mark.attrs as Record<string, unknown>)?.href ?? "";
text = `<a href="${escapeHtml(String(href))}" target="_blank" rel="noopener noreferrer">${text}</a>`;
break;
}
}
}
}
return text;
}
default:
return renderChildren(n);
}
}
function renderChildren(node: Record<string, unknown>): string {
const children = node.content as unknown[] | undefined;
if (!children) return "";
return renderNodes(children);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
The paywall gate
When an unsubscribed reader hits the content boundary (the paywall gate), they should see a gate with a fade gradient that creates the impression the article continues but fades into the paywall.
To create this, go to src/components/post and create a file called paywall-gate.tsx with the content:
import { Lock } from "lucide-react";
import Link from "next/link";
import { formatPrice } from "@/lib/utils";
export interface PaywallGateProps {
writerName: string;
writerHandle: string;
price?: number | null;
}
export function PaywallGate({
writerName,
writerHandle,
price,
}: PaywallGateProps) {
return (
<div className="relative mt-8">
<div className="pointer-events-none absolute -top-24 left-0 right-0 h-24 bg-gradient-to-t from-white to-transparent" />
<div className="rounded-xl border border-gray-200 bg-gradient-to-br from-gray-50 to-white p-8 text-center shadow-sm">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
<Lock className="h-6 w-6 text-amber-700" />
</div>
<h3 className="font-serif text-xl font-bold text-gray-900">
This content is for paid subscribers
</h3>
<p className="mt-2 text-sm text-gray-500">
Subscribe to {writerName}
{price ? ` for ${formatPrice(price)}/month` : ""} to unlock this
post and all premium content.
</p>
<Link href={`/${writerHandle}`} className="btn-primary mt-6 inline-flex">
Subscribe to read
</Link>
</div>
</div>
);
}
One thing we should note is that the CTA button redirects readers to the publication page rather than a checkout page, allowing us to expose the full publication to the reader.
The article pages
The article pages live in the /[writer]/[slug] route and renders the page content and paywall breaks we just configured.
Using a slug, it pulls the post's content, determines which content the user should see, and displays either the full content (subscriber), partial content via a paywall gate (preview post), or the entire content (free post).
To create the article pages, go to /src/app/[writer]/[slug] and create a page called page.tsx with the content:
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { requireAuth } from "@/lib/auth";
import { getPostBySlug } from "@/services/post-service";
import { canAccessPaidContent } from "@/services/subscription-service";
import { isLikedByUser } from "@/services/post-service";
import { PostContent } from "@/components/post/post-content";
import { LikeButton } from "@/components/post/like-button";
import { PaywallGate } from "@/components/post/paywall-gate";
import { formatDate, estimateReadingTime } from "@/lib/utils";
interface ArticlePageProps {
params: Promise<{ writer: string; slug: string }>;
}
export async function generateMetadata({
params,
}: ArticlePageProps): Promise<Metadata> {
const { writer, slug } = await params;
const post = await getPostBySlug(writer, slug);
if (!post) {
return { title: "Post not found | Penstack" };
}
return {
title: `${post.title} | Penstack`,
description: post.subtitle ?? `By ${post.writer.name}`,
openGraph: {
title: post.title,
description: post.subtitle ?? `By ${post.writer.name}`,
type: "article",
...(post.coverImageUrl ? { images: [post.coverImageUrl] } : {}),
},
};
}
export default async function ArticlePage({ params }: ArticlePageProps) {
const { writer: writerHandle, slug } = await params;
const post = await getPostBySlug(writerHandle, slug);
if (!post) notFound();
const user = await requireAuth({ redirect: false });
let hasAccess = true;
let paywallIndex: number | undefined;
if (post.visibility !== "FREE") {
if (!user) {
hasAccess = false;
} else {
hasAccess = await canAccessPaidContent(user.id, post.writerId);
}
if (!hasAccess && post.visibility === "PREVIEW" && post.paywallIndex != null) {
paywallIndex = post.paywallIndex;
}
}
const liked = user ? await isLikedByUser(user.id, post.id) : false;
const readingTime = estimateReadingTime(post.content);
return (
<article className="mx-auto max-w-3xl px-4 py-8">
{post.coverImageUrl && (
<img
src={post.coverImageUrl}
alt={post.title}
className="mb-8 aspect-[2/1] w-full rounded-xl object-cover"
/>
)}
<header className="mb-8">
<h1 className="font-serif text-4xl font-bold leading-tight">
{post.title}
</h1>
{post.subtitle && (
<p className="mt-3 text-xl text-gray-600">{post.subtitle}</p>
)}
<div className="mt-4 flex items-center gap-3 text-sm text-gray-500">
<a
href={`/${post.writer.handle}`}
className="flex items-center gap-2 font-medium text-gray-900 hover:underline"
>
{post.writer.avatarUrl && (
<img
src={post.writer.avatarUrl}
alt={post.writer.name}
className="h-8 w-8 rounded-full object-cover"
/>
)}
{post.writer.name}
</a>
<span aria-hidden="true">·</span>
<time dateTime={post.publishedAt?.toISOString()}>
{post.publishedAt ? formatDate(post.publishedAt) : "Draft"}
</time>
<span aria-hidden="true">·</span>
<span>{readingTime} min read</span>
</div>
</header>
{hasAccess ? (
<PostContent content={post.content} />
) : post.visibility === "PREVIEW" && paywallIndex != null ? (
<PostContent
content={post.content}
paywallIndex={paywallIndex}
writerName={post.writer.name}
writerHandle={post.writer.handle}
price={post.writer.monthlyPriceInCents ?? undefined}
/>
) : (
<PaywallGate
writerName={post.writer.name}
writerHandle={post.writer.handle}
price={post.writer.monthlyPriceInCents}
/>
)}
<footer className="mt-10 flex items-center justify-between border-t border-gray-200 pt-6">
<LikeButton
postId={post.id}
initialLiked={liked}
initialCount={post._count.likes}
isLoggedIn={!!user}
/>
<a
href={`/${post.writer.handle}`}
className="text-sm font-medium text-[var(--brand-600)] hover:underline"
>
More from {post.writer.name}
</a>
</footer>
</article>
);
}
Writer publication page
The [writer] route (see the src/app/[writer]/page.tsx page in the repo) uses a handle to select the writer, fetches their shared content, and checks whether the current user follows the writer and is subscribed to them.
The page contains a WriterHeader element at the top (avatar, name, bio, follower/subscriber/post counts, follow and subscribe buttons) and below it, a PostCard element for each shared content (title, subtitle, cover image thumbnail, date, reading time, like/view counts).
If the writer has a whopChatChannelId, a WriterChat section appears at the bottom. Access is gated by chatPublic so subscriber-only chat is enforced.
See src/components/writer/writer-header.tsx and src/components/post/post-card.tsx in the repo.
Like buttons in articles
To add the like button to the article pages, go to src/components/post and create a file called like-button.tsx with the content.
The isLoggedIn prop lets us redirect unauthenticated users to the login page (with a returnTo URL) instead of silently failing:
"use client";
import { useState } from "react";
import { Heart } from "lucide-react";
import { formatCount } from "@/lib/utils";
interface LikeButtonProps {
postId: string;
initialLiked: boolean;
initialCount: number;
isLoggedIn?: boolean;
}
export function LikeButton({
postId,
initialLiked,
initialCount,
isLoggedIn,
}: LikeButtonProps) {
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
async function handleToggle() {
if (!isLoggedIn) {
window.location.href = `/api/auth/login?returnTo=${window.location.pathname}`;
return;
}
setLiked(!liked);
setCount((c) => (liked ? c - 1 : c + 1));
try {
const res = await fetch(`/api/posts/${postId}/like`, {
method: "POST",
});
if (!res.ok) throw new Error("Failed");
const data = await res.json();
setLiked(data.liked);
setCount(data.count);
} catch {
setLiked(liked);
setCount(count);
}
}
return (
<button
onClick={handleToggle}
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors ${
liked
? "bg-red-50 text-red-600"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
<Heart
className={`h-4 w-4 ${liked ? "fill-red-500 text-red-500" : ""}`}
/>
{formatCount(count)}
</button>
);
}
Follow button
The follow mechanism (see src/components/writer/writer-header.tsx in the repo) mirrors the like pattern: POST to /api/writers/[id]/follow, revert on failure. If the user is not logged in, they are redirected to the login page with a returnTo URL pointing back to the writer's page.
Following is a free relationship distinct from subscribing. When a user follows a writer, the server creates a Follow record and a NEW_FOLLOWER notification.
Part 5: Payments, subscriptions, and KYC
Writers can publish articles and edit them, readers can interact with the articles, and subscribe to the writers. Now, let's take on one of the most important parts of our project - payments, subscriptions, and KYC.
Luckily for us, this will be quite easy since we'll be using the Whop Payments Network infrastructure for all three. Subscribers will pay the writers directly, the platform will take a 10% cut, and the entire flow will be handled without our project ever touching a credit card.
WHOP_API_KEY must be a company API key from your company's Settings > API Keys page on Whop. Not the app API key from Developer > Apps. Both use the apik_ prefix, so you can't tell them apart by looking at the key.We'll use Whop's Direct Charge model where payments go directly to the writer's connected Whop account:
- Subscriber pays the monthly subscription fee
- Whop Payments Network processes the charge as a Direct Charge
- Writer's connected account receives the 90% minus processing fees
- Our platform gets 10% application fee
- Whop fires a webhook and our project creates a Subscription record
Keep in mind that the 10% fee is defined in the src/constants/config.ts file:
export const PLATFORM_FEE_PERCENT = 10;
Connected accounts and KYC
Before our writers can start posting paid articles and receive payments, they must verify their identity. We do this by prompting the writers to click the "Enable Paid Subscriptions" button which creates a Whop account for them and redirect the writer to a Whop hosted KYC page. This way we don't have to deal with storing and delivering any KYC information.
To do this, let's go to src/app/api/writers/[id]/kyc and create a file called route.ts with the content:
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { rateLimit } from "@/lib/rate-limit";
import { whop } from "@/lib/whop";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const user = await requireAuth({ redirect: false });
if (!user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const limited = rateLimit(`kyc:${user.id}`, {
interval: 60_000,
maxRequests: 5,
});
if (limited) return limited;
const writer = await prisma.writer.findUnique({
where: { id },
include: { user: { select: { email: true } } },
});
if (!writer) {
return NextResponse.json({ error: "Writer not found" }, { status: 404 });
}
if (writer.userId !== user.id) {
return NextResponse.json(
{ error: "Not your publication" },
{ status: 403 }
);
}
if (writer.kycCompleted) {
return NextResponse.json({ error: "KYC already completed" }, { status: 409 });
}
let companyId = writer.whopCompanyId;
if (!companyId) {
const company = await whop.companies.create({
title: writer.name,
parent_company_id: process.env.WHOP_COMPANY_ID!,
email: writer.user.email,
});
companyId = company.id;
await prisma.writer.update({
where: { id },
data: { whopCompanyId: companyId },
});
}
const setupCheckout = await whop.checkoutConfigurations.create({
company_id: companyId,
mode: "setup",
redirect_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings`,
});
return NextResponse.json({ url: setupCheckout.purchase_url });
}
Pricing and inline plan creation
After writers complete the KYC, they can set a subscription price. The setting route (in src/app/api/writers/[id]/route.ts) validates the price with Zod (minimum $1.00, maximum $1,000.00) and stores it as monthlyPriceInCents on the Writer record.
When a reader clicks the "Subscribe" button, the author's ID is directed to the checkout route and used to create a Whop checkout configuration, after which the reader is redirected to a hosted checkout URL. Once the payment is complete, a subscription record is created via webhook.
To do this, go to src/app/api/checkout and create a file called route.ts with the content:
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { rateLimit } from "@/lib/rate-limit";
import { whop } from "@/lib/whop";
import { PLATFORM_FEE_PERCENT } from "@/constants/config";
const checkoutSchema = z.object({
writerId: z.string().min(1),
});
export async function POST(request: NextRequest) {
const user = await requireAuth({ redirect: false });
if (!user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const limited = rateLimit(`checkout:${user.id}`, {
interval: 60_000,
maxRequests: 10,
});
if (limited) return limited;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Invalid JSON body" },
{ status: 400 }
);
}
const parsed = checkoutSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0].message },
{ status: 400 }
);
}
const { writerId } = parsed.data;
const writer = await prisma.writer.findUnique({ where: { id: writerId } });
if (!writer) {
return NextResponse.json({ error: "Writer not found" }, { status: 404 });
}
if (!writer.kycCompleted) {
return NextResponse.json(
{ error: "Writer has not completed KYC" },
{ status: 400 }
);
}
if (!writer.whopCompanyId) {
return NextResponse.json(
{ error: "Writer does not have a connected account" },
{ status: 400 }
);
}
const existingSub = await prisma.subscription.findUnique({
where: { userId_writerId: { userId: user.id, writerId } },
});
if (existingSub && existingSub.status === "ACTIVE") {
return NextResponse.json(
{ error: "You are already subscribed to this writer" },
{ status: 409 }
);
}
const priceInCents = writer.monthlyPriceInCents ?? 0;
const priceInDollars = priceInCents / 100;
const applicationFee = Math.round(priceInCents * PLATFORM_FEE_PERCENT) / 10000;
const checkout = await whop.checkoutConfigurations.create({
plan: {
company_id: writer.whopCompanyId,
currency: "usd",
renewal_price: priceInDollars,
billing_period: 30,
plan_type: "renewal",
release_method: "buy_now",
application_fee_amount: applicationFee,
product: {
external_identifier: `penstack-writer-${writer.id}`,
title: `${writer.name} Subscription`,
},
},
redirect_url: `${process.env.NEXT_PUBLIC_APP_URL}/${writer.handle}`,
metadata: {
userId: user.id,
writerId: writer.id,
},
});
return NextResponse.json({ url: checkout.purchase_url });
}
Updating the SDK constructor
Before writing the webhook handler, update the getWhop() function in src/lib/whop.ts to include the webhook secret by adding webhookKey to the constructor:
_whop = new Whop({
appID: process.env.WHOP_APP_ID!,
apiKey: process.env.WHOP_API_KEY!,
webhookKey: btoa(process.env.WHOP_WEBHOOK_SECRET!),
...(process.env.WHOP_SANDBOX === "true" && {
baseURL: "https://sandbox-api.whop.com/api/v1",
}),
});
Create the webhook on the company's Developer page (not the app's Webhooks tab).
The webhook handler
The webhook handler is where payment state materializes in your database. If it's broken, payments succeed on Whop's side but your app never knows, subscribers pay but can't access content.
The handler must meet three requirements: signature verification (reject tampered payloads), idempotency (process each event exactly once), and correct event routing (map each event type to the right database update).
payment.succeeded, membership.activated), not underscores.Go to src/app/api/webhooks/whop and create a file called route.ts with the content:
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { whop } from "@/lib/whop";
export async function POST(request: NextRequest) {
const rawBody = await request.text();
const headers = Object.fromEntries(request.headers);
let webhookData: { type: string; data: Record<string, unknown>; id?: string };
try {
webhookData = (await whop.webhooks.unwrap(rawBody, { headers })) as unknown as {
type: string;
data: Record<string, unknown>;
id?: string;
};
} catch (err) {
console.error("Webhook unwrap error:", err);
return NextResponse.json(
{ error: "Invalid webhook signature" },
{ status: 401 }
);
}
const eventId = webhookData.id ?? (webhookData.data.id as string);
const event = webhookData.type;
const data = webhookData.data;
const existing = await prisma.webhookEvent.findUnique({
where: { id: eventId },
});
if (existing) {
return NextResponse.json({ received: true });
}
try {
switch (event) {
case "payment.succeeded":
await handlePaymentSucceeded(data);
break;
case "payment.failed":
await handlePaymentFailed(data);
break;
case "membership.activated":
await handleMembershipActivated(data);
break;
case "membership.deactivated":
await handleMembershipDeactivated(data);
break;
default:
break;
}
await prisma.webhookEvent.create({
data: { id: eventId, eventType: event },
});
} catch (error) {
console.error(`Webhook handler error for ${event}:`, error);
return NextResponse.json(
{ error: "Internal webhook processing error" },
{ status: 500 }
);
}
return NextResponse.json({ received: true });
}
async function handlePaymentSucceeded(data: Record<string, unknown>) {
const membershipId = data.membership_id as string | undefined;
if (!membershipId) return;
const subscription = await prisma.subscription.findUnique({
where: { whopMembershipId: membershipId },
include: { writer: true },
});
if (!subscription) return;
await prisma.subscription.update({
where: { id: subscription.id },
data: { status: "ACTIVE" },
});
await prisma.notification.create({
data: {
userId: subscription.writer.userId,
type: "PAYMENT_RECEIVED",
title: "Payment received",
message: "A subscriber payment was successfully processed.",
writerId: subscription.writerId,
},
});
}
async function handlePaymentFailed(data: Record<string, unknown>) {
const membershipId = data.membership_id as string | undefined;
if (!membershipId) return;
const subscription = await prisma.subscription.findUnique({
where: { whopMembershipId: membershipId },
include: { writer: true },
});
if (!subscription) return;
await prisma.subscription.update({
where: { id: subscription.id },
data: { status: "PAST_DUE" },
});
await prisma.notification.create({
data: {
userId: subscription.writer.userId,
type: "PAYMENT_FAILED",
title: "Payment failed",
message: "A subscriber payment failed to process.",
writerId: subscription.writerId,
},
});
}
async function handleMembershipActivated(data: Record<string, unknown>) {
const membershipId = data.id as string;
const userId = (data.metadata as Record<string, unknown>)?.userId as
| string
| undefined;
const writerId = (data.metadata as Record<string, unknown>)?.writerId as
| string
| undefined;
const currentPeriodEnd = data.current_period_end as string | undefined;
if (!userId || !writerId) return;
const subscription = await prisma.subscription.upsert({
where: { userId_writerId: { userId, writerId } },
update: {
status: "ACTIVE",
whopMembershipId: membershipId,
currentPeriodEnd: currentPeriodEnd
? new Date(currentPeriodEnd)
: undefined,
cancelledAt: null,
},
create: {
userId,
writerId,
status: "ACTIVE",
whopMembershipId: membershipId,
currentPeriodEnd: currentPeriodEnd
? new Date(currentPeriodEnd)
: undefined,
},
});
const writer = await prisma.writer.findUnique({ where: { id: writerId } });
if (writer) {
await prisma.notification.create({
data: {
userId: writer.userId,
type: "NEW_SUBSCRIBER",
title: "New subscriber",
message: "Someone just subscribed to your publication!",
writerId,
},
});
}
return subscription;
}
async function handleMembershipDeactivated(data: Record<string, unknown>) {
const membershipId = data.id as string;
const subscription = await prisma.subscription.findUnique({
where: { whopMembershipId: membershipId },
});
if (!subscription) return;
await prisma.subscription.update({
where: { id: subscription.id },
data: {
status: "CANCELLED",
cancelledAt: new Date(),
},
});
}
Subscription status checking
This function lives in the subscription service created in Part 4 (src/services/subscription-service.ts). It checks whether a user can access a writer's paid content, and runs on every paid post page load.
export async function canAccessPaidContent(
userId: string,
writerId: string
) {
const sub = await prisma.subscription.findUnique({
where: { userId_writerId: { userId, writerId } },
});
if (!sub) return false;
if (sub.status !== "ACTIVE" && sub.status !== "CANCELLED") return false;
if (sub.status === "CANCELLED" && sub.currentPeriodEnd) {
return sub.currentPeriodEnd > new Date();
}
return sub.status === "ACTIVE";
}
Cancelled subscribers keep access until currentPeriodEnd passes. They've already paid for the current cycle, so we don't want to revoke access early.
Checkpoint: first payment processed
Test the complete payment flow in the Whop sandbox (WHOP_SANDBOX=true):
- Complete KYC in writer settings
- Set a monthly price (like $5.00)
- In an incognito window, log in as a different user and subscribe using a test card
- Check application logs for webhook receipt and confirm a Subscription record exists with status
ACTIVE - Publish a PAID post. The subscribed user sees full content, a non-subscriber sees the paywall
- Cancel the subscription and verify access persists until
currentPeriodEnd
In the next part, we add the features like explore, notification, and chat that directly affects engagement and churn.
Part 6: Explore, notifications, and chat
In the project's current state, the only way for readers to find publications is if they know the link addresses, and this is a problem. We will solve this with a explore section on our homepage, set up a notification system to keep readers engaged, and add embedded chats that users can utilise in publication profiles.
The explore page will feature two distinct sections serving two different purposes. One will be a trending publications section (a list of authors with high engagement) and beneath it, a reverse chronological list of all publications' posts.
The trending algorithm
The trending section ranks writers by a score computed from three signals:
score = followers * 1 + subscribers * 3 + recent_posts_14d * 2
Subscribers are weighted at 3x because a paid subscription is the strongest engagement signal. Recent posts carry 2x to ensure active writers surface above dormant ones. Followers sit at 1x as a baseline. The 14-day window for "recent posts" is defined in src/constants/config.ts as TRENDING_WINDOW_DAYS.
The new posts feed uses cursor-based pagination. When users click the "Load more" button, the client sends the post ID they see on the screen to the server, and the server sends the next batch.
Go to src/services and create a file called explore-service.ts with the content:
import { prisma } from "@/lib/prisma";
import {
POSTS_PER_PAGE,
TRENDING_WRITERS_COUNT,
TRENDING_WINDOW_DAYS,
TRENDING_WEIGHTS,
} from "@/constants/config";
import type { PublicationCategory } from "@/generated/prisma/client";
export async function getTrendingWriters(limit = TRENDING_WRITERS_COUNT) {
const windowStart = new Date();
windowStart.setDate(windowStart.getDate() - TRENDING_WINDOW_DAYS);
const writers = await prisma.writer.findMany({
include: {
user: { select: { displayName: true, avatarUrl: true } },
_count: { select: { followers: true, subscriptions: true } },
posts: {
where: { published: true, publishedAt: { gte: windowStart } },
select: { id: true },
},
},
});
const scored = writers.map((writer) => {
const score =
writer._count.followers * TRENDING_WEIGHTS.followers +
writer._count.subscriptions * TRENDING_WEIGHTS.subscribers +
writer.posts.length * TRENDING_WEIGHTS.recentPosts;
return { ...writer, trendingScore: score };
});
scored.sort((a, b) => b.trendingScore - a.trendingScore);
return scored.slice(0, limit).map(({ posts, ...rest }) => rest);
}
export async function getRecentPosts(
opts: { cursor?: string; limit?: number; category?: PublicationCategory } = {}
) {
const { cursor, limit = POSTS_PER_PAGE, category } = opts;
const posts = await prisma.post.findMany({
where: {
published: true,
...(category ? { writer: { category } } : {}),
},
orderBy: { publishedAt: "desc" },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
include: {
writer: {
include: {
user: { select: { displayName: true, avatarUrl: true } },
},
},
_count: { select: { likes: true } },
},
});
const hasMore = posts.length > limit;
const items = hasMore ? posts.slice(0, limit) : posts;
const nextCursor = hasMore ? items[items.length - 1].id : null;
return { items, nextCursor };
}
The PostFeed client component (see src/components/explore/post-feed.tsx in the repo) manages cursor state and appends results on each "Load more" click. The server renders the first page; subsequent pages are fetched client-side. The button disappears when nextCursor is null.
Category filtering
To deliver the articles that actually interest individual writers, we're going to include a category filter that updates the URL to /?category=TECHNOLOGY, making filtered views shareable.
Using URL params instead of component state means the server re-renders with filtered data on each navigation, and users can share filtered links directly.
Go to src/components/explore and create a file called category-filter.tsx with the content:
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { PublicationCategory } from "@/generated/prisma/browser";
import { CATEGORY_LABELS } from "@/constants/categories";
export function CategoryFilter() {
const router = useRouter();
const searchParams = useSearchParams();
const active = searchParams.get("category");
function handleSelect(category: string | null) {
const params = new URLSearchParams(searchParams.toString());
if (category) {
params.set("category", category);
} else {
params.delete("category");
}
router.push(`/?${params.toString()}`);
}
return (
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
<button
onClick={() => handleSelect(null)}
className={`shrink-0 rounded-full px-4 py-1.5 text-sm font-medium ${
!active ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-600"
}`}
>
All
</button>
{Object.values(PublicationCategory).map((cat) => (
<button
key={cat}
onClick={() => handleSelect(cat)}
className={`shrink-0 rounded-full px-4 py-1.5 text-sm font-medium ${
active === cat ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-600"
}`}
>
{CATEGORY_LABELS[cat]}
</button>
))}
</div>
);
}
Notification system
Our project has five notification types: new post, new subscriber, new follower, and payment received/failed. To create this service, go to src/services and create a file called notification-service.ts with the content:
import { prisma } from "@/lib/prisma";
import type { NotificationType } from "@/generated/prisma/client";
export async function notifyFollowers(
writerId: string,
type: NotificationType,
title: string,
message: string,
refs?: { postId?: string; writerId?: string }
) {
const followers = await prisma.follow.findMany({
where: { writerId },
select: { userId: true },
});
if (followers.length === 0) return;
await prisma.notification.createMany({
data: followers.map((f) => ({
userId: f.userId,
type,
title,
message,
postId: refs?.postId,
writerId: refs?.writerId,
})),
});
}
Embedded Whop chat
Live chats are one of the biggest engagement drivers in these types of projects, and it allows writers to form a community much more easily.
Rather than building WebSocket infrastructure, message storage, moderation, and presence indicators, we embed Whop's chat components directly.
The chat needs an access token, so we create a rate-limited endpoint that returns the user's Whop OAuth token.
Go to src/app/api/token and create a file called route.ts with the content:
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { rateLimit } from "@/lib/rate-limit";
export async function GET() {
const session = await getSession();
if (!session.userId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const limited = rateLimit(`token:${session.userId}`, {
interval: 60_000,
maxRequests: 30,
});
if (limited) return limited;
return NextResponse.json({ accessToken: session.accessToken ?? null });
}
Then, go to src/components/chat and create a file called writer-chat.tsx with the content:
"use client";
import { useEffect, useState, type CSSProperties, type FC, type ReactNode } from "react";
import { Elements } from "@whop/embedded-components-react-js";
import { loadWhopElements } from "@whop/embedded-components-vanilla-js";
let ChatElement: FC<{ options: { channelId: string }; style?: CSSProperties }> | undefined;
let ChatSession: FC<{ token: () => Promise<string>; children: ReactNode }> | undefined;
try {
const mod = require("@whop/embedded-components-react-js");
ChatElement = mod.ChatElement;
ChatSession = mod.ChatSession;
} catch {
}
interface WriterChatProps {
channelId: string;
className?: string;
}
async function getToken(): Promise<string> {
const res = await fetch("/api/token");
const data = await res.json();
return data.accessToken;
}
export function WriterChat({ channelId, className }: WriterChatProps) {
const [elements, setElements] =
useState<Awaited<ReturnType<typeof loadWhopElements>>>(null);
useEffect(() => {
loadWhopElements().then(setElements);
}, []);
if (!elements || !ChatElement || !ChatSession) {
return (
<div className={className}>
<div className="flex h-[500px] items-center justify-center rounded-xl border border-gray-200 bg-gray-50 text-gray-500">
<p>Chat is loading...</p>
</div>
</div>
);
}
return (
<Elements elements={elements}>
<ChatSession token={getToken}>
<div className={className}>
<ChatElement
options={{ channelId }}
style={{ height: "500px", width: "100%", borderRadius: "12px", overflow: "hidden" }}
/>
</div>
</ChatSession>
</Elements>
);
}
For TypeScript to accept these imports, we need a type augmentation.
Go to src/types and create a file called whop-chat.d.ts with the content:
import type { CSSProperties, FC, ReactNode } from "react";
declare module "@whop/embedded-components-react-js" {
export interface ChatElementOptions {
channelId: string;
deeplinkToPostId?: string;
onEvent?: (event: { type: string; detail: Record<string, unknown> }) => void;
}
export const ChatElement: FC<{ options: ChatElementOptions; style?: CSSProperties }>;
export const ChatSession: FC<{ token: () => Promise<string>; children: ReactNode }>;
}
The channelId comes from the writer's whopChatChannelId field. The chatPublic boolean controls access: when false, only subscribers see the chat section on the writer's profile page.
The writer analytics dashboard
Since writers in our project can receive payments and actually run a platform of their own, we need to provide them with information about their performance. The dashboard (see src/app/dashboard/page.tsx in the repo) shows four stat cards: subscribers, followers, total views, total posts - followed by a table of all posts with status, visibility, view count, and like count.
Add the following function to src/services/writer-service.ts:
export async function getWriterStats(writerId: string) {
const [subscriberCount, followerCount, totalViews, postCount] =
await Promise.all([
prisma.subscription.count({ where: { writerId, status: "ACTIVE" } }),
prisma.follow.count({ where: { writerId } }),
prisma.post.aggregate({
where: { writerId, published: true },
_sum: { viewCount: true },
}),
prisma.post.count({ where: { writerId, published: true } }),
]);
return {
subscribers: subscriberCount,
followers: followerCount,
totalViews: totalViews._sum.viewCount ?? 0,
totalPosts: postCount,
};
}
Part 7: Demo mode, polish, and production readiness
Our platform is now almost entirely ready. Authors can share content, readers can subscribe, payments are processed via Direct Charge, and notifications inform all users of important actions. There are a few things you need to pay attention to before completing the project.
Demo mode
The subscribe button uses a hybrid approach. Writers who have completed KYC and have a connected Whop account (whopCompanyId + kycCompleted) get a real Whop sandbox checkout.
Readers are redirected to a hosted checkout page, and a subscription record is created via webhook after payment. Since we're already using Whop Sandbox keys throughout this tutorial, no real money is involved.
For seeded demo writers (created by the seed script, without a connected Whop account), the subscribe button shows a DemoModal that explains the writer hasn't completed payment setup. Clicking "Confirm subscription" creates a mock subscription via /api/demo/subscribe without touching the payment network.
The SubscribeButton component receives a hasCheckout prop from the server:
hasCheckout={!!writer.whopCompanyId && !!writer.kycCompleted}
When hasCheckout is true, it calls /api/checkout (real Whop checkout). When false, it opens the demo modal. This way the demo infrastructure (src/lib/demo.ts, src/components/demo/, src/app/api/demo/, prisma/seed.ts) is only used as a fallback for writers without payment setup.
Rate limiting reference
Nearly every API route uses the in-memory rate limiter we built in Part 1. The webhook endpoint is excluded since Whop controls call frequency.
| Route | Key pattern | Max requests | Window |
|---|---|---|---|
GET /api/auth/login | auth:login (global) | 10 | 60s |
GET /api/posts | posts:list (global) | 60 | 60s |
POST /api/posts | posts:create:{userId} | 10 | 60s |
POST /api/posts/[id]/like | like:{userId} | 30 | 60s |
POST /api/writers | writers:create:{userId} | 5 | 60s |
PATCH /api/writers/[id] | writer:update:{userId} | 20 | 60s |
POST /api/writers/[id]/kyc | kyc:{userId} | 5 | 60s |
POST /api/checkout | checkout:{userId} | 10 | 60s |
POST /api/follow | follow:{userId} | 30 | 60s |
GET /api/notifications | notifications:{userId} | 30 | 60s |
GET /api/token | token:{userId} | 30 | 60s |
POST /api/demo/subscribe | demo:subscribe:{userId} | 10 | 60s |
Security and performance
Our session cookies are set to SameSite: Lax. This prevents malicious websites from sending requests to our site on behalf of users who have logged into our project. Additionally, because Tiptap stores shares as JSON rather than HTML and we use the escapeHtml function, malicious users cannot use scripts as share content.
All routes that write or read user data use requireAuth(). The only exceptions are the public feed (/api/posts), public profiles (/api/writers/[id]), and the webhook endpoint (which verifies Whop's signature instead).
For the sake of performance, we use the Next.js' Image component for all uploaded images to get automatic format conversion, resizing, and lazy loading. The Tiptap editor is also dynamically imported so users never download the editor code:
const Editor = dynamic(() => import("@/components/editor/post-editor"), { ssr: false });
Switching from sandbox to live Whop keys
Throughout this tutorial we've used the Whop sandbox keys (from sandbox.whop.com) so we could test payments without moving real money. To go live, you need to get new keys from Whop.com:
- Go to whop.com, open your whop's dashboard, and navigate to the Developer page
- Create a new app (or use an existing one) and grab the App ID, API Key, Company ID, Client ID, and Client Secret
- Set the OAuth redirect URL to your production domain:
https://your-app.vercel.app/api/auth/callback - Create a company-level webhook pointing to
https://your-app.vercel.app/api/webhooks/whopand copy the new webhook secret - Update your Vercel environment variables with the live keys (
WHOP_APP_ID,WHOP_API_KEY,WHOP_COMPANY_ID,WHOP_WEBHOOK_SECRET,WHOP_CLIENT_ID,WHOP_CLIENT_SECRET) - Remove
WHOP_SANDBOX=truefrom your environment variables (or leave it unset)
Once the sandbox variable is gone, the src/lib/whop.ts SDK client automatically points to the live Whop API and OAuth endpoints instead of the sandbox ones.
Deployment checklist
Production
- All environment variables set in Vercel:
WHOP_APP_ID,WHOP_API_KEY,WHOP_COMPANY_ID,WHOP_WEBHOOK_SECRET,WHOP_CLIENT_ID,WHOP_CLIENT_SECRET,DATABASE_URL,DIRECT_URL,UPLOADTHING_TOKEN,SESSION_SECRET,NEXT_PUBLIC_APP_URL - Schema pushed:
npx prisma db push - Webhook URL configured in Whop:
https://your-app.vercel.app/api/webhooks/whop - OAuth redirect URL in Whop:
https://your-app.vercel.app/api/auth/callback - Uploadthing callback URL configured for production domain
Demo (optional)
- Create a second Vercel project from the same repository
- Configure a separate Supabase database (never share the production database)
- Push schema and run seed:
npx prisma db push && npm run db:seed - Seeded writers use the demo subscribe fallback; real writers who complete KYC get sandbox checkout
Verification
Before calling the platform complete:
- Rate limiting rejects excessive requests (429 response)
- Subscribe buttons redirect to Whop checkout for KYC'd writers, or show demo modal for seeded writers
- The webhook handler creates subscription records after successful payments
- All environment variables are set and the build succeeds on Vercel
What we've built and what's next
Over seven parts, we built a functional Substack clone where:
- Users can become writers and create publications
- Post preview, paid, or free articles
- Readers can follow and subscribe to writers
- Preview and paid articles are kept safe from unsubscribed readers
- Readers can leave likes on articles
- Publication profiles have embedded Whop chats
The full source code is available at https://github.com/whopio/whop-tutorials/tree/main/penstack. The live demo is running at https://penstack-fresh.vercel.app/.
Build your own platform with Whop Payments Network
In this project, we used Whop Payments Network, the Whop API, and the Whop infrastructure to easily solve some of the most challenging parts of building a fully functional project that can actually move money and offer a safe experience to the users.
This Substack clone is one of the many platform examples you can build with Whop. You can check out our other build with Whop guides in our tutorials category and learn more about the entire Whop infrastructure in our developer documentation.