close

DEV Community

Cover image for How to add a paywall to your app or website
Doğukan Karakaş
Doğukan Karakaş

Posted on • Originally published at whop.com

How to add a paywall to your app or website

We just shipped a tutorial on adding a paywall to an existing Next.js app with Whop. The whole thing runs without a database and without webhooks. The visitor opens a locked page, pays inside an embedded checkout, and the page unlocks in place. Identity comes from the receipt; entitlement is checked live on every render.

Live demo (try the test card 4242 4242 4242 4242 to watch the unlock happen)

The usual paywall has four layers: a users table, a subscriptions table, a webhook handler keeping the subscriptions table in sync, and a signup flow gating the purchase. We collapse all four. Whop holds the user, holds the entitlement, and is the source of truth on every render. The cookie holds one field: whopUserId. Nothing in the database, nothing on a webhook.

How the whole flow works

Every paywall has three jobs. Enforcement is the server side gate that decides whether to render the content. Entitlement is the answer to "does this user own the product?" and lives on Whop. Identity is a whopUserId in an encrypted cookie.

Here is the twist: putting checkout first inverts the usual order. The checkout produces identity and entitlement in one step, and the receipt id is the proof the browser hands back to our server. The checkout is the signup form.

End to end:

  1. A visitor opens a premium post. The server component finds no whopUserId cookie and renders the paywall with the checkout embedded.
  2. The visitor pays inside the embed. The email field in the checkout is the first place we capture identity.
  3. The embed's onComplete callback fires with a receipt id (pay_...), which the client posts to /api/unlock.
  4. The unlock route asks Whop for that payment, confirms it is paid and bought one of our products, and reads the buyer's user.id.
  5. The route writes { whopUserId } into an encrypted iron-session cookie, and the client refreshes the route.
  6. The page re-renders. The gate calls users.checkAccess for each relevant product with the cookie's user id, any grant renders the content, and the same live check runs on every later visit. Revoke the membership and the page locks again.

The receipt id is the only thing that ever crosses from browser to server, and it is worthless until Whop confirms what it paid for. The API key and every verification step stay server side.

The gate

The entitlement layer is one small file. users.checkAccess is the source of truth; React's cache() dedupes per render so a page with multiple gated components only pays for one round trip per product.

import { cache } from "react";
import { getSession } from "@/lib/session";
import { getWhop } from "@/lib/whop";

export const checkProductAccess = cache(
  async (productId: string, whopUserId: string): Promise<boolean> => {
    const result = await getWhop().users.checkAccess(productId, {
      id: whopUserId,
    });
    return result.has_access;
  },
);

export async function hasAccess(
  productIds: Array<string | null | undefined>,
): Promise<boolean> {
  const session = await getSession();
  const whopUserId = session.whopUserId;
  if (!whopUserId) return false;

  const ids = productIds.filter((id): id is string => Boolean(id));
  const results = await Promise.all(
    ids.map((id) => checkProductAccess(id, whopUserId)),
  );
  return results.some(Boolean);
}
Enter fullscreen mode Exit fullscreen mode

Anonymous visitors return false immediately, no API call. Signed sessions pay one Whop round trip per product per render, always fresh. There is no isPro flag anywhere because there is nothing to keep fresh; the gate queries Whop every time and the answer is always current.

The gated page

The page branches on hasAccess. The unlocked branch never renders for locked visitors, not even hidden with CSS.

const unlocked =
  !post.premium ||
  (await hasAccess([env.WHOP_PRO_PRODUCT_ID, post.productId]));

return (
  <main>
    <h1>{post.title}</h1>
    {unlocked ? (
      <article>{/* premium content */}</article>
    ) : (
      <PaywallCard
        options={[/* one time plus subscription tier */]}
        environment={env.WHOP_SANDBOX ? "sandbox" : "production"}
        returnUrl={`${env.APP_URL}/posts/${post.slug}`}
      />
    )}
  </main>
);
Enter fullscreen mode Exit fullscreen mode

A page can be unlocked by any of multiple products. For a sellable post, that is its own product or the subscription product. hasAccess takes the list and returns true if the user owns any of them.

The checkout embed

There is no checkout session route anywhere. The embed takes a planId directly. Whop's checkout collects the payment and the user's email in one step, and the second argument of onComplete is the receipt id (a pay_...) that the client posts to the unlock route.

<WhopCheckoutEmbed
  planId={active.planId}
  environment={environment}
  returnUrl={returnUrl}
  onComplete={(_planId, receiptId) => {
    if (!receiptId) return;
    void verify(receiptId);
  }}
/>
Enter fullscreen mode Exit fullscreen mode

The receipt id stays in component memory, never in a URL.

The unlock route

The route does exactly one job: trade a verified receipt for a session. It does not grant entitlement, because the gate queries Whop on the next render anyway. Keeping the split is what keeps both pieces small.

const payment = await getWhop().payments.retrieve(receiptId);

// A receipt only unlocks if it paid for one of our products
if (!knownProductIds.has(payment.product?.id)) {
  return Response.json({ error: "wrong_product" }, { status: 403 });
}
if (payment.status !== "paid" || payment.substatus?.includes("refund")) {
  return Response.json({ error: "not_paid" }, { status: 403 });
}

const session = await getSession();
session.whopUserId = payment.user.id;
session.username = payment.user.username;
await session.save();
return Response.json({ ok: true });
Enter fullscreen mode Exit fullscreen mode

The route returns JSON instead of redirecting. Writing an iron-session cookie and returning NextResponse.redirect from the same handler is a known way to lose the Set-Cookie header. The client calls router.refresh() after the 200 instead.

A nuance worth knowing: a refund alone does not revoke access on Whop. Refunding flips the payment substatus (which our route refuses on subsequent receipts), but the membership stays valid until you cancel or terminate it. Revoking the membership is what relocks the page.

A few decisions worth knowing

The cookie holds whopUserId only. There is no isPro flag or any other ownership state in the cookie. Everything related to ownership lives on Whop and gets fetched per render.

React cache() on checkAccess. A page with three gated sections still costs one API call per product per render. Without cache() it would cost three.

Trim env vars. A trailing newline pasted into a dashboard env field fails the SDK with errors that never mention whitespace. Every process.env.* read in the env validator calls .trim().

Returning users on a new device. The whopUserId in the cookie matches the sub claim on Whop's OAuth id_token. Add Sign in with Whop later and the OAuth callback sets the same field; the gate unlocks with zero changes here. The cookie shape is identical to the one our user authentication article uses.

When to add a database

This is the leanest possible paywall. Live API checks are great for small to medium traffic and apps that need fresh entitlement reads. When you need durable payment records (receipts, refunds, renewals, analytics) or want to absorb traffic that would saturate per-render API checks, layer in a webhook-fed database flag.

The two approaches compose. Use this gate for content. Add the webhook database for the payment timeline.

Sandbox to production

The switch is one env var: WHOP_SANDBOX=false (or remove it entirely). The SDK base URL and the embed's environment prop both derive from it, so they flip together.

Production checklist:

  1. Recreate the products and plans in production. Sandbox ids do not carry over.
  2. Create a production Company API key with the same permissions (payment:basic:read, plan:basic:read, access_pass:basic:read, member:basic:read).
  3. Swap the product and plan ids in the env and the posts map.
  4. Generate a fresh SESSION_SECRET and set APP_URL to your real origin.

No webhook to migrate. The paywall's production switch is just keys and ids.

Links

If you have an existing Next.js app and want to put a paywall in front of premium content without standing up a billing layer, this is the pattern.

Top comments (0)