GuidesLemon Squeezy
Credit goes to Wahab Shaikh for this guide.
Table of Contents:
MongoDB Setup
- Install the package
- Add the environment variables
- Create a new API key from Settings > API
- Find your store ID from Settings > Stores
- 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.
- Update your config
- Update your
/types/config.ts
file - Update your
/models/User.ts
file - Update your
/components/Pricing.tsx
file - Update your
/components/ButtonCheckout.tsx
file - Update your
/components/ButtonAccount.tsx
file - Create a
/libs/lemonsqueezy.ts
file - Create an API route for creating a Checkout session
- Create an API route for creating a Customer Portal session
- Add your webhook URL on Settings > Webhooks
- Create an API route for your Lemon Sqeeuzy webhook
Terminal
1npm install @lemonsqueezy/lemonsqueezy.js
.env.local
1LEMONSQUEEZY_API_KEY=
2LEMONSQUEEZY_STORE_ID=
3LEMONSQUEEZY_SIGNING_SECRET=
Just copy the number, without 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]
./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 },
/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}
/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);
/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 >;
/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
/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;
/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};
/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
/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}
/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
- Add the environment variables
- Create a new API key from Settings > API
- Find your store ID from Settings > Stores
- 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.
- Update your config
- Update your
/components/Pricing.tsx
file - Update your
/components/ButtonCheckout.tsx
file - Update your
/components/ButtonAccount.tsx
file - Create a
/libs/lemonsqueezy.ts
file - Create an API route for creating a Checkout session
- Create an API route for creating a Customer Portal session
- Add your webhook URL on Settings > Webhooks
- Create an API route for your Lemon Sqeeuzy webhook
Terminal
1npm install @lemonsqueezy/lemonsqueezy.js
.env.local
1LEMONSQUEEZY_API_KEY=
2LEMONSQUEEZY_STORE_ID=
3LEMONSQUEEZY_SIGNING_SECRET=
Just copy the number, without 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]
./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 },
/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 >;
/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
/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
/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
/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
/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
/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 variantId =
52 payload.data.attributes.first_order_item.variant_id.toString();
53
54 const plan = configFile.lemonsqueezy.plans.find(
55 (p) => p.variantId === variantId
56 );
57 if (!plan) {
58 throw new Error("Plan not found for variantId:", variantId);
59 }
60
61 // Update the profile where id equals the userId (in table called 'profiles') and update the customer_id, variant_id, and has_access (provisioning)
62 await supabase
63 .from("profiles")
64 .update({
65 customer_id: customerId,
66 variant_id: variantId,
67 has_access: true,
68 })
69 .eq("id", userId);
70
71 // Extra: send email with user link, product page, etc...
72 // try {
73 // await sendEmail({to: ...});
74 // } catch (e) {
75 // console.error("Email issue:" + e?.message);
76 // }
77
78 break;
79 }
80
81 case "subscription_cancelled": {
82 // The customer subscription stopped
83 // ❌ Revoke access to the product
84
85
86 await supabase
87 .from("profiles")
88 .update({ has_access: false })
89 .eq("customer_id", customerId);
90
91 break;
92 }
93
94 default:
95 // Unhandled event type
96 }
97 } catch (e) {
98 console.error("lemonsqueezy error: ", e.message);
99 }
100
101 return NextResponse.json({});
102}
103