[ ] Install the package
npm install @lemonsqueezy/lemonsqueezy.js
[ ] Add the environment variables in your .env.local
file
[ ] Create a new API key from Settings > API
[ ] Find your store ID from Settings > Stores
<aside> 💡 Just copy the number, without the #
</aside>
[ ] Generate a random string for the signing secret
LEMONSQUEEZY_API_KEY=
LEMONSQUEEZY_STORE_ID=
LEMONSQUEEZY_SIGNING_SECRET=
[ ] Create a new product from Store > Products. Make sure to add a variant, even if you plan to have a single price.
<aside>
💡 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]
.
</aside>
[ ] Add the following config in your config.js
file:
lemonsqueezy: {
// Create a product and add multiple variants via your Lemon Squeezy dashboard, then add them here. You can add as many plans as you want, just make sure to add the variantId.
plans: [
{
// REQUIRED — we use this to find the plan in the webhook (for instance if you want to update the user's credits based on the plan)
variantId:
process.env.NODE_ENV === "development"
? "123456"
: "456789",
// REQUIRED - Name of the plan, displayed on the pricing page
name: "Starter",
// A friendly description of the plan, displayed on the pricing page. Tip: explain why this plan and not others.
description: "Perfect for small projects",
// The price you want to display, the one user will be charged on Lemon Squeezy
price: 99,
// If you have an anchor price (i.e. $149) that you want to display crossed out, put it here. Otherwise, leave it empty.
priceAnchor: 149,
features: [
{
name: "NextJS boilerplate",
},
{ name: "User oauth" },
{ name: "Database" },
{ name: "Emails" },
],
},
{
variantId:
process.env.NODE_ENV === "development"
? "123456"
: "456789",
// This plan will look different on the pricing page, it will be highlighted. You can only have one plan with isFeatured: true.
isFeatured: true,
name: "Advanced",
description: "You need more power",
price: 149,
priceAnchor: 299,
features: [
{
name: "NextJS boilerplate",
},
{ name: "User oauth" },
{ name: "Database" },
{ name: "Emails" },
{ name: "1 year of updates" },
{ name: "24/7 support" },
],
},
],
},
[ ] [Skip if using JavaScript] If you’re using TypeScript, add the following inside the ConfigProps
interface in the types/config.ts
file:
lemonsqueezy: {
plans: {
isFeatured?: boolean;
variantId: string;
name: string;
description?: string;
price: number;
priceAnchor?: number;
features: {
name: string;
}[];
}[];
};
[ ] Replace the content of models/User.js
file with the following:
import mongoose from "mongoose";
import toJSON from "./plugins/toJSON";
// USER SCHEMA
const userSchema = new mongoose.Schema(
{
name: {
type: String,
trim: true,
},
email: {
type: String,
trim: true,
lowercase: true,
private: true,
},
image: {
type: String,
},
// Used in the Stripe webhook to identify the user in Stripe and later create Customer Portal or prefill user credit card details
customerId: {
type: String,
},
// Used in the Stripe webhook. should match a plan in config.js file.
variantId: {
type: String,
},
// Used to determine if the user has access to the product—it's turn on/off by the Stripe webhook
hasAccess: {
type: Boolean,
default: false,
},
},
{
timestamps: true,
toJSON: { virtuals: true },
}
);
// add plugin that converts mongoose to json
userSchema.plugin(toJSON);
export default mongoose.models.User || mongoose.model("User", userSchema);
[ ] Replace the content of components/Pricing.jsx
file with the following:
import config from "@/config";
import ButtonCheckout from "./ButtonCheckout";
// <Pricing/> displays the pricing plans for your app
// It's your Lemon Squeezy config in config.js (config.lemonsqueezy.plans[]) that will be used to display the plans
// <ButtonCheckout /> renders a button that will redirect the user to Lemon Squeezy checkout called the /api/lemonsqueezy/create-checkout API endpoint with the correct variantId
const Pricing = () => {
return (
<section className="bg-base-200 overflow-hidden" id="pricing">
<div className="py-24 px-8 max-w-5xl mx-auto">
<div className="flex flex-col text-center w-full mb-20">
<p className="font-medium text-primary mb-8">Pricing</p>
<h2 className="font-bold text-3xl lg:text-5xl tracking-tight">
Save hours of repetitive code and ship faster!
</h2>
</div>
<div className="relative flex justify-center flex-col lg:flex-row items-center lg:items-stretch gap-8">
{config.lemonsqueezy.plans.map((plan) => (
<div key={plan.variantId} className="relative w-full max-w-lg">
{plan.isFeatured && (
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
<span
className={`badge text-xs text-primary-content font-semibold border-0 bg-primary`}
>
POPULAR
</span>
</div>
)}
{plan.isFeatured && (
<div
className={`absolute -inset-[1px] rounded-[9px] bg-primary z-10`}
></div>
)}
<div className="relative flex flex-col h-full gap-5 lg:gap-8 z-10 bg-base-100 p-8 rounded-lg">
<div className="flex justify-between items-center gap-4">
<div>
<p className="text-lg lg:text-xl font-bold">{plan.name}</p>
{plan.description && (
<p className="text-base-content/80 mt-2">
{plan.description}
</p>
)}
</div>
</div>
<div className="flex gap-2">
{plan.priceAnchor && (
<div className="flex flex-col justify-end mb-[4px] text-lg ">
<p className="relative">
<span className="absolute bg-base-content h-[1.5px] inset-x-0 top-[53%]"></span>
<span className="text-base-content/80">
${plan.priceAnchor}
</span>
</p>
</div>
)}
<p className={`text-5xl tracking-tight font-extrabold`}>
${plan.price}
</p>
<div className="flex flex-col justify-end mb-[4px]">
<p className="text-xs text-base-content/60 uppercase font-semibold">
USD
</p>
</div>
</div>
{plan.features && (
<ul className="space-y-2.5 leading-relaxed text-base flex-1">
{plan.features.map((feature, i) => (
<li key={i} className="flex items-center gap-2">
<svg
xmlns="<http://www.w3.org/2000/svg>"
viewBox="0 0 20 20"
fill="currentColor"
className="w-[18px] h-[18px] opacity-80 shrink-0"
>
<path
fillRule="evenodd"
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"
clipRule="evenodd"
/>
</svg>
<span>{feature.name} </span>
</li>
))}
</ul>
)}
<div className="space-y-2">
<ButtonCheckout variantId={plan.variantId} />
<p className="flex items-center justify-center gap-2 text-sm text-center text-base-content/80 font-medium relative">
Pay once. Access forever.
</p>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default Pricing;
[ ] [JavaScript] Replace the content of components/ButtonCheckout.jsx
file with the following:
"use client";
import { useState } from "react";
import apiClient from "@/libs/api";
import config from "@/config";
// This component is used to create Lemon Squeezy Checkout Sessions
// It calls the /api/lemonsqueezy/create-checkout route with the variantId and redirectUrl
const ButtonCheckout = ({ variantId }) => {
const [isLoading, setIsLoading] = useState(false);
const handlePayment = async () => {
setIsLoading(true);
try {
const { url }: { url: string } = await apiClient.post(
"/lemonsqueezy/create-checkout",
{
variantId,
redirectUrl: window.location.href,
}
);
window.location.href = url;
} catch (e) {
console.error(e);
}
setIsLoading(false);
};
return (
<button
className="btn btn-primary btn-block group"
onClick={() => handlePayment()}
>
{isLoading ? (
<span className="loading loading-spinner loading-xs"></span>
) : (
<svg
className="w-5 h-5 fill-primary-content group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-200"
viewBox="0 0 375 509"
fill="none"
xmlns="<http://www.w3.org/2000/svg>"
>
<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" />
</svg>
)}
Get {config?.appName}
</button>
);
};
export default ButtonCheckout;
[ ] [JavaScript] Replace the content of components/ButtonAccount.jsx
file with the following:
/* eslint-disable @next/next/no-img-element */
"use client";
import { useState } from "react";
import { Popover, Transition } from "@headlessui/react";
import { useSession, signOut } from "next-auth/react";
import apiClient from "@/libs/api";
// A button to show user some account actions
// 1. Billing: open a LemonSqeeuzy Customer Portal to manage their billing (cancel subscription, update payment method, etc.).
// This is only available if the customer has a customerId (they made a purchase previously)
// 2. Logout: sign out and go back to homepage
// See more at <https://shipfa.st/docs/components/buttonAccount>
const ButtonAccount = () => {
const { data: session, status } = useSession();
const [isLoading, setIsLoading] = useState(false);
const handleSignOut = () => {
signOut({ callbackUrl: "/" });
};
const handleBilling = async () => {
setIsLoading(true);
try {
const { url } = await apiClient.post(
"/lemonsqueezy/create-portal"
);
window.location.href = url;
} catch (e) {
console.error(e);
}
setIsLoading(false);
};
// Don't show anything if not authenticated (we don't have any info about the user)
if (status === "unauthenticated") return null;
return (
<Popover className="relative z-10">
{({ open }) => (
<>
<Popover.Button className="btn">
{session?.user?.image ? (
<img
src={session?.user?.image}
alt={session?.user?.name || "Account"}
className="w-6 h-6 rounded-full shrink-0"
referrerPolicy="no-referrer"
width={24}
height={24}
/>
) : (
<span className="w-6 h-6 bg-base-300 flex justify-center items-center rounded-full shrink-0">
{session?.user?.name?.charAt(0) ||
session?.user?.email?.charAt(0)}
</span>
)}
{session?.user?.name || "Account"}
{isLoading ? (
<span className="loading loading-spinner loading-xs"></span>
) : (
<svg
xmlns="<http://www.w3.org/2000/svg>"
viewBox="0 0 20 20"
fill="currentColor"
className={`w-5 h-5 duration-200 opacity-50 ${
open ? "transform rotate-180 " : ""
}`}
>
<path
fillRule="evenodd"
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"
clipRule="evenodd"
/>
</svg>
)}
</Popover.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Popover.Panel className="absolute left-0 z-10 mt-3 w-screen max-w-[16rem] transform">
<div className="overflow-hidden rounded-xl shadow-xl ring-1 ring-base-content ring-opacity-5 bg-base-100 p-1">
<div className="space-y-0.5 text-sm">
<button
className="flex items-center gap-2 hover:bg-base-300 duration-200 py-1.5 px-4 w-full rounded-lg font-medium"
onClick={handleBilling}
>
<svg
xmlns="<http://www.w3.org/2000/svg>"
viewBox="0 0 20 20"
fill="currentColor"
className="w-5 h-5"
>
<path
fillRule="evenodd"
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"
clipRule="evenodd"
/>
</svg>
Billing
</button>
<button
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"
onClick={handleSignOut}
>
<svg
xmlns="<http://www.w3.org/2000/svg>"
viewBox="0 0 20 20"
fill="currentColor"
className="w-5 h-5"
>
<path
fillRule="evenodd"
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"
clipRule="evenodd"
/>
<path
fillRule="evenodd"
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"
clipRule="evenodd"
/>
</svg>
Logout
</button>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};
export default ButtonAccount;
[ ] [TypeScript] Replace the content of components/ButtonCheckout.tsx
file with the following:
"use client";
import { useState } from "react";
import apiClient from "@/libs/api";
import config from "@/config";
// This component is used to create Lemon Squeezy Checkout Sessions
// It calls the /api/lemonsqueezy/create-checkout route with the variantId and redirectUrl
const ButtonCheckout = ({ variantId }: { variantId: string }) => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const handlePayment = async () => {
setIsLoading(true);
try {
const { url }: { url: string } = await apiClient.post(
"/lemonsqueezy/create-checkout",
{
variantId,
redirectUrl: window.location.href,
}
);
window.location.href = url;
} catch (e) {
console.error(e);
}
setIsLoading(false);
};
return (
<button
className="btn btn-primary btn-block group"
onClick={() => handlePayment()}
>
{isLoading ? (
<span className="loading loading-spinner loading-xs"></span>
) : (
<svg
className="w-5 h-5 fill-primary-content group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-200"
viewBox="0 0 375 509"
fill="none"
xmlns="<http://www.w3.org/2000/svg>"
>
<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" />
</svg>
)}
Get {config?.appName}
</button>
);
};
export default ButtonCheckout;
[ ] [TypeScript] Replace the content of components/ButtonAccount.tsx
file with the following:
/* eslint-disable @next/next/no-img-element */
"use client";
import { useState } from "react";
import { Popover, Transition } from "@headlessui/react";
import { useSession, signOut } from "next-auth/react";
import apiClient from "@/libs/api";
// A button to show user some account actions
// 1. Billing: open a LemonSqeeuzy Customer Portal to manage their billing (cancel subscription, update payment method, etc.).
// This is only available if the customer has a customerId (they made a purchase previously)
// 2. Logout: sign out and go back to homepage
// See more at <https://shipfa.st/docs/components/buttonAccount>
const ButtonAccount = () => {
const { data: session, status } = useSession();
const [isLoading, setIsLoading] = useState<boolean>(false);
const handleSignOut = () => {
signOut({ callbackUrl: "/" });
};
const handleBilling = async () => {
setIsLoading(true);
try {
const { url }: { url: string } = await apiClient.post(
"/lemonsqueezy/create-portal"
);
window.location.href = url;
} catch (e) {
console.error(e);
}
setIsLoading(false);
};
// Don't show anything if not authenticated (we don't have any info about the user)
if (status === "unauthenticated") return null;
return (
<Popover className="relative z-10">
{({ open }) => (
<>
<Popover.Button className="btn">
{session?.user?.image ? (
<img
src={session?.user?.image}
alt={session?.user?.name || "Account"}
className="w-6 h-6 rounded-full shrink-0"
referrerPolicy="no-referrer"
width={24}
height={24}
/>
) : (
<span className="w-6 h-6 bg-base-300 flex justify-center items-center rounded-full shrink-0">
{session?.user?.name?.charAt(0) ||
session?.user?.email?.charAt(0)}
</span>
)}
{session?.user?.name || "Account"}
{isLoading ? (
<span className="loading loading-spinner loading-xs"></span>
) : (
<svg
xmlns="<http://www.w3.org/2000/svg>"
viewBox="0 0 20 20"
fill="currentColor"
className={`w-5 h-5 duration-200 opacity-50 ${
open ? "transform rotate-180 " : ""
}`}
>
<path
fillRule="evenodd"
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"
clipRule="evenodd"
/>
</svg>
)}
</Popover.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Popover.Panel className="absolute left-0 z-10 mt-3 w-screen max-w-[16rem] transform">
<div className="overflow-hidden rounded-xl shadow-xl ring-1 ring-base-content ring-opacity-5 bg-base-100 p-1">
<div className="space-y-0.5 text-sm">
<button
className="flex items-center gap-2 hover:bg-base-300 duration-200 py-1.5 px-4 w-full rounded-lg font-medium"
onClick={handleBilling}
>
<svg
xmlns="<http://www.w3.org/2000/svg>"
viewBox="0 0 20 20"
fill="currentColor"
className="w-5 h-5"
>
<path
fillRule="evenodd"
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"
clipRule="evenodd"
/>
</svg>
Billing
</button>
<button
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"
onClick={handleSignOut}
>
<svg
xmlns="<http://www.w3.org/2000/svg>"
viewBox="0 0 20 20"
fill="currentColor"
className="w-5 h-5"
>
<path
fillRule="evenodd"
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"
clipRule="evenodd"
/>
<path
fillRule="evenodd"
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"
clipRule="evenodd"
/>
</svg>
Logout
</button>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};
export default ButtonAccount;
[ ] [JavaScript] Create a libs/lemonsqueezy.js
file and add the following content:
import {
createCheckout,
getCustomer,
lemonSqueezySetup,
} from "@lemonsqueezy/lemonsqueezy.js";
// 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.
export const createLemonSqueezyCheckout = async ({
user,
redirectUrl,
variantId,
discountCode,
}) => {
try {
lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY });
const storeId = process.env.LEMONSQUEEZY_STORE_ID;
const newCheckout = {
productOptions: {
redirectUrl,
},
checkoutData: {
discountCode,
email: user?.email,
name: user?.name,
custom: {
userId: user?._id.toString(),
},
},
};
const { data, error } = await createCheckout(
storeId,
variantId,
newCheckout
);
if (error) {
throw error;
}
return data.data.attributes.url;
} catch (e) {
console.error(e);
return null;
}
};
// This is used to create Customer Portal sessions, so users can manage their subscriptions (payment methods, cancel, etc..)
export const createCustomerPortal = async ({
customerId,
}) => {
try {
lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY });
const { data, error } = await getCustomer(customerId);
if (error) {
throw error;
}
return data.data.attributes.urls.customer_portal;
} catch (error) {
console.error(error);
return null;
}
};
[ ] [TypeScript] Create a libs/lemonsqueezy.ts
file and add the following content:
import {
NewCheckout,
createCheckout,
getCustomer,
lemonSqueezySetup,
} from "@lemonsqueezy/lemonsqueezy.js";
interface CreateLemonSqueezyCheckoutParams {
variantId: string;
redirectUrl: string;
discountCode?: string;
user?: {
email: string;
name: string;
_id: string;
};
}
interface CreateCustomerPortalParams {
customerId: string;
}
// 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.
export const createLemonSqueezyCheckout = async ({
user,
redirectUrl,
variantId,
discountCode,
}: CreateLemonSqueezyCheckoutParams): Promise<string> => {
try {
lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY });
const storeId = process.env.LEMONSQUEEZY_STORE_ID;
const newCheckout: NewCheckout = {
productOptions: {
redirectUrl,
},
checkoutData: {
discountCode,
email: user?.email,
name: user?.name,
custom: {
userId: user?._id.toString(),
},
},
};
const { data, error } = await createCheckout(
storeId,
variantId,
newCheckout
);
if (error) {
throw error;
}
return data.data.attributes.url;
} catch (e) {
console.error(e);
return null;
}
};
// This is used to create Customer Portal sessions, so users can manage their subscriptions (payment methods, cancel, etc..)
export const createCustomerPortal = async ({
customerId,
}: CreateCustomerPortalParams): Promise<string> => {
try {
lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY });
const { data, error } = await getCustomer(customerId);
if (error) {
throw error;
}
return data.data.attributes.urls.customer_portal;
} catch (error) {
console.error(error);
return null;
}
};
[ ] [JavaScript] Create a app/api/lemonsqueezy/create-checkout/route.js
file and add the following content:
import { createLemonSqueezyCheckout } from "@/libs/lemonsqueezy";
import connectMongo from "@/libs/mongoose";
import { authOptions } from "@/libs/next-auth";
import User from "@/models/User";
import { getServerSession } from "next-auth/next";
import { NextRequest, NextResponse } from "next/server";
// This function is used to create a Stripe Checkout Session (one-time payment or subscription)
// It's called by the <ButtonCheckout /> component
// 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
export async function POST(req) {
const body = await req.json();
if (!body.variantId) {
return NextResponse.json(
{ error: "Variant ID is required" },
{ status: 400 }
);
} else if (!body.redirectUrl) {
return NextResponse.json(
{ error: "Redirect URL is required" },
{ status: 400 }
);
}
try {
const session = await getServerSession(authOptions);
await connectMongo();
const user = await User.findById(session?.user?.id);
const { variantId, redirectUrl } = body;
const checkoutURL = await createLemonSqueezyCheckout({
variantId,
redirectUrl,
// If user is logged in, this will automatically prefill Checkout data like email and/or credit card for faster checkout
user,
// If you send coupons from the frontend, you can pass it here
// discountCode: body.discountCode,
});
return NextResponse.json({ url: checkoutURL });
} catch (e) {
console.error(e);
return NextResponse.json({ error: e?.message }, { status: 500 });
}
}
[ ] [JavaScript] Create a app/api/lemonsqueezy/create-portal/route.js
file and add the following content:
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/libs/next-auth";
import connectMongo from "@/libs/mongoose";
import User from "@/models/User";
import { createCustomerPortal } from "@/libs/lemonsqueezy";
export async function POST() {
const session = await getServerSession(authOptions);
if (session) {
try {
await connectMongo();
const { id } = session.user;
const user = await User.findById(id);
if (!user?.customerId) {
return NextResponse.json(
{
error:
"You don't have a billing account yet. Make a purchase first.",
},
{ status: 400 }
);
}
const url = await createCustomerPortal({
customerId: user.customerId,
});
return NextResponse.json({
url,
});
} catch (e) {
console.error(e);
return NextResponse.json({ error: e?.message }, { status: 500 });
}
} else {
// Not Signed in
return NextResponse.json({ error: "Not signed in" }, { status: 401 });
}
}
[ ] [TypeScript] Create a app/api/lemonsqueezy/create-checkout/route.ts
file and add the following content:
import { createLemonSqueezyCheckout } from "@/libs/lemonsqueezy";
import connectMongo from "@/libs/mongoose";
import { authOptions } from "@/libs/next-auth";
import User from "@/models/User";
import { getServerSession } from "next-auth/next";
import { NextRequest, NextResponse } from "next/server";
// This function is used to create a Stripe Checkout Session (one-time payment or subscription)
// It's called by the <ButtonCheckout /> component
// 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
export async function POST(req: NextRequest) {
const body = await req.json();
if (!body.variantId) {
return NextResponse.json(
{ error: "Variant ID is required" },
{ status: 400 }
);
} else if (!body.redirectUrl) {
return NextResponse.json(
{ error: "Redirect URL is required" },
{ status: 400 }
);
}
try {
const session = await getServerSession(authOptions);
await connectMongo();
const user = await User.findById(session?.user?.id);
const { variantId, redirectUrl } = body;
const checkoutURL = await createLemonSqueezyCheckout({
variantId,
redirectUrl,
// If user is logged in, this will automatically prefill Checkout data like email and/or credit card for faster checkout
user,
// If you send coupons from the frontend, you can pass it here
// discountCode: body.discountCode,
});
return NextResponse.json({ url: checkoutURL });
} catch (e) {
console.error(e);
return NextResponse.json({ error: e?.message }, { status: 500 });
}
}
[ ] [TypeScript] Create a app/api/lemonsqueezy/create-portal/route.ts
file and add the following content:
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/libs/next-auth";
import connectMongo from "@/libs/mongoose";
import User from "@/models/User";
import { createCustomerPortal } from "@/libs/lemonsqueezy";
export async function POST() {
const session = await getServerSession(authOptions);
if (session) {
try {
await connectMongo();
const { id } = session.user;
const user = await User.findById(id);
if (!user?.customerId) {
return NextResponse.json(
{
error:
"You don't have a billing account yet. Make a purchase first.",
},
{ status: 400 }
);
}
const url = await createCustomerPortal({
customerId: user.customerId,
});
return NextResponse.json({
url,
});
} catch (e) {
console.error(e);
return NextResponse.json({ error: e?.message }, { status: 500 });
}
} else {
// Not Signed in
return NextResponse.json({ error: "Not signed in" }, { status: 401 });
}
}
[ ] Add a webhook URL from Settings > Webhooks
[ ] [JavaScript] Create a app/api/webhook/lemonsqueezy/route.js
file and add the following content:
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import connectMongo from "@/libs/mongoose";
import crypto from "crypto";
import config from "@/config";
import User from "@/models/User";
// This is where we receive LemonSqueezy webhook events
// It used to update the user data, send emails, etc...
// By default, it'll store the user in the database
// See more: <https://shipfa.st/docs/features/payments>
export async function POST(req) {
const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
if (!secret) {
return new Response("LEMONSQUEEZY_SIGNING_SECRET is required.", {
status: 400,
});
}
await connectMongo();
// Verify the signature
const text = await req.text();
const hmac = crypto.createHmac("sha256", secret);
const digest = Buffer.from(hmac.update(text).digest("hex"), "utf8");
const signature = Buffer.from(headers().get("x-signature"), "utf8");
if (!crypto.timingSafeEqual(digest, signature)) {
return new Response("Invalid signature.", {
status: 400,
});
}
// Get the payload
const payload = JSON.parse(text);
const eventName = payload.meta.event_name;
const customerId = payload.data.attributes.customer_id.toString();
try {
switch (eventName) {
case "order_created": {
// ✅ Grant access to the product
const userId = payload.meta?.custom_data?.userId;
const email = payload.data.attributes.user_email;
const name = payload.data.attributes.user_name;
const variantId =
payload.data.attributes.first_order_item.variant_id.toString();
const plan = config.lemonsqueezy.plans.find(
(p) => p.variantId === variantId
);
if (!plan) {
throw new Error("Plan not found for variantId:", variantId);
}
let user;
// Get or create the user. userId is normally pass in the checkout session (clientReferenceID) to identify the user when we get the webhook event
if (userId) {
user = await User.findById(userId);
} else if (email) {
user = await User.findOne({ email });
if (!user) {
user = await User.create({
email,
name,
});
await user.save();
}
} else {
throw new Error("No user found");
}
// Update user data + Grant user access to your product. It's a boolean in the database, but could be a number of credits, etc...
user.variantId = variantId;
user.customerId = customerId;
user.hasAccess = true;
await user.save();
// Extra: send email with user link, product page, etc...
// try {
// await sendEmail(...);
// } catch (e) {
// console.error("Email issue:" + e?.message);
// }
break;
}
// case "checkout.session.expired": {
// // User didn't complete the transaction
// // You don't need to do anything here, by you can send an email to the user to remind him to complete the transaction, for instance
// break;
// }
// case "customer.subscription.updated": {
// // The customer might have changed the plan (higher or lower plan, cancel soon etc...)
// // You don't need to do anything here, because Stripe will let us know when the subscription is canceled for good (at the end of the billing cycle) in the "customer.subscription.deleted" event
// // You can update the user data to show a "Cancel soon" badge for instance
// break;
// }
case "subscription_cancelled": {
// The customer subscription stopped
// ❌ Revoke access to the product
const user = await User.findOne({ customerId });
// Revoke access to your product
user.hasAccess = false;
await user.save();
break;
}
// case "invoice.paid": {
// // Customer just paid an invoice (for instance, a recurring payment for a subscription)
// // ✅ Grant access to the product
// const stripeObject: Stripe.Invoice = event.data
// .object as Stripe.Invoice;
// const priceId = stripeObject.lines.data[0].price.id;
// const customerId = stripeObject.customer;
// const user = await User.findOne({ customerId });
// // Make sure the invoice is for the same plan (priceId) the user subscribed to
// if (user.priceId !== priceId) break;
// // Grant user access to your product. It's a boolean in the database, but could be a number of credits, etc...
// user.hasAccess = true;
// await user.save();
// break;
// }
// case "invoice.payment_failed":
// // A payment failed (for instance the customer does not have a valid payment method)
// // ❌ Revoke access to the product
// // ⏳ OR wait for the customer to pay (more friendly):
// // - Stripe will automatically email the customer (Smart Retries)
// // - We will receive a "customer.subscription.deleted" when all retries were made and the subscription has expired
// break;
default:
// Unhandled event type
}
} catch (e) {
console.error("lemonsqueezy error: ", e.message);
}
return NextResponse.json({});
}
[ ] [TypeScript] Create a app/api/webhook/lemonsqueezy/route.ts
file and add the following content:
import { NextResponse, NextRequest } from "next/server";
import { headers } from "next/headers";
import connectMongo from "@/libs/mongoose";
import crypto from "crypto";
import config from "@/config";
import User from "@/models/User";
// This is where we receive LemonSqueezy webhook events
// It used to update the user data, send emails, etc...
// By default, it'll store the user in the database
// See more: <https://shipfa.st/docs/features/payments>
export async function POST(req: NextRequest) {
const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
if (!secret) {
return new Response("LEMONSQUEEZY_SIGNING_SECRET is required.", {
status: 400,
});
}
await connectMongo();
// Verify the signature
const text = await req.text();
const hmac = crypto.createHmac("sha256", secret);
const digest = Buffer.from(hmac.update(text).digest("hex"), "utf8");
const signature = Buffer.from(headers().get("x-signature"), "utf8");
if (!crypto.timingSafeEqual(digest, signature)) {
return new Response("Invalid signature.", {
status: 400,
});
}
// Get the payload
const payload = JSON.parse(text);
const eventName = payload.meta.event_name;
const customerId = payload.data.attributes.customer_id.toString();
try {
switch (eventName) {
case "order_created": {
// ✅ Grant access to the product
const userId = payload.meta?.custom_data?.userId;
const email = payload.data.attributes.user_email;
const name = payload.data.attributes.user_name;
const variantId =
payload.data.attributes.first_order_item.variant_id.toString();
const plan = config.lemonsqueezy.plans.find(
(p) => p.variantId === variantId
);
if (!plan) {
throw new Error("Plan not found for variantId:", variantId);
}
let user;
// Get or create the user. userId is normally pass in the checkout session (clientReferenceID) to identify the user when we get the webhook event
if (userId) {
user = await User.findById(userId);
} else if (email) {
user = await User.findOne({ email });
if (!user) {
user = await User.create({
email,
name,
});
await user.save();
}
} else {
throw new Error("No user found");
}
// Update user data + Grant user access to your product. It's a boolean in the database, but could be a number of credits, etc...
user.variantId = variantId;
user.customerId = customerId;
user.hasAccess = true;
await user.save();
// Extra: send email with user link, product page, etc...
// try {
// await sendEmail(...);
// } catch (e) {
// console.error("Email issue:" + e?.message);
// }
break;
}
// case "checkout.session.expired": {
// // User didn't complete the transaction
// // You don't need to do anything here, by you can send an email to the user to remind him to complete the transaction, for instance
// break;
// }
// case "customer.subscription.updated": {
// // The customer might have changed the plan (higher or lower plan, cancel soon etc...)
// // You don't need to do anything here, because Stripe will let us know when the subscription is canceled for good (at the end of the billing cycle) in the "customer.subscription.deleted" event
// // You can update the user data to show a "Cancel soon" badge for instance
// break;
// }
case "subscription_cancelled": {
// The customer subscription stopped
// ❌ Revoke access to the product
const user = await User.findOne({ customerId });
// Revoke access to your product
user.hasAccess = false;
await user.save();
break;
}
// case "invoice.paid": {
// // Customer just paid an invoice (for instance, a recurring payment for a subscription)
// // ✅ Grant access to the product
// const stripeObject: Stripe.Invoice = event.data
// .object as Stripe.Invoice;
// const priceId = stripeObject.lines.data[0].price.id;
// const customerId = stripeObject.customer;
// const user = await User.findOne({ customerId });
// // Make sure the invoice is for the same plan (priceId) the user subscribed to
// if (user.priceId !== priceId) break;
// // Grant user access to your product. It's a boolean in the database, but could be a number of credits, etc...
// user.hasAccess = true;
// await user.save();
// break;
// }
// case "invoice.payment_failed":
// // A payment failed (for instance the customer does not have a valid payment method)
// // ❌ Revoke access to the product
// // ⏳ OR wait for the customer to pay (more friendly):
// // - Stripe will automatically email the customer (Smart Retries)
// // - We will receive a "customer.subscription.deleted" when all retries were made and the subscription has expired
// break;
default:
// Unhandled event type
}
} catch (e) {
console.error("lemonsqueezy error: ", e.message);
}
return NextResponse.json({});
}
npm uninstall stripe
stripe
object from config.js
filestripe
object from ConfigProps
in types/config.ts
filelibs/stripe.js
app/api/stripe/create-checkout/route.js
app/api/stripe/create-portal/route.js
app/api/webhook/stripe/route.js