GuidesLemon Squeezy

Credit goes to Wahab Shaikh for this guide.

Table of Contents:

MongoDB Setup

  • Install the package
  • Terminal

    1npm install @lemonsqueezy/lemonsqueezy.js
  • Add the environment variables
  • .env.local

    1LEMONSQUEEZY_API_KEY=
    2LEMONSQUEEZY_STORE_ID=
    3LEMONSQUEEZY_SIGNING_SECRET=
    • Create a new API key from Settings > API
    • Find your store ID from Settings > Stores
    • Just copy the number, without the #

    • Generate a random string for the signing secret
  • Create a new product from Settings > Products. Make sure to add a variant, even if you plan to have a single price.
  • You can get the variantId by selecting the variant and then copying the number from the URL. It follows the convention: https://app.lemonsqueezy.com/product/[productId]/variants/[variantId].
  • Update your config
  • /config.ts

    1stripe: {
    2lemonsqueezy: {
    3    plans: [
    4      {
    5        priceId:
    6        variantId:
    7          process.env.NODE_ENV === "development"
    8            ? "price_1Niyy5AxyNprDp7iZIqEyD2h"
    9            ? "123456"
    10            : "price_456",
    11            : "456789",
    12        name: "Starter",
    13        description: "Perfect for small projects",
    14        price: 79,
    15        priceAnchor: 99,
    16        features: [
    17          {
    18            name: "NextJS boilerplate",
    19          },
    20          { name: "User oauth" },
    21          { name: "Database" },
    22          { name: "Emails" },
    23        ],
    24      },
    25      {
    26        isFeatured: true,
    27        priceId:
    28        variantId:
    29          process.env.NODE_ENV === "development"
    30            ? "price_1Niyy5AxyNprDp7iZIqEyD2h"
    31            ? "123456"
    32            : "price_456",
    33            : "456789",
    34        name: "Advanced",
    35        description: "You need more power",
    36        price: 99,
    37        priceAnchor: 149,
    38        features: [
    39          {
    40            name: "NextJS boilerplate",
    41          },
    42          { name: "User oauth" },
    43          { name: "Database" },
    44          { name: "Emails" },
    45          { name: "1 year of updates" },
    46          { name: "24/7 support" },
    47        ],
    48      },
    49    ],
    50  },
  • Update your /types/config.ts file
  • /types/config.ts

    1export interface ConfigProps {
    2  appName: string;
    3  appDescription: string;
    4  domainName: string;
    5  crisp: {
    6    id?: string,
    7    onlyShowOnRoutes?: string[],
    8  };
    9  stripe: {
    10  lemonsqueezy: {
    11    plans: {
    12      isFeatured?: boolean,
    13      priceId: string,
    14      variantId: string,
    15      name: string,
    16      description?: string,
    17      price: number,
    18      priceAnchor?: number,
    19      features: {
    20        name: string,
    21      }[],
    22    }[],
    23  };
    24  aws?: {
    25    bucket?: string,
    26    bucketUrl?: string,
    27    cdn?: string,
    28  };
    29  mailgun: {
    30    subdomain: string,
    31    fromNoReply: string,
    32    fromAdmin: string,
    33    supportEmail?: string,
    34    forwardRepliesTo?: string,
    35  };
    36  colors: {
    37    theme: Theme,
    38    main: string,
    39  };
    40  auth: {
    41    loginUrl: string,
    42    callbackUrl: string,
    43  };
    44}
  • Update your /models/User.ts file
  • /models/User.ts

    1import mongoose from "mongoose";
    2import toJSON from "./plugins/toJSON";
    3
    4const userSchema = mongoose.Schema(
    5  {
    6    name: {
    7      type: String,
    8      trim: true,
    9    },
    10    email: {
    11      type: String,
    12      trim: true,
    13      lowercase: true,
    14      private: true,
    15    },
    16    image: {
    17      type: String,
    18    },
    19    customerId: {
    20      type: String,
    21      validate(value) {
    22        return value.includes("cus_");
    23      },
    24    },
    25    priceId:
    26    variantId: {
    27      type: String,
    28      validate(value) {
    29        return value.includes("price_");
    30      },
    31    },
    32    hasAccess: {
    33      type: Boolean,
    34      default: false,
    35    },
    36  },
    37  {
    38    timestamps: true,
    39    toJSON: { virtuals: true },
    40  }
    41);
    42
    43userSchema.plugin(toJSON);
    44
    45export default mongoose.models.User || mongoose.model("User", userSchema);
  • Update your /components/Pricing.tsx file
  • /components/Pricing.tsx

    15<div className="relative flex justify-center flex-col lg:flex-row items-center lg:items-stretch gap-8">
    16  {config.stripe.plans.map((plan) => (
    17    <div key={plan.priceId} className="relative w-full max-w-lg">
    18  {config.lemonsqueezy.plans.map((plan) => (
    19    <div key={plan.variantId} className="relative w-full max-w-lg">
    20      {plan.isFeatured && (
    21        <div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
    22          <span
    23            className={`badge text-xs text-primary-content font-semibold border-0 bg-primary`}
    24          >
    25                  POPULAR
    26                </span>
    27            </div>
    28      )}
    29
    30            {plan.isFeatured && (
    31              <div
    32                className={`absolute -inset-[1px] rounded-[9px] bg-primary z-10`}
    33              ></div>
    34            )}
    35
    36            <div className="relative flex flex-col h-full gap-5 lg:gap-8 z-10 bg-base-100 p-8 rounded-lg">
    37              <div className="flex justify-between items-center gap-4">
    38                <div>
    39                  <p className="text-lg lg:text-xl font-bold">{plan.name}</p>
    40                  {plan.description && (
    41                    <p className="text-base-content/80 mt-2">{plan.description}</p>
    42                  )}
    43                </div>
    44              </div>
    45              <div className="flex gap-2">
    46                {plan.priceAnchor && (
    47                  <div className="flex flex-col justify-end mb-[4px] text-lg ">
    48                    <p className="relative">
    49                      <span className="absolute bg-base-content h-[1.5px] inset-x-0 top-[53%]"></span>
    50                      <span className="text-base-content/80">
    51                        ${plan.priceAnchor}
    52                      </span>
    53                    </p>
    54                  </div>
    55                )}
    56                <p className={`text-5xl tracking-tight font-extrabold`}>
    57                  ${plan.price}
    58                </p>
    59                <div className="flex flex-col justify-end mb-[4px]">
    60                  <p className="text-xs text-base-content/60 uppercase font-semibold">
    61                    USD
    62                  </p>
    63                </div>
    64              </div>
    65              {plan.features && (
    66                <ul className="space-y-2.5 leading-relaxed text-base flex-1">
    67                  {plan.features.map((feature, i) => (
    68                    <li key={i} className="flex items-center gap-2">
    69                      <svg
    70                        xmlns="http://www.w3.org/2000/svg"
    71                        viewBox="0 0 20 20"
    72                        fill="currentColor"
    73                        className="w-[18px] h-[18px] opacity-80 shrink-0"
    74                      >
    75                        <path
    76                          fillRule="evenodd"
    77                          d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
    78                          clipRule="evenodd"
    79                        />
    80                      </svg>
    81
    82                      <span>{feature.name} </span>
    83                    </li>
    84                  ))}
    85                </ul>
    86              )}
    87              <div className="space-y-2">
    88                <ButtonCheckout priceId={plan.priceId} />
    89                <ButtonCheckout variantId={plan.variantId} />
    90
    91                <p className="flex items-center justify-center gap-2 text-sm text-center text-base-content/80 font-medium relative">
    92                  Pay once. Access forever.
    93                </p>
    94              </div>
    95            </div>
    96          </div>
    97  ))}
    98</div >; 
  • Update your /components/ButtonCheckout.tsx file
  • /components/ButtonCheckout.tsx

    1"use client";
    2
    3import { useState } from "react";
    4import apiClient from "@/libs/api";
    5import config from "@/config";
    6
    7const ButtonCheckout = ({ priceId, mode = "payment" }: { priceId: string; mode?: "payment" | "subscription"; }) => {
    8const ButtonCheckout = ({ variantId }: { variantId: string }) => {
    9  const [isLoading, setIsLoading] = useState(false);
    10
    11  const handlePayment = async () => {
    12    setIsLoading(true);
    13
    14    try {
    15	  const { url }: { url: string } = await apiClient.post("/stripe/create-checkout", {
    16        priceId,
    17        mode,
    18        successUrl: window.location.href,
    19        cancelUrl: window.location.href,
    20      });
    21      window.location.href = res.url;
    22      const { url }: { url: string } = await apiClient.post(
    23        "/lemonsqueezy/create-checkout",
    24        {
    25          variantId,
    26          redirectUrl: window.location.href,
    27        }
    28      );
    29      window.location.href = url;
    30    } catch (e) {
    31      console.error(e);
    32    }
    33
    34    setIsLoading(false);
    35  };
    36
    37  return (
    38    <button
    39      className="btn btn-primary btn-block group"
    40      onClick={() => handlePayment()}
    41    >
    42      {isLoading ? (
    43        <span className="loading loading-spinner loading-xs"></span>
    44      ) : (
    45        <svg
    46          className="w-5 h-5 fill-primary-content group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-200"
    47          viewBox="0 0 375 509"
    48          fill="none"
    49          xmlns="http://www.w3.org/2000/svg"
    50        >
    51          <path d="M249.685 14.125C249.685 11.5046 248.913 8.94218 247.465 6.75675C246.017 4.57133 243.957 2.85951 241.542 1.83453C239.126 0.809546 236.463 0.516683 233.882 0.992419C231.301 1.46815 228.917 2.69147 227.028 4.50999L179.466 50.1812C108.664 118.158 48.8369 196.677 2.11373 282.944C0.964078 284.975 0.367442 287.272 0.38324 289.605C0.399039 291.938 1.02672 294.226 2.20377 296.241C3.38082 298.257 5.06616 299.929 7.09195 301.092C9.11775 302.255 11.4133 302.867 13.75 302.869H129.042V494.875C129.039 497.466 129.791 500.001 131.205 502.173C132.62 504.345 134.637 506.059 137.01 507.106C139.383 508.153 142.01 508.489 144.571 508.072C147.131 507.655 149.516 506.503 151.432 504.757L172.698 485.394C247.19 417.643 310.406 338.487 359.975 250.894L373.136 227.658C374.292 225.626 374.894 223.327 374.882 220.99C374.87 218.653 374.243 216.361 373.065 214.341C371.887 212.322 370.199 210.646 368.17 209.482C366.141 208.318 363.841 207.706 361.5 207.707H249.685V14.125Z" />
    52        </svg>
    53      )}
    54      Get {config?.appName}
    55    </button>
    56  );
    57};
    58
    59export default ButtonCheckout;
    60
  • Update your /components/ButtonAccount.tsx file
  • /components/ButtonAccount.tsx

    1/* eslint-disable @next/next/no-img-element */
    2"use client";
    3
    4import { useState } from "react";
    5import { Popover, Transition } from "@headlessui/react";
    6import { useSession, signOut } from "next-auth/react";
    7import apiClient from "@/libs/api";
    8
    9const ButtonAccount = () => {
    10  const { data: session, status } = useSession();
    11  const [isLoading, setIsLoading] = useState(false);
    12
    13  const handleSignOut = () => {
    14    signOut({ callbackUrl: "/" });
    15  };
    16  const handleBilling = async () => {
    17    setIsLoading(true);
    18
    19    try {
    20      const { url }: { url: string } = await apiClient.post("/stripe/create-portal", {
    21        returnUrl: window.location.href,
    22      });
    23      const { url }: { url: string } = await apiClient.post("/lemonsqueezy/create-portal");
    24
    25      window.location.href = url;
    26    } catch (e) {
    27      console.error(e);
    28    }
    29
    30    setIsLoading(false);
    31  };
    32
    33  // Don't show anything if not authenticated (we don't have any info about the user)
    34  if (status === "unauthenticated") return null;
    35
    36  return (
    37    <Popover className="relative z-10">
    38      {({ open }) => (
    39        <>
    40          <Popover.Button className="btn">
    41            {session?.user?.image ? (
    42              <img
    43                src={session?.user?.image}
    44                alt={session?.user?.name || "Account"}
    45                className="w-6 h-6 rounded-full shrink-0"
    46                referrerPolicy="no-referrer"
    47                width={24}
    48                height={24}
    49              />
    50            ) : (
    51              <span className="w-6 h-6 bg-base-300 flex justify-center items-center rounded-full shrink-0">
    52                {session?.user?.name?.charAt(0) ||
    53                  session?.user?.email?.charAt(0)}
    54              </span>
    55            )}
    56
    57            {session?.user?.name || "Account"}
    58
    59            {isLoading ? (
    60              <span className="loading loading-spinner loading-xs"></span>
    61            ) : (
    62              <svg
    63                xmlns="http://www.w3.org/2000/svg"
    64                viewBox="0 0 20 20"
    65                fill="currentColor"
    66                className={`w-5 h-5 duration-200 opacity-50 ${
    67                    open ? "transform rotate-180 " : ""
    68                  }`}
    69              >
    70                <path
    71                  fillRule="evenodd"
    72                  d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    73                  clipRule="evenodd"
    74                />
    75              </svg>
    76            )}
    77          </Popover.Button>
    78          <Transition
    79            enter="transition duration-100 ease-out"
    80            enterFrom="transform scale-95 opacity-0"
    81            enterTo="transform scale-100 opacity-100"
    82            leave="transition duration-75 ease-out"
    83            leaveFrom="transform scale-100 opacity-100"
    84            leaveTo="transform scale-95 opacity-0"
    85          >
    86            <Popover.Panel className="absolute left-0 z-10 mt-3 w-screen max-w-[16rem] transform">
    87              <div className="overflow-hidden rounded-xl shadow-xl ring-1 ring-base-content ring-opacity-5 bg-base-100 p-1">
    88                <div className="space-y-0.5 text-sm">
    89                  <button
    90                    className="flex items-center gap-2 hover:bg-base-300 duration-200 py-1.5 px-4 w-full rounded-lg font-medium"
    91                    onClick={handleBilling}
    92                  >
    93                    <svg
    94                      xmlns="http://www.w3.org/2000/svg"
    95                      viewBox="0 0 20 20"
    96                      fill="currentColor"
    97                      className="w-5 h-5"
    98                    >
    99                      <path
    100                        fillRule="evenodd"
    101                        d="M2.5 4A1.5 1.5 0 001 5.5V6h18v-.5A1.5 1.5 0 0017.5 4h-15zM19 8.5H1v6A1.5 1.5 0 002.5 16h15a1.5 1.5 0 001.5-1.5v-6zM3 13.25a.75.75 0 01.75-.75h1.5a.75.75 0 010 1.5h-1.5a.75.75 0 01-.75-.75zm4.75-.75a.75.75 0 000 1.5h3.5a.75.75 0 000-1.5h-3.5z"
    102                        clipRule="evenodd"
    103                      />
    104                    </svg>
    105                    Billing
    106                  </button>
    107                  <button
    108                    className="flex items-center gap-2 hover:bg-error/20 hover:text-error duration-200 py-1.5 px-4 w-full rounded-lg font-medium"
    109                    onClick={handleSignOut}
    110                  >
    111                    <svg
    112                      xmlns="http://www.w3.org/2000/svg"
    113                      viewBox="0 0 20 20"
    114                      fill="currentColor"
    115                      className="w-5 h-5"
    116                    >
    117                      <path
    118                        fillRule="evenodd"
    119                        d="M3 4.25A2.25 2.25 0 015.25 2h5.5A2.25 2.25 0 0113 4.25v2a.75.75 0 01-1.5 0v-2a.75.75 0 00-.75-.75h-5.5a.75.75 0 00-.75.75v11.5c0 .414.336.75.75.75h5.5a.75.75 0 00.75-.75v-2a.75.75 0 011.5 0v2A2.25 2.25 0 0110.75 18h-5.5A2.25 2.25 0 013 15.75V4.25z"
    120                        clipRule="evenodd"
    121                      />
    122                      <path
    123                        fillRule="evenodd"
    124                        d="M6 10a.75.75 0 01.75-.75h9.546l-1.048-.943a.75.75 0 111.004-1.114l2.5 2.25a.75.75 0 010 1.114l-2.5 2.25a.75.75 0 11-1.004-1.114l1.048-.943H6.75A.75.75 0 016 10z"
    125                        clipRule="evenodd"
    126                      />
    127                    </svg>
    128                    Logout
    129                  </button>
    130                </div>
    131              </div>
    132            </Popover.Panel>
    133          </Transition>
    134        </>
    135      )}
    136    </Popover>
    137  );
    138};
    139
    140export default ButtonAccount;
  • Create a /libs/lemonsqueezy.ts file
  • /libs/lemonsqueezy.ts

    1import {
    2  NewCheckout,
    3  createCheckout,
    4  getCustomer,
    5  lemonSqueezySetup,
    6} from "@lemonsqueezy/lemonsqueezy.js";
    7
    8interface CreateLemonSqueezyCheckoutParams {
    9  variantId: string;
    10  redirectUrl: string;
    11  discountCode?: string;
    12  user?: {
    13    email: string;
    14    name: string;
    15    _id: string;
    16  };
    17}
    18
    19interface CreateCustomerPortalParams {
    20  customerId: string;
    21}
    22
    23// This is used to create a Stripe Checkout for one-time payments. It's usually triggered with the <ButtonCheckout /> component. Webhooks are used to update the user's state in the database.
    24export const createLemonSqueezyCheckout = async ({
    25  user,
    26  redirectUrl,
    27  variantId,
    28  discountCode,
    29}: CreateLemonSqueezyCheckoutParams): Promise<string> => {
    30  try {
    31    lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY });
    32
    33    const storeId = process.env.LEMONSQUEEZY_STORE_ID;
    34
    35    const newCheckout: NewCheckout = {
    36      productOptions: {
    37        redirectUrl,
    38      },
    39      checkoutData: {
    40        discountCode,
    41        email: user?.email,
    42        name: user?.name,
    43        custom: {
    44          userId: user?._id.toString(),
    45        },
    46      },
    47    };
    48
    49    const { data, error } = await createCheckout(
    50      storeId,
    51      variantId,
    52      newCheckout
    53    );
    54
    55    if (error) {
    56      throw error;
    57    }
    58
    59    return data.data.attributes.url;
    60  } catch (e) {
    61    console.error(e);
    62    return null;
    63  }
    64};
    65
    66// This is used to create Customer Portal sessions, so users can manage their subscriptions (payment methods, cancel, etc..)
    67export const createCustomerPortal = async ({
    68  customerId,
    69}: CreateCustomerPortalParams): Promise<string> => {
    70  try {
    71    lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY });
    72
    73    const { data, error } = await getCustomer(customerId);
    74
    75    if (error) {
    76      throw error;
    77    }
    78
    79    return data.data.attributes.urls.customer_portal;
    80  } catch (error) {
    81    console.error(error);
    82    return null;
    83  }
    84};
  • Create an API route for creating a Checkout session
  • /app/api/lemonsqueezy/create-checkout/route.ts

    1import { createLemonSqueezyCheckout } from "@/libs/lemonsqueezy";
    2import connectMongo from "@/libs/mongoose";
    3import { authOptions } from "@/libs/next-auth";
    4import User from "@/models/User";
    5import { getServerSession } from "next-auth/next";
    6import { NextRequest, NextResponse } from "next/server";
    7
    8// This function is used to create a Stripe Checkout Session (one-time payment or subscription)
    9// It's called by the <ButtonCheckout /> component
    10// By default, it doesn't force users to be authenticated. But if they are, it will prefill the Checkout data with their email and/or credit card
    11export async function POST(req: NextRequest) {
    12  const body = await req.json();
    13
    14  if (!body.variantId) {
    15    return NextResponse.json(
    16      { error: "Variant ID is required" },
    17      { status: 400 }
    18    );
    19  } else if (!body.redirectUrl) {
    20    return NextResponse.json(
    21      { error: "Redirect URL is required" },
    22      { status: 400 }
    23    );
    24  }
    25
    26  try {
    27    const session = await getServerSession(authOptions);
    28
    29    await connectMongo();
    30
    31    const user = await User.findById(session?.user?.id);
    32
    33    const { variantId, redirectUrl } = body;
    34
    35    const checkoutURL = await createLemonSqueezyCheckout({
    36      variantId,
    37      redirectUrl,
    38      // If user is logged in, this will automatically prefill Checkout data like email and/or credit card for faster checkout
    39      user,
    40      // If you send coupons from the frontend, you can pass it here
    41      // discountCode: body.discountCode,
    42    });
    43
    44    return NextResponse.json({ url: checkoutURL });
    45  } catch (e) {
    46    console.error(e);
    47    return NextResponse.json({ error: e?.message }, { status: 500 });
    48  }
    49}
    50
  • Create an API route for creating a Customer Portal session
  • /app/api/lemonsqueezy/create-portal/route.ts

    1import { NextResponse } from "next/server";
    2import { getServerSession } from "next-auth/next";
    3import { authOptions } from "@/libs/next-auth";
    4import connectMongo from "@/libs/mongoose";
    5import User from "@/models/User";
    6import { createCustomerPortal } from "@/libs/lemonsqueezy";
    7
    8export async function POST() {
    9  const session = await getServerSession(authOptions);
    10
    11  if (session) {
    12    try {
    13      await connectMongo();
    14
    15      const { id } = session.user;
    16
    17      const user = await User.findById(id);
    18
    19      if (!user?.customerId) {
    20        return NextResponse.json(
    21          {
    22            error:
    23              "You don't have a billing account yet. Make a purchase first.",
    24          },
    25          { status: 400 }
    26        );
    27      }
    28
    29      const url = await createCustomerPortal({
    30        customerId: user.customerId,
    31      });
    32
    33      return NextResponse.json({
    34        url,
    35      });
    36    } catch (e) {
    37      console.error(e);
    38      return NextResponse.json({ error: e?.message }, { status: 500 });
    39    }
    40  } else {
    41  // Not Signed in
    42  return NextResponse.json({ error: "Not signed in" }, { status: 401 });
    43  }
    44}
  • Add your webhook URL on Settings > Webhooks
  • Create an API route for your Lemon Sqeeuzy webhook
  • /app/api/webhook/lemonsqueezy/route.ts

    1import { NextResponse, NextRequest } from "next/server";
    2import { headers } from "next/headers";
    3import connectMongo from "@/libs/mongoose";
    4import crypto from "crypto";
    5import config from "@/config";
    6import User from "@/models/User";
    7
    8// This is where we receive LemonSqueezy webhook events
    9// It used to update the user data, send emails, etc...
    10// By default, it'll store the user in the database
    11// See more: https://shipfa.st/docs/features/payments
    12export async function POST(req: NextRequest) {
    13  const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
    14  if (!secret) {
    15    return new Response("LEMONSQUEEZY_SIGNING_SECRET is required.", {
    16      status: 400,
    17    });
    18  }
    19
    20  await connectMongo();
    21
    22  // Verify the signature
    23  const text = await req.text();
    24
    25  const hmac = crypto.createHmac("sha256", secret);
    26  const digest = Buffer.from(hmac.update(text).digest("hex"), "utf8");
    27  const signature = Buffer.from(headers().get("x-signature"), "utf8");
    28
    29  if (!crypto.timingSafeEqual(digest, signature)) {
    30    return new Response("Invalid signature.", {
    31      status: 400,
    32    });
    33  }
    34
    35  // Get the payload
    36  const payload = JSON.parse(text);
    37
    38  const eventName = payload.meta.event_name;
    39  const customerId = payload.data.attributes.customer_id.toString();
    40
    41  try {
    42    switch (eventName) {
    43      case "order_created": {
    44        // ✅ Grant access to the product
    45        const userId = payload.meta?.custom_data?.userId;
    46
    47        const email = payload.data.attributes.user_email;
    48        const name = payload.data.attributes.user_name;
    49        const variantId =
    50          payload.data.attributes.first_order_item.variant_id.toString();
    51
    52        const plan = config.lemonsqueezy.plans.find(
    53          (p) => p.variantId === variantId
    54        );
    55        if (!plan) {
    56          throw new Error("Plan not found for variantId:", variantId);
    57        }
    58
    59        let user;
    60
    61        // Get or create the user. userId is normally pass in the checkout session (clientReferenceID) to identify the user when we get the webhook event
    62        if (userId) {
    63          user = await User.findById(userId);
    64        } else if (email) {
    65          user = await User.findOne({ email });
    66
    67          if (!user) {
    68            user = await User.create({
    69              email,
    70              name,
    71            });
    72
    73            await user.save();
    74          }
    75        } else {
    76          throw new Error("No user found");
    77        }
    78
    79        // Update user data + Grant user access to your product. It's a boolean in the database, but could be a number of credits, etc...
    80        user.variantId = variantId;
    81        user.customerId = customerId;
    82        user.hasAccess = true;
    83        await user.save();
    84
    85        // Extra: send email with user link, product page, etc...
    86        // try {
    87        //   await sendEmail(...);
    88        // } catch (e) {
    89        //   console.error("Email issue:" + e?.message);
    90        // }
    91
    92        break;
    93      }
    94
    95      case "subscription_cancelled": {
    96        // The customer subscription stopped
    97        // ❌ Revoke access to the product
    98
    99        const user = await User.findOne({ customerId });
    100
    101        // Revoke access to your product
    102        user.hasAccess = false;
    103        await user.save();
    104
    105        break;
    106      }
    107
    108      default:
    109      // Unhandled event type
    110    }
    111
    112  } catch (e) {
    113    console.error("lemonsqueezy error: ", e.message);
    114  }
    115
    116  return NextResponse.json({});
    117}

Supabase Setup

  • Install the package
  • Terminal

    1npm install @lemonsqueezy/lemonsqueezy.js
  • Add the environment variables
  • .env.local

    1LEMONSQUEEZY_API_KEY=
    2LEMONSQUEEZY_STORE_ID=
    3LEMONSQUEEZY_SIGNING_SECRET=
    • Create a new API key from Settings > API
    • Find your store ID from Settings > Stores
    • Just copy the number, without the #

    • Generate a random string for the signing secret
  • Create a new product from Settings > Products. Make sure to add a variant, even if you plan to have a single price.
  • You can get the variantId by selecting the variant and then copying the number from the URL. It follows the convention: https://app.lemonsqueezy.com/product/[productId]/variants/[variantId].
  • Update your config
  • /config.ts

    1stripe: {
    2lemonsqueezy: {
    3    plans: [
    4      {
    5        priceId:
    6        variantId:
    7          process.env.NODE_ENV === "development"
    8            ? "price_1Niyy5AxyNprDp7iZIqEyD2h"
    9            ? "123456"
    10            : "price_456",
    11            : "456789",
    12        name: "Starter",
    13        description: "Perfect for small projects",
    14        price: 79,
    15        priceAnchor: 99,
    16        features: [
    17          {
    18            name: "NextJS boilerplate",
    19          },
    20          { name: "User oauth" },
    21          { name: "Database" },
    22          { name: "Emails" },
    23        ],
    24      },
    25      {
    26        isFeatured: true,
    27        priceId:
    28        variantId:
    29          process.env.NODE_ENV === "development"
    30            ? "price_1Niyy5AxyNprDp7iZIqEyD2h"
    31            ? "123456"
    32            : "price_456",
    33            : "456789",
    34        name: "Advanced",
    35        description: "You need more power",
    36        price: 99,
    37        priceAnchor: 149,
    38        features: [
    39          {
    40            name: "NextJS boilerplate",
    41          },
    42          { name: "User oauth" },
    43          { name: "Database" },
    44          { name: "Emails" },
    45          { name: "1 year of updates" },
    46          { name: "24/7 support" },
    47        ],
    48      },
    49    ],
    50  },
  • Update your /components/Pricing.tsx file
  • /components/Pricing.tsx

    15<div className="relative flex justify-center flex-col lg:flex-row items-center lg:items-stretch gap-8">
    16  {config.stripe.plans.map((plan) => (
    17    <div key={plan.priceId} className="relative w-full max-w-lg">
    18  {config.lemonsqueezy.plans.map((plan) => (
    19    <div key={plan.variantId} className="relative w-full max-w-lg">
    20      {plan.isFeatured && (
    21        <div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
    22          <span
    23            className={`badge text-xs text-primary-content font-semibold border-0 bg-primary`}
    24          >
    25                  POPULAR
    26                </span>
    27            </div>
    28      )}
    29
    30            {plan.isFeatured && (
    31              <div
    32                className={`absolute -inset-[1px] rounded-[9px] bg-primary z-10`}
    33              ></div>
    34            )}
    35
    36            <div className="relative flex flex-col h-full gap-5 lg:gap-8 z-10 bg-base-100 p-8 rounded-lg">
    37              <div className="flex justify-between items-center gap-4">
    38                <div>
    39                  <p className="text-lg lg:text-xl font-bold">{plan.name}</p>
    40                  {plan.description && (
    41                    <p className="text-base-content/80 mt-2">{plan.description}</p>
    42                  )}
    43                </div>
    44              </div>
    45              <div className="flex gap-2">
    46                {plan.priceAnchor && (
    47                  <div className="flex flex-col justify-end mb-[4px] text-lg ">
    48                    <p className="relative">
    49                      <span className="absolute bg-base-content h-[1.5px] inset-x-0 top-[53%]"></span>
    50                      <span className="text-base-content/80">
    51                        ${plan.priceAnchor}
    52                      </span>
    53                    </p>
    54                  </div>
    55                )}
    56                <p className={`text-5xl tracking-tight font-extrabold`}>
    57                  ${plan.price}
    58                </p>
    59                <div className="flex flex-col justify-end mb-[4px]">
    60                  <p className="text-xs text-base-content/60 uppercase font-semibold">
    61                    USD
    62                  </p>
    63                </div>
    64              </div>
    65              {plan.features && (
    66                <ul className="space-y-2.5 leading-relaxed text-base flex-1">
    67                  {plan.features.map((feature, i) => (
    68                    <li key={i} className="flex items-center gap-2">
    69                      <svg
    70                        xmlns="http://www.w3.org/2000/svg"
    71                        viewBox="0 0 20 20"
    72                        fill="currentColor"
    73                        className="w-[18px] h-[18px] opacity-80 shrink-0"
    74                      >
    75                        <path
    76                          fillRule="evenodd"
    77                          d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
    78                          clipRule="evenodd"
    79                        />
    80                      </svg>
    81
    82                      <span>{feature.name} </span>
    83                    </li>
    84                  ))}
    85                </ul>
    86              )}
    87              <div className="space-y-2">
    88                <ButtonCheckout priceId={plan.priceId} />
    89                <ButtonCheckout variantId={plan.variantId} />
    90
    91                <p className="flex items-center justify-center gap-2 text-sm text-center text-base-content/80 font-medium relative">
    92                  Pay once. Access forever.
    93                </p>
    94              </div>
    95            </div>
    96          </div>
    97  ))}
    98</div >; 
  • Update your /components/ButtonCheckout.tsx file
  • /components/ButtonCheckout.tsx

    1"use client";
    2
    3import { useState } from "react";
    4import apiClient from "@/libs/api";
    5import config from "@/config";
    6
    7const ButtonCheckout = ({ priceId, mode = "payment" }: { priceId: string; mode?: "payment" | "subscription"; }) => {
    8const ButtonCheckout = ({ variantId }: { variantId: string }) => {
    9  const [isLoading, setIsLoading] = useState(false);
    10
    11  const handlePayment = async () => {
    12    setIsLoading(true);
    13
    14    try {
    15	  const { url }: { url: string } = await apiClient.post("/stripe/create-checkout", {
    16        priceId,
    17        mode,
    18        successUrl: window.location.href,
    19        cancelUrl: window.location.href,
    20      });
    21      window.location.href = res.url;
    22      const { url }: { url: string } = await apiClient.post(
    23        "/lemonsqueezy/create-checkout",
    24        {
    25          variantId,
    26          redirectUrl: window.location.href,
    27        }
    28      );
    29      window.location.href = url;
    30    } catch (e) {
    31      console.error(e);
    32    }
    33
    34    setIsLoading(false);
    35  };
    36
    37  return (
    38    <button
    39      className="btn btn-primary btn-block group"
    40      onClick={() => handlePayment()}
    41    >
    42      {isLoading ? (
    43        <span className="loading loading-spinner loading-xs"></span>
    44      ) : (
    45        <svg
    46          className="w-5 h-5 fill-primary-content group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-200"
    47          viewBox="0 0 375 509"
    48          fill="none"
    49          xmlns="http://www.w3.org/2000/svg"
    50        >
    51          <path d="M249.685 14.125C249.685 11.5046 248.913 8.94218 247.465 6.75675C246.017 4.57133 243.957 2.85951 241.542 1.83453C239.126 0.809546 236.463 0.516683 233.882 0.992419C231.301 1.46815 228.917 2.69147 227.028 4.50999L179.466 50.1812C108.664 118.158 48.8369 196.677 2.11373 282.944C0.964078 284.975 0.367442 287.272 0.38324 289.605C0.399039 291.938 1.02672 294.226 2.20377 296.241C3.38082 298.257 5.06616 299.929 7.09195 301.092C9.11775 302.255 11.4133 302.867 13.75 302.869H129.042V494.875C129.039 497.466 129.791 500.001 131.205 502.173C132.62 504.345 134.637 506.059 137.01 507.106C139.383 508.153 142.01 508.489 144.571 508.072C147.131 507.655 149.516 506.503 151.432 504.757L172.698 485.394C247.19 417.643 310.406 338.487 359.975 250.894L373.136 227.658C374.292 225.626 374.894 223.327 374.882 220.99C374.87 218.653 374.243 216.361 373.065 214.341C371.887 212.322 370.199 210.646 368.17 209.482C366.141 208.318 363.841 207.706 361.5 207.707H249.685V14.125Z" />
    52        </svg>
    53      )}
    54      Get {config?.appName}
    55    </button>
    56  );
    57};
    58
    59export default ButtonCheckout;
    60
  • Update your /components/ButtonAccount.tsx file
  • /components/ButtonAccount.tsx

    1/* eslint-disable @next/next/no-img-element */
    2"use client";
    3
    4import { useState, useEffect } from "react";
    5import { Popover, Transition } from "@headlessui/react";
    6import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
    7import apiClient from "@/libs/api";
    8
    9const ButtonAccount = () => {
    10  const supabase = createClientComponentClient();
    11  const [isLoading, setIsLoading] = useState(false);
    12  const [user, setUser] = useState(null);
    13
    14  useEffect(() => {
    15    const getUser = async () => {
    16      const { data } = await supabase.auth.getUser();
    17
    18      setUser(data.user);
    19    };
    20
    21    getUser();
    22  }, [supabase]);
    23
    24  const handleSignOut = async () => {
    25    await supabase.auth.signOut();
    26    window.location.href = "/";
    27  };
    28
    29  const handleBilling = async () => {
    30    setIsLoading(true);
    31
    32    try {
    33      const { url }: { url: string } = await apiClient.post("/stripe/create-portal", {
    34        returnUrl: window.location.href,
    35      });
    36      const { url }: { url: string } = await apiClient.post("/lemonsqueezy/create-portal");
    37
    38      window.location.href = url;
    39    } catch (e) {
    40      console.error(e);
    41    }
    42
    43    setIsLoading(false);
    44  };
    45
    46  return (
    47    <Popover className="relative z-10">
    48      {({ open }) => (
    49        <>
    50          <Popover.Button className="btn">
    51            {user?.user_metadata?.avatar_url ? (
    52              <img
    53                src={user?.user_metadata?.avatar_url}
    54                alt={"Profile picture"}
    55                className="w-6 h-6 rounded-full shrink-0"
    56                referrerPolicy="no-referrer"
    57                width={24}
    58                height={24}
    59              />
    60            ) : (
    61              <span className="w-8 h-8 bg-base-100 flex justify-center items-center rounded-full shrink-0 capitalize">
    62                {user?.email?.charAt(0)}
    63              </span>
    64            )}
    65
    66            {user?.user_metadata?.name ||
    67              user?.email?.split("@")[0] ||
    68              "Account"}
    69
    70            {isLoading ? (
    71              <span className="loading loading-spinner loading-xs"></span>
    72            ) : (
    73              <svg
    74                xmlns="http://www.w3.org/2000/svg"
    75                viewBox="0 0 20 20"
    76                fill="currentColor"
    77                className={`w-5 h-5 duration-200 opacity-50 ${open ? "transform rotate-180 " : ""
    78                  }`}
    79              >
    80                <path
    81                  fillRule="evenodd"
    82                  d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    83                  clipRule="evenodd"
    84                />
    85              </svg>
    86            )}
    87          </Popover.Button>
    88          <Transition
    89            enter="transition duration-100 ease-out"
    90            enterFrom="transform scale-95 opacity-0"
    91            enterTo="transform scale-100 opacity-100"
    92            leave="transition duration-75 ease-out"
    93            leaveFrom="transform scale-100 opacity-100"
    94            leaveTo="transform scale-95 opacity-0"
    95          >
    96            <Popover.Panel className="absolute left-0 z-10 mt-3 w-screen max-w-[16rem] transform">
    97              <div className="overflow-hidden rounded-xl shadow-xl ring-1 ring-base-content ring-opacity-5 bg-base-100 p-1">
    98                <div className="space-y-0.5 text-sm">
    99                  <button
    100                    className="flex items-center gap-2 hover:bg-base-300 duration-200 py-1.5 px-4 w-full rounded-lg font-medium"
    101                    onClick={handleBilling}
    102                  >
    103                    <svg
    104                      xmlns="http://www.w3.org/2000/svg"
    105                      viewBox="0 0 20 20"
    106                      fill="currentColor"
    107                      className="w-5 h-5"
    108                    >
    109                      <path
    110                        fillRule="evenodd"
    111                        d="M2.5 4A1.5 1.5 0 001 5.5V6h18v-.5A1.5 1.5 0 0017.5 4h-15zM19 8.5H1v6A1.5 1.5 0 002.5 16h15a1.5 1.5 0 001.5-1.5v-6zM3 13.25a.75.75 0 01.75-.75h1.5a.75.75 0 010 1.5h-1.5a.75.75 0 01-.75-.75zm4.75-.75a.75.75 0 000 1.5h3.5a.75.75 0 000-1.5h-3.5z"
    112                        clipRule="evenodd"
    113                      />
    114                    </svg>
    115                    Billing
    116                  </button>
    117                  <button
    118                    className="flex items-center gap-2 hover:bg-error/20 hover:text-error duration-200 py-1.5 px-4 w-full rounded-lg font-medium"
    119                    onClick={handleSignOut}
    120                  >
    121                    <svg
    122                      xmlns="http://www.w3.org/2000/svg"
    123                      viewBox="0 0 20 20"
    124                      fill="currentColor"
    125                      className="w-5 h-5"
    126                    >
    127                      <path
    128                        fillRule="evenodd"
    129                        d="M3 4.25A2.25 2.25 0 015.25 2h5.5A2.25 2.25 0 0113 4.25v2a.75.75 0 01-1.5 0v-2a.75.75 0 00-.75-.75h-5.5a.75.75 0 00-.75.75v11.5c0 .414.336.75.75.75h5.5a.75.75 0 00.75-.75v-2a.75.75 0 011.5 0v2A2.25 2.25 0 0110.75 18h-5.5A2.25 2.25 0 013 15.75V4.25z"
    130                        clipRule="evenodd"
    131                      />
    132                      <path
    133                        fillRule="evenodd"
    134                        d="M6 10a.75.75 0 01.75-.75h9.546l-1.048-.943a.75.75 0 111.004-1.114l2.5 2.25a.75.75 0 010 1.114l-2.5 2.25a.75.75 0 11-1.004-1.114l1.048-.943H6.75A.75.75 0 016 10z"
    135                        clipRule="evenodd"
    136                      />
    137                    </svg>
    138                    Logout
    139                  </button>
    140                </div>
    141              </div>
    142            </Popover.Panel>
    143          </Transition>
    144        </>
    145      )}
    146    </Popover>
    147  );
    148};
    149
    150export default ButtonAccount;
    151
  • Create a /libs/lemonsqueezy.ts file
  • /libs/lemonsqueezy.ts

    1import {
    2    createCheckout,
    3    getCustomer,
    4    lemonSqueezySetup,
    5} from "@lemonsqueezy/lemonsqueezy.js";
    6
    7// This is used to create a Lemon Squeezy Checkout for one-time payments. It's usually triggered with the <ButtonCheckout /> component. Webhooks are used to update the user's state in the database.
    8export const createLemonSqueezyCheckout = async ({
    9    user,
    10    redirectUrl,
    11    variantId,
    12    discountCode,
    13}) => {
    14    try {
    15        lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY });
    16
    17        const storeId = process.env.LEMONSQUEEZY_STORE_ID;
    18
    19        const newCheckout = {
    20            productOptions: {
    21                redirectUrl,
    22            },
    23            checkoutData: {
    24                discountCode,
    25                email: user?.email,
    26                name: user?.name,
    27                custom: {
    28                    userId: user?.id.toString(),
    29                },
    30            },
    31        };
    32
    33        const { data, error } = await createCheckout(
    34            storeId,
    35            variantId,
    36            newCheckout
    37        );
    38
    39        if (error) {
    40            throw error;
    41        }
    42
    43        return data.data.attributes.url;
    44    } catch (e) {
    45        console.error(e);
    46        return null;
    47    }
    48};
    49
    50// This is used to create Customer Portal sessions, so users can manage their subscriptions (payment methods, cancel, etc..)
    51export const createCustomerPortal = async ({ customerId }) => {
    52    try {
    53        lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY });
    54
    55        const { data, error } = await getCustomer(customerId);
    56
    57        if (error) {
    58            throw error;
    59        }
    60
    61        return data.data.attributes.urls.customer_portal;
    62    } catch (error) {
    63        console.error(error);
    64        return null;
    65    }
    66};
    67
  • Create an API route for creating a Checkout session
  • /app/api/lemonsqueezy/create-checkout/route.ts

    1import { createLemonSqueezyCheckout } from "@/libs/lemonsqueezy";
    2import { NextRequest, NextResponse } from "next/server";
    3import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
    4import { cookies } from "next/headers";
    5
    6export async function POST(req) {
    7    try {
    8        const cookieStore = cookies();
    9        const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
    10
    11        const {
    12            data: { session },
    13        } = await supabase.auth.getSession();
    14
    15        // User who are not logged in can't make a purchase
    16        if (!session) {
    17            return NextResponse.json(
    18                { error: "You must be logged in to make a purchase." },
    19                { status: 401 }
    20            );
    21        }
    22
    23        const body = await req.json();
    24
    25        const { variantId, redirectUrl } = body;
    26
    27        if (!variantId) {
    28            return NextResponse.json(
    29                { error: "Variant ID is required" },
    30                { status: 400 }
    31            );
    32        } else if (!redirectUrl) {
    33            return NextResponse.json(
    34                { error: "Redirect URL is required" },
    35                { status: 400 }
    36            );
    37        }
    38
    39        // Search for a profile with unique ID equals to the user session ID (in table called 'profiles')
    40        const { data } = await supabase
    41            .from("profiles")
    42            .select("*")
    43            .eq("id", session?.user?.id)
    44            .single();
    45
    46        // If no profile found, create one. This is used to store the Stripe customer ID
    47        if (!data) {
    48            await supabase.from("profiles").insert([
    49                {
    50                    id: session.user.id,
    51                    variant_id: body.variantId,
    52                    email: session?.user?.email,
    53                },
    54            ]);
    55        }
    56
    57        const checkoutURL = await createLemonSqueezyCheckout({
    58            variantId,
    59            redirectUrl,
    60            // If user is logged in, this will automatically prefill Checkout data like email and/or credit card for faster checkout
    61            user: data
    62            // If you send coupons from the frontend, you can pass it here
    63            // discountCode: body.discountCode,
    64        });
    65
    66        return NextResponse.json({ url: checkoutURL });
    67    } catch (e) {
    68        console.error(e);
    69        return NextResponse.json({ error: e?.message }, { status: 500 });
    70    }
    71}
    72
  • Create an API route for creating a Customer Portal session
  • /app/api/lemonsqueezy/create-portal/route.ts

    1import { NextResponse } from "next/server";
    2import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
    3import { cookies } from "next/headers";
    4import { createCustomerPortal } from "@/libs/lemonsqueezy";
    5
    6export async function POST() {
    7    try {
    8        const cookieStore = cookies();
    9        const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
    10
    11        const {
    12            data: { session },
    13        } = await supabase.auth.getSession();
    14
    15        // User who are not logged in can't make a purchase
    16        if (!session) {
    17            return NextResponse.json(
    18                { error: "You must be logged in to view billing information." },
    19                { status: 401 }
    20            );
    21        }
    22
    23        const { data } = await supabase
    24            .from("profiles")
    25            .select("*")
    26            .eq("id", session?.user?.id)
    27            .single();
    28
    29        if (!data?.customer_id) {
    30            return NextResponse.json(
    31                {
    32                    error: "You don't have a billing account yet. Make a purchase first.",
    33                },
    34                { status: 400 }
    35            );
    36        }
    37
    38        const url = await createCustomerPortal({
    39            customerId: data.customer_id,
    40        });
    41
    42        return NextResponse.json({
    43            url,
    44        });
    45    } catch (e) {
    46        console.error(e);
    47        return NextResponse.json({ error: e?.message }, { status: 500 });
    48    }
    49}
    50
  • Add your webhook URL on Settings > Webhooks
  • Create an API route for your Lemon Sqeeuzy webhook
  • /app/api/webhook/lemonsqueezy/route.ts

    1import { NextRequest, NextResponse } from "next/server";
    2import { headers } from "next/headers";
    3import crypto from "crypto";
    4import { SupabaseClient } from "@supabase/supabase-js";
    5import configFile from "@/config";
    6
    7// This is where we receive LemonSqueezy webhook events
    8// It used to update the user data, send emails, etc...
    9// By default, it'll store the user in the database
    10// See more: https://shipfa.st/docs/features/payments
    11export async function POST(req) {
    12    const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
    13    if (!secret) {
    14        return new Response("LEMONSQUEEZY_SIGNING_SECRET is required.", {
    15            status: 400,
    16        });
    17    }
    18
    19    // Verify the signature
    20    const text = await req.text();
    21
    22    const hmac = crypto.createHmac("sha256", secret);
    23    const digest = Buffer.from(hmac.update(text).digest("hex"), "utf8");
    24    const signature = Buffer.from(headers().get("x-signature"), "utf8");
    25
    26    if (!crypto.timingSafeEqual(digest, signature)) {
    27        return new Response("Invalid signature.", {
    28            status: 400,
    29        });
    30    }
    31
    32    // Get the payload
    33    const payload = JSON.parse(text);
    34
    35    const eventName = payload.meta.event_name;
    36    const customerId = payload.data.attributes.customer_id.toString();
    37
    38    // Create a private supabase client using the secret service_role API key
    39    const supabase = new SupabaseClient(
    40        process.env.NEXT_PUBLIC_SUPABASE_URL,
    41        process.env.SUPABASE_SERVICE_ROLE_KEY
    42    );
    43
    44    try {
    45        switch (eventName) {
    46            case "order_created": {
    47                // First payment is successful and a subscription is created (if mode was set to "subscription" in ButtonCheckout)
    48                // ✅ Grant access to the product
    49                const userId = payload.meta?.custom_data?.userId;
    50
    51                const email = payload.data.attributes.user_email;
    52                const name = payload.data.attributes.user_name;
    53                const variantId =
    54                    payload.data.attributes.first_order_item.variant_id.toString();
    55
    56                const plan = configFile.lemonsqueezy.plans.find(
    57                    (p) => p.variantId === variantId
    58                );
    59                if (!plan) {
    60                    throw new Error("Plan not found for variantId:", variantId);
    61                }
    62
    63                // Update the profile where id equals the userId (in table called 'profiles') and update the customer_id, price_id, and has_access (provisioning)
    64                await supabase
    65                    .from("profiles")
    66                    .update({
    67                        name: name,
    68                        email: email,
    69                        customer_id: customerId,
    70                        variant_id: variantId,
    71                        has_access: true,
    72                    })
    73                    .eq("id", userId);
    74
    75                // Extra: send email with user link, product page, etc...
    76                // try {
    77                //   await sendEmail({to: ...});
    78                // } catch (e) {
    79                //   console.error("Email issue:" + e?.message);
    80                // }
    81
    82                break;
    83            }
    84
    85            case "subscription_cancelled": {
    86                // The customer subscription stopped
    87                // ❌ Revoke access to the product
    88
    89
    90                await supabase
    91                    .from("profiles")
    92                    .update({ has_access: false })
    93                    .eq("customer_id", customerId);
    94
    95                break;
    96            }
    97
    98            default:
    99            // Unhandled event type
    100        }
    101    } catch (e) {
    102        console.error("lemonsqueezy error: ", e.message);
    103    }
    104
    105    return NextResponse.json({});
    106}
    107