DEV Community

Cover image for I built a full Substack clone with Next.js and Whop
Doğukan Karakaş
Doğukan Karakaş

Posted on

I built a full Substack clone with Next.js and Whop

I spent the last few weeks building a Substack clone called Penstack. Rich text editor, paid subscriptions where money goes directly to writers, OAuth login, embedded chat per publication.

The editor and publication pages were the parts I thought would be hard. They weren't, honestly. Standard React. What I was dreading was the payments layer: routing money to individual creators, handling identity verification, dealing with tax stuff. And then building a chat system on top of that. Those are the kinds of features that turn a weekend project into a quarter-long slog.

I ended up using Whop for all of it, and most of those problems just... went away. I'll explain what I mean.

What's in the box

Penstack is a multi-writer publishing platform. Readers browse, subscribe to writers, and get access to paid content. Writers get a rich text editor, an analytics dashboard, and a chat channel for their community. Here's what it does:

  • Rich text editor (Tiptap) with images, code blocks, formatting, links
  • Custom paywall break: writers drop a marker anywhere in a post, and everything below it is gated
  • Paid subscriptions via Whop Direct Charges. Money goes to the writer, Penstack takes 10%
  • OAuth login through Whop (PKCE flow, iron-session cookies)
  • Identity verification (KYC) for writers, handled by Whop's hosted flow
  • Embedded chat per publication using Whop's React components
  • Explore page with trending writers and category filters
  • Notifications for follows, subscriptions, and payments
  • Writer dashboard with subscriber/follower/view counts

Stack: Next.js 15 (App Router), Prisma 7, Supabase for Postgres, Tiptap for the editor, Whop SDK, UploadThing for images, deployed on Vercel.

It handles real money. You can deploy it and charge people today.

How Whop made the hard parts easy

Auth without a password table

I've implemented email/password auth from scratch before. Password hashing, email verification, reset token generation and expiration, session management, CSRF protection. It takes a while and it's never fun. The Whop OAuth setup replaced all of that with two API routes and a config object.

The whole OAuth config:

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://${whopDomain}/oauth`,
  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"],
  redirectUri: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback`,
};
Enter fullscreen mode Exit fullscreen mode

The login route generates a PKCE challenge, stashes the verifier in an iron-session cookie, and redirects to Whop. The callback exchanges the authorization code for a token, pulls the user profile from /oauth/userinfo, upserts them into Postgres, and sets the session. That's it. No password table, no token rotation, no "forgot password" flow.

One thing I really like: the WHOP_SANDBOX toggle. Set it to true during development and every OAuth call hits the sandbox environment instead of production. When you're ready to go live, just remove it.

Payments in about 100 lines

This was the feature I was most nervous about. If I'd gone the Stripe Connect route, I'd be building account onboarding flows, managing payout schedules, handling currency edge cases for different countries. That alone could eat weeks.

Whop's Direct Charge model skips most of that complexity. The payment goes directly to the writer's connected account, and Penstack takes a 10% application fee. Penstack is never the merchant of record, which means I'm not on the hook for refunds, chargebacks, or international tax compliance.

Here's the checkout route. This is the core of the whole payments system:

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 });
Enter fullscreen mode Exit fullscreen mode

That single SDK call creates the plan, the product, and a hosted checkout page. The application_fee_amount field is where Penstack takes its cut. The metadata carries user and writer IDs through to the webhook so I know who subscribed to whom.

The subscriber flow looks like this: they click "Subscribe," my server creates the checkout config, Whop hosts the actual payment page, and after payment succeeds Whop fires a membership.activated webhook. My handler picks that up and creates the subscription record in Postgres.

Writers also need identity verification before they can accept payments. That's two more SDK calls:

// Create a connected account under the platform
const company = await whop.companies.create({
  title: writer.name,
  parent_company_id: process.env.WHOP_COMPANY_ID!,
  email: writer.user.email,
});

// Generate hosted KYC URL
const setupCheckout = await whop.checkoutConfigurations.create({
  company_id: company.id,
  mode: "setup",
  redirect_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings`,
});
Enter fullscreen mode Exit fullscreen mode

Whop hosts the whole KYC flow. Identity documents, bank account linking, tax info. Penstack doesn't see or store any of it.

On the webhook side, Whop sends events like payment.succeeded and membership.activated. The webhook handler verifies the signature, does an idempotency check (so duplicate deliveries don't create duplicate records), and routes each event type to the right database update. About 60 lines for the handler plus four event-specific functions.

Chat without building chat

I was fully prepared to skip this feature. Building a real-time chat system means WebSocket servers, message persistence, connection state management, moderation tooling. It's a ton of infrastructure for something that isn't core to a publishing platform.

But Whop has embeddable chat components, so I tried them:

import { Elements } from "@whop/embedded-components-react-js";

<Elements elements={elements}>
  <ChatSession token={getToken}>
    <ChatElement
      options={{ channelId }}
      style={{ height: "500px", width: "100%" }}
    />
  </ChatSession>
</Elements>
Enter fullscreen mode Exit fullscreen mode

That's the whole chat implementation on the frontend. Each writer's publication has a channelId, and the token prop grabs the user's OAuth access token from a lightweight API endpoint. Writers can also set their chat to subscriber-only with a boolean toggle. I wrote zero backend code for messaging.

The paywall break (the part I actually engineered)

This is probably the most interesting piece from a pure engineering standpoint. Substack gives you a binary choice: a post is free or it's paid. Penstack lets writers place the paywall break wherever they want in a post. Write three paragraphs of free preview to hook the reader, then drop the break, and everything after it requires a subscription.

It's a custom Tiptap node, defined as an atom in the block group so it sits between paragraphs like a horizontal rule:

import { Node, mergeAttributes } from "@tiptap/core";

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 });
        },
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

Writers insert it from the toolbar (there's a lock icon) or with Cmd+Shift+P. In the editor it renders as a visible divider with a label.

The server-side part is dead simple. When saving a post, the client scans the Tiptap JSON for the break's position:

let paywallIndex: number | undefined;
if (content?.content) {
  const idx = content.content.findIndex(
    (node) => node.type === "paywallBreak"
  );
  if (idx !== -1) paywallIndex = idx;
}
Enter fullscreen mode Exit fullscreen mode

That index gets stored on the post record. When a non-subscriber loads the article, the server slices the content array at that position and only sends back what's above the break. The node itself doesn't know anything about pricing or subscription status. It's just a position marker. All the access control happens on the server.

Things I'd do differently

I'd build the webhook handler first if I started over. It's the most important code in the project because it's where payment state actually materializes in your database. If the webhook handler is broken, people pay but your app has no idea. I burned a couple hours on a signature verification bug that turned out to be a trailing newline in the webhook secret. (printf, not echo, when setting env vars through a CLI. Lesson learned.)

I'd also use the Whop sandbox (sandbox.whop.com) from the very beginning. I started on production and switched to sandbox later, which meant recreating my app and re-entering all my credentials again. Starting on sandbox and switching to production when you're ready is way smoother.

The other thing: if you're deploying to Vercel with Supabase, use the Vercel integration instead of setting up Supabase manually. It populates your connection strings automatically, and you avoid the whole "which pooler port do I use" confusion. (Transaction mode on port 6543 for your app, session mode on port 5432 for Prisma migrations. I mixed them up more than once.)

Links

The Whop-specific code in the whole project is maybe 300 lines. Everything else is a normal Next.js app. If you're building something where creators need to get paid, the Whop developer docs are worth looking at.

Top comments (0)