---
title: How to add checkout to a Next.js app
slug: add-checkout-to-nextjs-app
excerpt: You can add embedded or externally hosted checkouts to a Next.js app using the Whop infrastructure. Learn how to in this guide.
customExcerpt: You can add embedded or externally hosted checkouts to a Next.js app using the Whop infrastructure. Learn how to in this guide.
featureImage: "https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/04/How-to-add-checkout-to-a-Next.js-SaaS-app.webp"
status: published
publishedAt: "2026-05-04T14:27:00.000Z"
updatedAt: "2026-05-08T10:56:00.000Z"
createdAt: "2026-04-23T13:09:20.986Z"
tags:
  - { name: Tutorials, slug: tutorials }
  - { name: Developers, slug: developers }
authors:
  - { name: East, slug: east }
  - { name: Destinee Walston, slug: destinee }
---

# How to add checkout to a Next.js app

![Video thumbnail](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/media/2026/05/web-app-payments_thumb.jpg)
[Watch video](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/media/2026/05/web-app-payments.mp4)

## Key takeaways

- Whop's API lets Next.js developers add payments via either an embedded checkout or a hosted redirect, both firing identical webhooks.
- Developers should build in Whop's sandbox first, scripting product and plan creation with the SDK to populate environment variables cleanly.
- A webhook endpoint paired with a WebhookEvent table flips the user's plan flag idempotently, enabling reliable feature gating after purchase.

You can add a checkout and connect a payment system to your Next.js app using the Whop infrastructure by adding a few lines into your web page.

In this guide, we're going to walk you through building a working checkout using a single React component. Then, we'll do a deep dive on building a webhook that tells our app who paid, and a flag on the user row we can gate the features with for SaaS-like apps that need to know which user paid.

You can take a look at our [checkout demo](https://nextjs-checkout-demo.vercel.app/) to see a basic step by step walkthrough of how the checkout works.

> 

## Quick start

Adding an embedded checkout to a Next.js app can be done with a single component. Whop handles the checkout form, payments service, receipts, and order management.

All payments you receive can be managed in the Whop dashboard at Whop.com

### Step 1: Creating a plan

The embedded Whop checkout is a customizable component that requires a plan ID. To get it, you need to have a whop and create a plan in it. Follow the steps below to crate one:

- Go to your whop dashboard ([create a whop](https://whop.com/blog/create-a-whop/) if you don't have one already)
- Click on the **Checkout links** page and the **Create checkout link** at the top right
- Customize your checkout link in the editor and hit **Create checkout link**
- Back in the **Checkout links** page of your dashboard, click on the context menu button of your checkout link, hover over the **Details** option, and click on the plan ID to copy it

> 

### Step 2: Install the embed package

To make your code render the embedded checkout, install the checkout package using the command below:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install @whop/checkout</code></pre>
  </div>
</div>

### Step 3: Add the embed into a page

Now, go to the checkout page of your project (the `page.tsx` file) and add the checkout component:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { WhopCheckoutEmbed } from &quot;@whop/checkout/react&quot;;
export default function Page() {
  return (
    &lt;WhopCheckoutEmbed
      planId=&quot;plan_XXXXXXXXX&quot;
      returnUrl=&quot;https://yoursite.com/checkout/complete&quot;
    /&gt;
  );
}</code></pre>
  </div>
</div>

As you can see in the snippet above, the `planId` and `returnUrl` parameters are placeholders. You should replace the `plan_XXXXXXXXX` and `https://yoursite.com/checkout/complete` arguments with your actual plan ID you copied in the first step and your website's checkout completion URL.

You can test the checkout you just embedded by using a test card with the card number `4242 4242 4242 4242` (any CVC and future expiry). Once complete, you should be able to see the payment landing on your whop dashboard.

> 

## Setting up the integration

The quick setup we looked at above is enough for a working checkout. But if you want to know which user paid so you can change database flags, gate features, or log the purchases in your own system, you need an API key, a webhook, some environment variables, a database client, and a small authentication helper.

First, let's get the sandbox secret. We're going to use the sandbox environment of Whop for the development phase. This allows us to simulate payments without moving real money. We'll look at how you can switch from sandbox to the live Whop environment later in the guide.

### Getting the API key

Go to sandbox.whop.com, and [create a whop](https://whop.com/blog/create-a-whop/). Once done, visit the **Developer** page of your whop (on the bottom left) and create a company API key. You can do this by:

- Finding the "API keys" section at the top of the Developer page and clicking on the **Create** button of the Company API keys
- Give your API key a name and select Admin from the inherit permissions from role dropdown, then click **Create**
- Once created, copy the company API key (starts with `apik_`) and note it down. You'll add it to your environment variables later

> 

### Create the product and plans

To let your users complete payments, you need products on Whop, and there are two easy ways to create them:

- Manually creating them in the Whop dashboard
- Using the Whop API to create them Let's break down both.

#### First option: create them in the dashboard

You can create a product (for one-time payments) and a plan (for recurring payments) in the dashboard by following these steps:

- Go to the **Products** section of your whop and click **Create product** at the top right
- In the product editor, give your product a name and a headline
- Select **Paid access** in the pricing section, leave the payment type as **recurring** (selected by default), give it a price, and select the payment interval (1 month by default)

This plan will be the recurring payment in your app. Now, let's create the one-time payment by following the exact steps as below, but only selecting **one-time** as the payment type.

Once you're done, go back to the **Products** page of your whop, click on the context menu buttons of the product and plan you've created, hover over the **Details** part, and copy their IDs (starts with `prod_`)

#### Second option: use the Whop API

To script the setup of the product and plan, go to `scripts/` in your project, and create a file called `setup-whop.ts` with the code below:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">setup-whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { config } from &quot;dotenv&quot;;
import Whop from &quot;@whop/sdk&quot;;

config({ path: &quot;.env.local&quot; });
config();

const apiKey = process.env.WHOP_COMPANY_API_KEY;
const sandbox = process.env.WHOP_SANDBOX === &quot;true&quot;;
const explicitCompanyId = process.env.WHOP_COMPANY_ID;

if (!apiKey) {
  console.error(&quot;Set WHOP_COMPANY_API_KEY in .env.local before running this script.&quot;);
  process.exit(1);
}

const whop = new Whop({
  apiKey,
  ...(sandbox &amp;&amp; { baseURL: &quot;https://sandbox-api.whop.com/api/v1&quot; }),
});

async function resolveCompanyId(): Promise&lt;string&gt; {
  if (explicitCompanyId) return explicitCompanyId;
  try {
    const iterator = await whop.companies.list();
    for await (const company of iterator) {
      return (company as { id: string }).id;
    }
  } catch (err) {
    const error = err as { error?: { error?: { message?: string } } };
    const msg = error?.error?.error?.message ?? &quot;&quot;;
    if (msg.includes(&quot;company:basic:read&quot;)) {
      throw new Error(
        &quot;Your Company API Key cannot list companies (missing company:basic:read scope). &quot; +
          &quot;Set WHOP_COMPANY_ID=biz_... (find it in the Whop dashboard URL) and run this again.&quot;,
      );
    }
    throw err;
  }
  throw new Error(
    &quot;No company found. Create one in the Whop dashboard before running setup.&quot;,
  );
}

async function main() {
  const companyId = await resolveCompanyId();
  console.log(`Using company: ${companyId}\n`);

  const product = await whop.products.create({
    company_id: companyId,
    title: &quot;Pro&quot;,
    visibility: &quot;hidden&quot;,
  });
  const productId = (product as { id: string }).id;
  console.log(`Created product: ${productId}`);

  const subscription = await whop.plans.create({
    company_id: companyId,
    product_id: productId,
    plan_type: &quot;renewal&quot;,
    initial_price: 0,
    renewal_price: 29,
    billing_period: 30,
    currency: &quot;usd&quot;,
    visibility: &quot;hidden&quot;,
    release_method: &quot;buy_now&quot;,
  });
  const subscriptionId = (subscription as { id: string }).id;

  const lifetime = await whop.plans.create({
    company_id: companyId,
    product_id: productId,
    plan_type: &quot;one_time&quot;,
    initial_price: 199,
    currency: &quot;usd&quot;,
    visibility: &quot;hidden&quot;,
    release_method: &quot;buy_now&quot;,
  });
  const lifetimeId = (lifetime as { id: string }).id;

  console.log(&quot;\nAdd these to your .env.local:&quot;);
  console.log(`NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID=${subscriptionId}`);
  console.log(`NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID=${lifetimeId}`);
}

main().catch((err) =&gt; {
  console.error(err);
  process.exit(1);
});</code></pre>
  </div>
</div>

Then, use the command below to run the script:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">WHOP_COMPANY_API_KEY=&quot;apik_...&quot; WHOP_SANDBOX=&quot;true&quot; npx tsx scripts/setup-whop.ts</code></pre>
  </div>
</div>

> 

### Create the webhook

Now, let's create the webhook that will listen to actions from Whop so your app can know when a user completes a payment. Go to the Developer page of your whop again, and under the **Webhooks** section, click **Create webhook**, give it a name, set the endpoint URL to `https://your-domain.com/api/webhooks/whop`, and enable the events below before clicking **Save**:

- `payment_succeeded`
- `payment_failed`
- `membership_activated`
- `membership_deactivated`
Once created, copy your webhook secret, you'll use it later.

### Installing dependencies

To be able to add the checkout to your project, you're going to have to install four packages, including the Whop server SDK:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install @whop/sdk @whop/checkout @vercel/functions zod</code></pre>
  </div>
</div>

### Environment variables

Now, let's add all the secrets you've got so far into the environment variables of your project. We're going to use Vercel in this guide. Go to the Environment Variables page in the project settings on Vercel. There, add the environment variables:

<table>
  <thead>
    <tr>
      <th>Variable</th>
      <th>Example</th>
      <th>How to get it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>WHOP_COMPANY_API_KEY</code></td>
      <td><code>apik_...</code></td>
      <td>Whop dashboard → Business Settings → API Keys → create.</td>
    </tr>
    <tr>
      <td><code>WHOP_WEBHOOK_SECRET</code></td>
      <td><code>...</code></td>
      <td>Shown when we created the webhook in the previous step.</td>
    </tr>
    <tr>
      <td><code>WHOP_SANDBOX</code></td>
      <td><code>true</code></td>
      <td>Set manually. <code>true</code> during development; remove or set to <code>false</code> in production.</td>
    </tr>
    <tr>
      <td><code>NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID</code></td>
      <td><code>plan_...</code></td>
      <td>The subscription plan ID (First option: dashboard - Second option: printed by the setup script).</td>
    </tr>
    <tr>
      <td><code>NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID</code></td>
      <td><code>plan_...</code></td>
      <td>The one-time plan ID (same source as above).</td>
    </tr>
    <tr>
      <td><code>NEXT_PUBLIC_APP_URL</code></td>
      <td><code>http://localhost:3000</code></td>
      <td>Our app's origin. <code>http://localhost:3000</code> locally; the Vercel production URL once deployed.</td>
    </tr>
  </tbody>
</table>

### Validate environment variables at startup

In the `lib/` folder of your project, create a file called `env.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">env.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { z } from &quot;zod&quot;;

const envSchema = z.object({
  WHOP_COMPANY_API_KEY: z.string().min(1),
  WHOP_WEBHOOK_SECRET: z.string().min(1),
  WHOP_SANDBOX: z
    .string()
    .optional()
    .transform((v) =&gt; v === &quot;true&quot;),
  NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID: z.string().startsWith(&quot;plan_&quot;),
  NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID: z.string().startsWith(&quot;plan_&quot;),
  NEXT_PUBLIC_APP_URL: z.string().url(),
});

type Env = z.infer&lt;typeof envSchema&gt;;

let cached: Env | null = null;

function parseAll(): Env {
  if (cached) return cached;
  cached = envSchema.parse({
    WHOP_COMPANY_API_KEY: process.env.WHOP_COMPANY_API_KEY,
    WHOP_WEBHOOK_SECRET: process.env.WHOP_WEBHOOK_SECRET,
    WHOP_SANDBOX: process.env.WHOP_SANDBOX,
    NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID:
      process.env.NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID,
    NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID:
      process.env.NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  });
  return cached;
}

export const env = new Proxy({} as Env, {
  get(_target, prop: string) {
    return parseAll()[prop as keyof Env];
  },
});</code></pre>
  </div>
</div>

### SDK client

We're going to use a single shared Whop SDK client. Keep in mind that setting the `WHOP_SANDBOX` environment variable to `true` points it at the sandbox environment of Whop.

Go to `lib/` and create a file called `whop.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Whop from &quot;@whop/sdk&quot;;
import { env } from &quot;@/lib/env&quot;;

let _whop: Whop | null = null;

export function getWhop(): Whop {
  if (!_whop) {
    _whop = new Whop({
      apiKey: env.WHOP_COMPANY_API_KEY,
      webhookKey: Buffer.from(env.WHOP_WEBHOOK_SECRET).toString(&quot;base64&quot;),
      ...(env.WHOP_SANDBOX &amp;&amp; {
        baseURL: &quot;https://sandbox-api.whop.com/api/v1&quot;,
      }),
    });
  }
  return _whop;
}</code></pre>
  </div>
</div>

### User helper

In the rest of this article, we'll use a `requireUser()` helper that returns the current user. You should use whatever your existing authentication library exposes, and swap the `requireUser()` calls in the snippets below for its equivalent.

## Create a checkout session

When a user clicks the upgrade button, you create a Whop checkout session, tag it with the user's ID, and redirect to a page that render the embed. Later when the webhook fires, Whop gives us the same ID back. This is how you know which one of your users have paid.

### Plan definitions

We're going to have a single source of truth for both plans. The pricing UI and the checkout route will both read from it. To build it, go to `lib/` and create a file called `plans.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">plans.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export type PlanKey = &quot;subscription&quot; | &quot;lifetime&quot;;

export interface PlanDefinition {
  planKey: PlanKey;
  name: string;
  price: number;
  priceSuffix: string;
  description: string;
  features: readonly string[];
  envVar:
    | &quot;NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID&quot;
    | &quot;NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID&quot;;
}

export const PRO_FEATURES = [
  &quot;Unlimited projects&quot;,
  &quot;Advanced analytics&quot;,
  &quot;Priority support&quot;,
  &quot;Team access (up to 5 seats)&quot;,
] as const;

export const PLANS = {
  subscription: {
    planKey: &quot;subscription&quot;,
    name: &quot;Pro Monthly&quot;,
    price: 29,
    priceSuffix: &quot;/mo&quot;,
    description: &quot;Month-to-month billing. Cancel anytime.&quot;,
    features: PRO_FEATURES,
    envVar: &quot;NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID&quot;,
  },
  lifetime: {
    planKey: &quot;lifetime&quot;,
    name: &quot;Pro Lifetime&quot;,
    price: 199,
    priceSuffix: &quot;one-time&quot;,
    description: &quot;Pay once. Keep Pro forever.&quot;,
    features: PRO_FEATURES,
    envVar: &quot;NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID&quot;,
  },
} as const satisfies Record&lt;PlanKey, PlanDefinition&gt;;

export function planIdFor(key: PlanKey): string {
  const plan = PLANS[key];
  const value = process.env[plan.envVar];
  if (!value) {
    throw new Error(`Missing env var: ${plan.envVar}`);
  }
  return value;
}</code></pre>
  </div>
</div>

### The checkout route

We're going to use the same route to handle both plans. It validates the `plan` field, asks Whop for a session using the user ID, and redirects `/checkout` with session ID to the URL. Go to `app/api/checkout/` and create a file called `route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse, type NextRequest } from &quot;next/server&quot;;
import { requireUser } from &quot;@/lib/auth&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;
import { PLANS, planIdFor, type PlanKey } from &quot;@/lib/plans&quot;;
import { env } from &quot;@/lib/env&quot;;

function isPlanKey(value: FormDataEntryValue | null): value is PlanKey {
  return value === &quot;subscription&quot; || value === &quot;lifetime&quot;;
}

export async function POST(request: NextRequest): Promise&lt;NextResponse&gt; {
  const user = await requireUser();
  const form = await request.formData();
  const plan = form.get(&quot;plan&quot;);

  if (!isPlanKey(plan)) {
    return NextResponse.json({ error: &quot;Invalid plan&quot; }, { status: 400 });
  }

  const planId = planIdFor(plan);

  const whop = getWhop();
  const config = await whop.checkoutConfigurations.create({
    plan_id: planId,
    mode: &quot;payment&quot;,
    redirect_url: `${env.NEXT_PUBLIC_APP_URL}/checkout/complete`,
    metadata: { userId: user.id },
  });

  const sessionId = (config as { id: string }).id;

  const url = new URL(&quot;/checkout&quot;, env.NEXT_PUBLIC_APP_URL);
  url.searchParams.set(&quot;session&quot;, sessionId);
  url.searchParams.set(&quot;plan&quot;, PLANS[plan].planKey);

  return NextResponse.redirect(url, { status: 303 });
}</code></pre>
  </div>
</div>

> 

### Triggering the checkout

The pricing UI POSTs to `/api/checkout` with the plan key. The simplest version is a plain form with a hidden `plan` input:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">HTML</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-html">&lt;form action=&quot;/api/checkout&quot; method=&quot;POST&quot;&gt;
  &lt;input type=&quot;hidden&quot; name=&quot;plan&quot; value=&quot;subscription&quot; /&gt;
  &lt;button type=&quot;submit&quot;&gt;Upgrade to Pro Monthly&lt;/button&gt;
&lt;/form&gt;</code></pre>
  </div>
</div>

## Render the embedded checkout

Now, we're going to build two files: a server-rendered page that reads the query parameters and a client component that mounts the embed iframe.

#### The page shell

The page re-checks the authentication, validates the query parameters, and redirects to home if either is missing, and renders a plan alongside the embed. Go to `app/checkout/` and create a file called `page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &quot;next/navigation&quot;;
import { PlanSummaryCard } from &quot;@/components/plan-summary-card&quot;;
import { WhopCheckout } from &quot;./WhopCheckout&quot;;
import { PLANS, type PlanKey } from &quot;@/lib/plans&quot;;
import { requireUser } from &quot;@/lib/auth&quot;;
import { env } from &quot;@/lib/env&quot;;

interface SearchParams {
  session?: string;
  plan?: string;
}

function isPlanKey(value: string | undefined): value is PlanKey {
  return value === &quot;subscription&quot; || value === &quot;lifetime&quot;;
}

export default async function CheckoutPage({
  searchParams,
}: {
  searchParams: Promise&lt;SearchParams&gt;;
}) {
  await requireUser();
  const { session, plan } = await searchParams;

  if (!session || !isPlanKey(plan)) redirect(&quot;/&quot;);

  const planDef = PLANS[plan];

  return (
    &lt;div&gt;
      &lt;PlanSummaryCard
        name={planDef.name}
        price={planDef.price}
        priceSuffix={planDef.priceSuffix}
        features={[...planDef.features]}
      /&gt;
      &lt;WhopCheckout
        sessionId={session}
        returnUrl={`${env.NEXT_PUBLIC_APP_URL}/checkout/complete`}
        sandbox={env.WHOP_SANDBOX}
      /&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

#### The client component

The client component mounts the Whop embed and passes through the session ID, return URL, and the sandbox flag we use to indicate that we're working with the sandbox environment for now.

We'll switch this to live environment later. Go to `app/checkout/` and create a file called `WhopCheckout.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">WhopCheckout.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&quot;use client&quot;;

import { WhopCheckoutEmbed } from &quot;@whop/checkout/react&quot;;

interface WhopCheckoutProps {
  sessionId: string;
  returnUrl: string;
  sandbox: boolean;
}

export function WhopCheckout({
  sessionId,
  returnUrl,
  sandbox,
}: WhopCheckoutProps) {
  return (
    &lt;WhopCheckoutEmbed
      sessionId={sessionId}
      returnUrl={returnUrl}
      environment={sandbox ? &quot;sandbox&quot; : &quot;production&quot;}
      theme=&quot;light&quot;
      themeOptions={{ accentColor: &quot;pink&quot; }}
      fallback={&lt;CheckoutSkeleton /&gt;}
    /&gt;
  );
}

function CheckoutSkeleton() {
  return &lt;div className=&quot;h-[560px] w-full animate-pulse rounded bg-neutral-100&quot; /&gt;;
}</code></pre>
  </div>
</div>

The user fills in the card details inside the iframe, so our app doesn't touch that data at all. The `returnUrl` is where Whop redirects the user. The `sandbox` flag mirrors `env.WHOP_SANDBOX`, so this file doesn't change when we ship to production.

> 

## Handle the return URL

After the user pays, Whop redirects the browser to your `returnUrl` with a `?status=success` or `?status=error` in the URL. The webhook handles updating your database in the background. The page just needs to render a success or failure message.

### The complete page

The page reads `?status` from the URL and renders either the success message or an error link.

Go to `app/checkout/complete/` and create a file called `page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { requireUser } from &quot;@/lib/auth&quot;;
import { PRO_FEATURES } from &quot;@/lib/plans&quot;;

interface SearchParams {
  status?: string;
}

export default async function CompletePage({
  searchParams,
}: {
  searchParams: Promise&lt;SearchParams&gt;;
}) {
  await requireUser();
  const { status } = await searchParams;

  if (status === &quot;error&quot;) {
    return (
      &lt;div&gt;
        &lt;h1&gt;Payment didn&amp;rsquo;t go through&lt;/h1&gt;
        &lt;Link href=&quot;/&quot;&gt;Back to plans&lt;/Link&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;div&gt;
      &lt;h1&gt;Thanks, payment received&lt;/h1&gt;
      &lt;p&gt;Your access will be active shortly. Check your email for a receipt.&lt;/p&gt;
      &lt;ul&gt;
        {PRO_FEATURES.map((f) =&gt; (
          &lt;li key={f}&gt;{f}&lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

## Handle the webhook

Whop notifies our app via webhooks when a user action is done. Like when a payment succeeds, a renewal fails, etc.

When one of these webhooks hits our endpoint, we verify it's from Whop, read the data, and find out which user of ours it's about. Then, we update their `user.plan` accordingly.

### Event handlers

We need two handlers, one for new payments, and one for cancellation. Go to `lib/` and create a file called `webhooks.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">webhooks.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { z } from &quot;zod&quot;;
import { prisma } from &quot;@/lib/db&quot;;

// Whop&#039;s payment webhook payload only guarantees a small set of fields —
// `data.plan.plan_type` is NOT returned on webhooks, only when you
// retrieve the plan separately. We parse defensively and derive the
// plan type from the plan id instead.
const paymentSchema = z.object({
  id: z.string(),
  metadata: z.record(z.string(), z.unknown()).nullish(),
  plan: z.object({ id: z.string() }).optional(),
  billing_reason: z.string().nullish(),
});

const membershipSchema = z.object({
  id: z.string(),
  metadata: z.record(z.string(), z.unknown()).nullish(),
  plan: z.object({ id: z.string() }).optional(),
});

function readUserId(metadata: unknown): string | null {
  if (!metadata || typeof metadata !== &quot;object&quot;) return null;
  const value = (metadata as Record&lt;string, unknown&gt;).userId;
  return typeof value === &quot;string&quot; ? value : null;
}

function planTypeFromPlanId(
  planId: string | undefined,
): &quot;subscription&quot; | &quot;lifetime&quot; | null {
  if (!planId) return null;
  if (planId === process.env.NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID) {
    return &quot;subscription&quot;;
  }
  if (planId === process.env.NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID) {
    return &quot;lifetime&quot;;
  }
  return null;
}

async function alreadyProcessed(eventId: string): Promise&lt;boolean&gt; {
  const existing = await prisma.webhookEvent.findUnique({
    where: { id: eventId },
  });
  return existing !== null;
}

async function markProcessed(eventId: string, type: string): Promise&lt;void&gt; {
  try {
    await prisma.webhookEvent.create({ data: { id: eventId, type } });
  } catch {
  }
}

export async function handlePaymentSucceeded(eventId: string, data: unknown) {
  if (await alreadyProcessed(eventId)) return;

  let payment: z.infer&lt;typeof paymentSchema&gt;;
  try {
    payment = paymentSchema.parse(data);
  } catch (err) {
    console.error(&quot;[webhook] payment.succeeded parse failed:&quot;, err, &quot;payload:&quot;, data);
    return;
  }

  const userId = readUserId(payment.metadata);
  if (!userId) {
    console.error(
      &quot;[webhook] payment.succeeded with no userId metadata&quot;,
      payment.id,
    );
    return;
  }

  const planType = planTypeFromPlanId(payment.plan?.id);
  if (!planType) {
    console.error(
      &quot;[webhook] payment.succeeded with unrecognized plan id:&quot;,
      payment.plan?.id,
    );
    return;
  }

  try {
    await prisma.user.update({
      where: { id: userId },
      data: { plan: &quot;pro&quot;, planType, planSince: new Date() },
    });
  } catch (err) {
    console.error(&quot;[webhook] payment.succeeded DB update failed:&quot;, err);
    return;
  }

  await markProcessed(eventId, &quot;payment.succeeded&quot;);
}

export async function handleMembershipDeactivated(eventId: string, data: unknown) {
  if (await alreadyProcessed(eventId)) return;

  let membership: z.infer&lt;typeof membershipSchema&gt;;
  try {
    membership = membershipSchema.parse(data);
  } catch (err) {
    console.error(&quot;[webhook] membership.deactivated parse failed:&quot;, err);
    return;
  }

  const userId = readUserId(membership.metadata);
  if (!userId) return;

  try {
    await prisma.user.update({
      where: { id: userId },
      data: { plan: &quot;free&quot;, planType: null, planSince: null },
    });
  } catch (err) {
    console.error(&quot;[webhook] membership.deactivated DB update failed:&quot;, err);
    return;
  }

  await markProcessed(eventId, &quot;membership.deactivated&quot;);
}</code></pre>
  </div>
</div>

### The route handler

The route receives Whop's POSTs and verifies the signature. If it sees a mismatch, it replies with a 401. On a valid event, replies with 200. Go to `app/api/webhooks/whop/` and create a file called `route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { waitUntil } from &quot;@vercel/functions&quot;;
import { NextResponse, type NextRequest } from &quot;next/server&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;
import {
  handlePaymentSucceeded,
  handleMembershipDeactivated,
} from &quot;@/lib/webhooks&quot;;

interface WhopEvent {
  type: string;
  id: string;
  data: Record&lt;string, unknown&gt;;
}

export async function POST(request: NextRequest): Promise&lt;NextResponse&gt; {
  const bodyText = await request.text();
  const headers = Object.fromEntries(request.headers);

  let event: WhopEvent;
  try {
    event = getWhop().webhooks.unwrap(bodyText, {
      headers,
    }) as unknown as WhopEvent;
  } catch (err) {
    console.error(&quot;[webhook] signature verification failed:&quot;, err);
    return NextResponse.json({ error: &quot;Invalid signature&quot; }, { status: 401 });
  }

  switch (event.type) {
    case &quot;payment.succeeded&quot;:
    case &quot;membership.activated&quot;:
      waitUntil(handlePaymentSucceeded(event.id, event.data));
      break;
    case &quot;membership.deactivated&quot;:
      waitUntil(handleMembershipDeactivated(event.id, event.data));
      break;
    case &quot;payment.failed&quot;:
      console.error(&quot;[webhook] payment.failed:&quot;, event.id);
      break;
    default:
      break;
  }

  return NextResponse.json({ ok: true });
}</code></pre>
  </div>
</div>

## Gating paid features

With the webhook keeping `user.plan` current, gating a page is a three-line helper. Go to `lib/` and create a file called `access.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">access.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &quot;next/navigation&quot;;
import { requireUser } from &quot;@/lib/auth&quot;;

export async function requirePro() {
  const user = await requireUser();
  if (user.plan !== &quot;pro&quot;) redirect(&quot;/?upgrade=1&quot;);
  return user;
}</code></pre>
  </div>
</div>

Use it in any server component that renders Pro-only content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Component</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { requirePro } from &quot;@/lib/access&quot;;

export default async function ProDashboard() {
  const user = await requirePro();
  return &lt;div&gt;Welcome back, {user.email}&lt;/div&gt;;
}</code></pre>
  </div>
</div>

## Switching from sandbox to production

As we mentioned, we've been using the sandbox environment of Whop (sandbox.whop.com) throughout the guide. It allows us to simulate payments without moving real money.

To go live, you should transform your project to the live environment (whop.com) let's see what you should do:

- **Recreate the plans and products in the production environment** - Go to the live environment at whop.com, create a whop if you don't already have one, and follow the steps we documented below to recreate your secret keys, products, and plans
- **Create a production webhook** - Using the production URL of your project, create a new webhook to get a live secret
- **Update your environment variables** - Using the new secret keys you got on the live environment, you should go back to your host and update your environment variables like `WHOP_COMPANY_API_KEY` and `WHOP_WEBHOOK_SECRET`
- **Direct the API to the live environment** - Go to your environment variables and set `WHOP_SANDBOX` to `false`

## Getting paid

When a user completes a payment, the funds get transferred to your company's Whop balance. There are two easy ways to move that money to a bank account: using a Whop hosted dashboard to complete the payouts, or adding an embedded payout component to your project.

<table>
  <thead>
    <tr>
      <th></th>
      <th>Hosted</th>
      <th>Embedded</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Where the owner manages it</td>
      <td>Whop dashboard</td>
      <td>Inside our own admin UI</td>
    </tr>
    <tr>
      <td>Setup</td><td>None</td>
      <td>Install <code>@whop/embedded-components-react-js</code></td>
    </tr>
    <tr>
      <td>KYC + bank linking</td>
      <td>Handled by Whop</td>
      <td>Handled by Whop (rendered in our UI)</td>
    </tr>
    <tr>
      <td>Best for</td>
      <td>Single-owner project</td>
      <td>Teams keeping everything in their own app</td>
    </tr>
  </tbody>
</table>

## Choosing a checkout type

Whop supports two checkout methods for Next.js projects: an embedded checkout that you can integrate into your app, and Whop-hosted checkout pages that redirect users back to your app once the checkout is complete.

Both accept the same metadata, fire the same webhook, and end in the same `user.plan = "pro"` flip.

<table>
  <thead>
    <tr>
      <th></th>
      <th>Embedded</th>
      <th>Hosted</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Where it renders</td>
      <td>Inside your own project</td>
      <td><code>whop.com/checkout/...</code></td>
    </tr>
    <tr>
      <td>User leaves your domain</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Setup</td>
      <td>Server route + iframe component</td>
      <td>One redirect or anchor tag</td>
    </tr>
    <tr>
      <td>Metadata support</td>
      <td>Yes</td>
      <td>Yes, via <code>checkoutConfigurations.create</code></td>
    </tr>
    <tr>
      <td>Brand look</td>
      <td>Theme + accent color</td>
      <td>Whop-branded</td>
    </tr>
    <tr>
      <td>Best for</td>
      <td>Primary upgrade flow</td>
      <td>Email links, experiments, static sites</td>
    </tr>
  </tbody>
</table>

## Step up your projects with Whop

You now know how to add a checkout to your Next.js project. But that's not the only thing Whop can help you step up your project with. If you have other creators that sign up to your platform and get paid (like a [Gumroad](https://whop.com/blog/build-gumroad-clone/) or a [Substack clone](https://whop.com/blog/build-substack-clone/)), you can use Whop to create a marketplace where creators publish content, users purchase, and you get a cut.

You can also use the Whop infrastructure to add live chats to your apps, easily handle user authentication with Whop OAuth, integrate private support chats, and much more. If you want to learn more about how Whop can help you, check out our [developer documentation](https://docs.whop.com/developer/).

**[Go to the Whop documentation](https://docs.whop.com/developer/)**
