How to implement subscription model using LemonSqueezy in Next.js (13.4 stable app router)
Minhazur Rahman Ratul
Posted on May 27, 2023
Table of contents
- Introduction
- Getting Started
- Setup Next.js app
- User Registration
- Setup LemonSqueezy account
- How subscription works
- Implementing Subscription
- Deployment
- Conclusion
- Connect with me
Introduction
Hey guys! In this blog post, we will implement a subscription model using LemonSqueezy in our Next.js app with the latest App router. If you're interested in learning about it, this is the post for you.
Getting Started
Let me tell you what I am going to cover in this blog post. First, we are going to bootstrap our Next.js project. Then we will set up our LemonSqueezy account. After that, we will learn how the subscription process works and how we are going to integrate LemonSqueezy subscriptions into our Next.js app. Then we will write code and finally deploy our app on Vercel. So we will cover everything from start to finish. This blog post expects you to have familiarity with React and Next.js (App router). If you know the basics you are good to go.
What we will be building today? We are going to build a simple app that will let the user create an account and subscribe to our product. Later he can cancel it anytime, change credit card credentials, and all that. So that’s the overview of what we are going to build.
Setup Next.js app
For this tutorial, I have created a GitHub repo that has a branch called starter
You will have to follow a simple GitHub workflow and checkout to this branch to get the starter code to follow along with this tutorial. The final code will be in the main
branch.
User Registration
Let’s create a very simple authentication where users can register and log in. No session management and all that other authorization stuff since that’s not the main purpose of this tutorial.
Let’s make some edits in the app/sign-up/page.tsx
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React from "react";
import { toast } from "react-hot-toast";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { axios } from "~/lib/axios";
import { useAuth } from "~/providers/auth";
import { SignUpResponse } from "../api/auth/sign-up/route";
export default function SignUp() {
const [input, setInput] = React.useState({ email: "", password: "" });
const { setUser, user } = useAuth();
const router = useRouter();
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = ({
target: { value, name },
}) => {
setInput((pre) => ({ ...pre, [name]: value }));
};
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
try {
e.preventDefault();
const { email, password } = input;
if (!email || !password) return;
const { user, message } = await axios.post<any, SignUpResponse>("/api/auth/sign-up", {
email,
password,
});
setUser(user);
router.push("/");
toast.success(message);
} catch (err) {
//
}
};
return (
<div className="flex flex-col justify-center items-center gap-8 w-full">
<div className="w-full max-w-md bg-white/5 p-8 rounded-lg border border-gray-900">
<form onSubmit={handleSubmit} className="flex flex-col justify-center items-center gap-4">
<h2 className="text-2xl">Sign up</h2>
<Input
required
type="email"
onChange={handleInputChange}
name="email"
placeholder="Enter email"
/>
<Input
required
type="password"
placeholder="Enter password"
onChange={handleInputChange}
name="password"
/>
<Button type="submit" className="w-full">
Sign up
</Button>
</form>
</div>
<div>
<p>
Already have an account?{" "}
<Link className="text-blue-300 hover:underline underline-offset-4" href="/login">
Login
</Link>
</p>
</div>
</div>
);
}
Let create the API route. app/api/sign-up/route.ts
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { prisma } from "~/prisma/db";
import { User } from "~/providers/auth";
export type SignUpResponse = {
user: User;
message: string;
};
export async function POST(req: Request) {
try {
const { email, password } = await req.json();
const prevUser = await prisma.user.findUnique({
where: { email },
});
if (prevUser) return NextResponse.json({ message: "Email already in use" }, { status: 409 });
const hashedPassword = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { email, password: hashedPassword },
select: { id: true, email: true },
});
return NextResponse.json({
user,
message: "Your account has been created!",
} satisfies SignUpResponse);
} catch (err) {
if (err instanceof Error) {
return NextResponse.json({ message: err.message }, { status: 500 });
}
}
}
We are done with sign-up. Now let’s implement login.
app/login/page.tsx
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React from "react";
import { toast } from "react-hot-toast";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { axios } from "~/lib/axios";
import { useAuth } from "~/providers/auth";
import { SignUpResponse } from "../api/auth/sign-up/route";
export default function Login() {
const [input, setInput] = React.useState({ email: "", password: "" });
const { setUser, user } = useAuth();
const router = useRouter();
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = ({
target: { value, name },
}) => {
setInput((pre) => ({ ...pre, [name]: value }));
};
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
try {
e.preventDefault();
const { email, password } = input;
if (!email || !password) return;
const { user, message } = await axios.post<any, SignUpResponse>("/api/auth/login", {
email,
password,
});
setUser(user);
router.push("/");
toast.success(message);
} catch (err) {
//
}
};
return (
<div className="flex flex-col justify-center items-center gap-8 w-full">
<div className="w-full max-w-md bg-white/5 p-8 rounded-lg border border-gray-900">
<form onSubmit={handleSubmit} className="flex flex-col justify-center items-center gap-4">
<h2 className="text-2xl">Login</h2>
<Input
required
type="email"
placeholder="Enter email"
name="email"
value={input.email}
onChange={handleInputChange}
/>
<Input
required
type="password"
placeholder="Enter password"
name="password"
value={input.password}
onChange={handleInputChange}
/>
<Button type="submit" className="w-full">
Login
</Button>
</form>
</div>
<div>
<p>
Don't have an account?{" "}
<Link className="text-blue-300 hover:underline underline-offset-4" href="/sign-up">
Sign up
</Link>
</p>
</div>
</div>
);
}
app/api/auth/login/route.ts
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { prisma } from "~/prisma/db";
export async function POST(req: Request) {
const { email: emailInput, password: passwordInput } = await req.json();
const user = await prisma.user.findUnique({
where: { email: emailInput },
select: { id: true, email: true, password: true },
});
if (!user) return NextResponse.json({ message: "Your account does not exist" }, { status: 404 });
const { id, password, email } = user;
const passwordMatched = await bcrypt.compare(passwordInput, password);
if (!passwordMatched)
return NextResponse.json({ message: "Invalid credentials" }, { status: 403 });
return NextResponse.json({ user: { id, email }, message: "Welcome back!" });
}
That’s all about user registration.
Setup LemonSqueezy account
Before I start discussing how subscription works, let’s get our LemonSqueezy account ready. For that, I would highly suggest following their guide.
How subscription works
A subscription is a signed agreement between a supplier and customer that the customer will receive and provide payment for regular products or services. Subscription payments are collected in different intervals like Month, Week, Year, etc.
Before the user pays, he must subscribe to our product. Once he subscribes, he will automatically get charged after the specified interval from his credit card until he cancels the subscription which he can do anytime.
Once the user has subscribed and paid for our product, we use a webhook that will send a request to our API endpoint with a payload. We will make changes in our database according to that payload and the customer will get access to the premium plan or whatever.
So that’s basically how subscription works. Now let’s start implementing it in our code.
Implementing Subscription
If you have got your LemonSqueezy account ready, go to this page and generate a new API key. Also, create a test product. Here’s a guide on that. Now add this line in our .env
file:
LEMONSQUEEZY_API_KEY="[YOUR API KEY]"
We need those credentials to proceed further:
- Store id
- Product id
You can get this through the API they provide. Make an API request to this endpoint https://api.lemonsqueezy.com/v1/products
while having the authorization header present in your request.
The first marked credential is the Product id
and the second one is the Store id
Now let’s add them to our .env
file:
LEMON_SQUEEZY_STORE_ID="[YOUR STORE ID]"
LEMONS_SQUEEZY_PRODUCT_ID="[YOUR PRODUCT ID]"
Creating a checkout
Now that we have the required credentials, let create a checkout option in our app. First, we will add a button on the home
page.
app/page.tsx
"use client";
import { useRouter } from "next/navigation";
import React from "react";
import { Button } from "~/components/ui/button";
import { axios } from "~/lib/axios";
import { useAuth } from "~/providers/auth";
import { CreateCheckoutResponse } from "./api/payment/subscribe/route";
export default function Home() {
const { isAuthenticated, user } = useAuth();
const router = useRouter();
React.useEffect(() => {
if (!isAuthenticated) {
router.push("/login");
}
}, [isAuthenticated, router]);
if (!isAuthenticated || !user) return <></>;
const handleClick = async () => {
try {
const { checkoutURL } = await axios.post<any, CreateCheckoutResponse>(
"/api/payment/subscribe",
{ userId: user.id }
);
window.location.href = checkoutURL;
} catch (err) {
//
}
};
return (
<div className="w-full max-w-md bg-white/5 border border-gray-900 p-8 rounded-lg">
<div className="flex flex-col gap-4">
<h2 className="text-2xl">Your profile</h2>
<Button onClick={handleClick}>Subscribe</Button>
</div>
</div>
);
}
Before proceeding furture, we need to install a package:
yarn add lemonsqueezy.ts
This is an unofficial wrapper around the official API. Lets initialize it.
lib/lemons.ts
import { LemonsqueezyClient } from "lemonsqueezy.ts";
export const client = new LemonsqueezyClient(process.env.LEMONSQUEEZY_API_KEY as string);
Let’s create the API route for creating a checkout.
app/api/payment/subscribe/route.ts
import type { CreateCheckoutResult } from "lemonsqueezy.ts/dist/types";
import { NextResponse } from "next/server";
import { axios } from "~/lib/axios";
import { client } from "~/lib/lemons";
import { prisma } from "~/prisma/db";
export type CreateCheckoutResponse = {
checkoutURL: string;
};
export async function POST(request: Request) {
try {
const { userId } = await request.json();
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true },
});
if (!user) return NextResponse.json({ message: "Your account was not found" }, { status: 404 });
const variant = (
await client.listAllVariants({ productId: process.env.LEMONS_SQUEEZY_PRODUCT_ID })
).data[0];
const checkout = (await axios.post(
"https://api.lemonsqueezy.com/v1/checkouts",
{
data: {
type: "checkouts",
attributes: { checkout_data: { email: user.email, custom: [user.id] } },
relationships: {
store: { data: { type: "stores", id: process.env.LEMON_SQUEEZY_STORE_ID } },
variant: { data: { type: "variants", id: variant.id } },
},
},
},
{ headers: { Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}` } }
)) as CreateCheckoutResult;
return NextResponse.json({ checkoutURL: checkout.data.attributes.url }, { status: 201 });
} catch (err: any) {
return NextResponse.json({ message: err.message || err }, { status: 500 });
}
}
Let me explain this code to you. First, we are checking if the user exists or not. Secondly, we are listing all the variants of our product. In Stripe, we call it Price but in LemonSqueezy, we call it a Variant. A variant id is required for creating a checkout. If you have not created any variants for your product, LemonSqueezy automatically created a default variant for your product. In that case, that is what we are retrieving. Thirdly, We are creating a checkout through the API. I have passed the user email and the Id as the payload. This will be needed when we will deal with webhooks. I have also passed some required fields in the body and a auth header. Finally, send the checkout URL within the response. The client will redirect the user to this URL.
Synchronization with Webhook
Now that we are done with checkout where users can pay and subscribe to our product, it’s time to keep in sync with them. To create and test webhooks locally, we can use a service like ngrok. I have a Twitter thread on how it’s done. So not going into much detail.
Run the following command once you have got ngrok installed on your computer.
ngrok http 3000 # or wherever your app is running
Copy this URL. Go to this page on your LemonSqueezy dashboard, create a new webhook, and add this as a callback URL which should look like this http://[YOUR URL]/payment/webhook
. We still have to create this route. Then, add a signing secret. We will listen to those events:
- subscription_created
- subscription_updated
Now we have to update our .env
file.
LEMONS_SQUEEZY_SIGNATURE_SECRET="[YOUR SECRET]"
Whenever a user checks out and subscribes to our product, LemonSqueezy is going to send a post request to the endpoint specified in the webhook with a payload. So now let’s create that route. Before that, run the following command to install a package that will be used to validate the webhook signature.
yarn add raw-body
Now lets prepare our route for handling webhook requests.
api/api/payment/webhook/route.ts
import { Buffer } from "buffer";
import crypto from "crypto";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import rawBody from "raw-body";
import { Readable } from "stream";
import { client } from "~/lib/lemons";
import { prisma } from "~/prisma/db";
export async function POST(request: Request) {
const body = await rawBody(Readable.from(Buffer.from(await request.text())));
const headersList = headers();
const payload = JSON.parse(body.toString());
const sigString = headersList.get("x-signature");
const secret = process.env.LEMONS_SQUEEZY_SIGNATURE_SECRET as string;
const hmac = crypto.createHmac("sha256", secret);
const digest = Buffer.from(hmac.update(body).digest("hex"), "utf8");
const signature = Buffer.from(
Array.isArray(sigString) ? sigString.join("") : sigString || "",
"utf8"
);
// Check if the webhook event was for this product or not
if (
parseInt(payload.data.attributes.product_id) !==
parseInt(process.env.LEMONS_SQUEEZY_PRODUCT_ID as string)
) {
return NextResponse.json({ message: "Invalid product" }, { status: 403 });
}
// validate signature
if (!crypto.timingSafeEqual(digest, signature)) {
return NextResponse.json({ message: "Invalid signature" }, { status: 403 });
}
const userId = payload.meta.custom_data[0];
// Check if custom defined data i.e. the `userId` is there or not
if (!userId) {
return NextResponse.json({ message: "No userId provided" }, { status: 403 });
}
switch (payload.meta.event_name) {
case "subscription_created": {
const subscription = await client.retrieveSubscription({ id: payload.data.id });
await prisma.user.update({
where: { id: userId },
data: {
subscriptionId: `${subscription.data.id}`,
customerId: `${payload.data.attributes.customer_id}`,
variantId: subscription.data.attributes.variant_id,
currentPeriodEnd: subscription.data.attributes.renews_at,
},
});
}
case "subscription_updated": {
const subscription = await client.retrieveSubscription({ id: payload.data.id });
const user = await prisma.user.findUnique({
where: { subscriptionId: `${subscription.data.id}` },
select: { subscriptionId: true },
});
if (!user || !user.subscriptionId) return;
await prisma.user.update({
where: { subscriptionId: user.subscriptionId },
data: {
variantId: subscription.data.attributes.variant_id,
currentPeriodEnd: subscription.data.attributes.renews_at,
},
});
}
default: {
return;
}
}
}
Let me explain this code to you. First, we are validating the webhook signature for security concerns. Secondly, we are checking if the webhook was for the specific product which we are integrating into this app. Then we are checking if the payload contains our custom-defined credential in that case, it’s the userId
. After that, we have two switch cases. One for the subscription_created
and the other for the subscription_updated
event.
-
subscription_created
Whenever a user subscribes and pays for our product, this event is called. When this event is called, we wanna capture the subcriptionId, customerId, variantId, and the currentPeriodEnd in our database*.* -
subscription_updated
Whenever the subscription gets updated, this event is called. Updating a subscription refers to Paying at the period’s end, canceling the subscription, etc. When this event is called, we wanna update our variantId (just in case, the user picks a different pricing/variant) and the currentPeriodEnd.
I will explain the purpose of storing currentPeriodEnd later in this blog when will check the user’s subscription status.
Check the subscription status
Now that, we are done with checkout and synchronization, it’s time to fetch the user’s subscription status and limit his account accordingly.
lib/subscription.ts
import { prisma } from "~/prisma/db";
import { client } from "./lemons";
export async function getUserSubscriptionPlan(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
subscriptionId: true,
currentPeriodEnd: true,
customerId: true,
variantId: true,
},
});
if (!user) throw new Error("User not found");
// Check if user is on a pro plan.
const isPro =
user.variantId &&
user.currentPeriodEnd &&
user.currentPeriodEnd.getTime() + 86_400_000 > Date.now();
const subscription = await client.retrieveSubscription({ id: user.subscriptionId });
// If user has a pro plan, check cancel status on Stripe.
let isCanceled = false;
if (isPro && user.subscriptionId) {
isCanceled = subscription.data.attributes.cancelled;
}
return {
...user,
currentPeriodEnd: user.currentPeriodEnd?.getTime(),
isCanceled,
isPro,
updatePaymentMethodURL: subscription.data.attributes.urls.update_payment_method,
};
}
In this code above, we are first checking if the user exists or not. Then we are checking if the user is on the pro plan or not and store it in the isPro
var. This was the purpose of storing the currentPeriodEnd
Then if the user is on the pro plan, check if he has canceled the plan or not. Finally, return all of these within an object. We will use this function to retrieve if the user is on the pro plan or not and limit his account accordingly.
Now we will be making some updates to our home page. Until now, the home page was being rendered as a client component. Now we will render it as a server component. We have to make make our authentication persistent as well. So there will be a bit more addition to it.
app/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getUserSubscriptionPlan } from "~/lib/subscription";
import SubscribeButton from "./SubscribeButton";
export default async function Home() {
const cookiesList = cookies();
const userId = cookiesList.get("userId")?.value || "";
if (!userId) return redirect("/login");
const { isPro } = await getUserSubscriptionPlan(userId);
return (
<div className="w-full max-w-md bg-white/5 border border-gray-900 p-8 rounded-lg">
<div className="flex flex-col gap-4">
<h2 className="text-2xl">Your profile</h2>
{isPro ? <p>You're subscribed</p> : <SubscribeButton />}
</div>
</div>
);
}
Fetching the subscription status and hide the subscribe button accordingly.
app/SubscribeButton.tsx
"use client";
import { useRouter } from "next/navigation";
import React from "react";
import { Button } from "~/components/ui/button";
import { axios } from "~/lib/axios";
import { useAuth } from "~/providers/auth";
import { CreateCheckoutResponse } from "./api/payment/subscribe/route";
export default function SubscribeButton() {
const { isAuthenticated, user } = useAuth();
const router = useRouter();
React.useEffect(() => {
if (!isAuthenticated) {
router.push("/login");
}
}, [isAuthenticated, router]);
if (!isAuthenticated || !user) return <></>;
const handleClick = async () => {
try {
const { checkoutURL } = await axios.post<any, CreateCheckoutResponse>(
"/api/payment/subscribe",
{ userId: user.id }
);
window.location.href = checkoutURL;
} catch (err) {
//
}
};
return <Button onClick={handleClick}>Subscribe</Button>;
}
Since the home page is a RSC now, we had to separate this button to a client component.
app/login/page.tsx
One Small addition (Storing cookie)
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React from "react";
import { toast } from "react-hot-toast";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { axios } from "~/lib/axios";
import { useAuth } from "~/providers/auth";
import { SignUpResponse } from "../api/auth/sign-up/route";
export default function Login() {
const [input, setInput] = React.useState({ email: "", password: "" });
const { setUser, user } = useAuth();
const router = useRouter();
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = ({
target: { value, name },
}) => {
setInput((pre) => ({ ...pre, [name]: value }));
};
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
try {
e.preventDefault();
const { email, password } = input;
if (!email || !password) return;
const { user, message } = await axios.post<any, SignUpResponse>("/api/auth/login", {
email,
password,
});
setUser(user);
// ADDITION: Store the user id in a cookie 👇
document.cookie = `userId=${user?.id}`;
router.push("/");
toast.success(message);
} catch (err) {
//
}
};
return (
<div className="flex flex-col justify-center items-center gap-8 w-full">
<div className="w-full max-w-md bg-white/5 p-8 rounded-lg border border-gray-900">
<form onSubmit={handleSubmit} className="flex flex-col justify-center items-center gap-4">
<h2 className="text-2xl">Login</h2>
<Input
required
type="email"
placeholder="Enter email"
name="email"
value={input.email}
onChange={handleInputChange}
/>
<Input
required
type="password"
placeholder="Enter password"
name="password"
value={input.password}
onChange={handleInputChange}
/>
<Button type="submit" className="w-full">
Login
</Button>
</form>
</div>
<div>
<p>
Don't have an account?{" "}
<Link className="text-blue-300 hover:underline underline-offset-4" href="/sign-up">
Sign up
</Link>
</p>
</div>
</div>
);
}
app/sign-up/page.tsx
One Small addition (same as login)
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React from "react";
import { toast } from "react-hot-toast";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { axios } from "~/lib/axios";
import { useAuth } from "~/providers/auth";
import { SignUpResponse } from "../api/auth/sign-up/route";
export default function SignUp() {
const [input, setInput] = React.useState({ email: "", password: "" });
const { setUser, user } = useAuth();
const router = useRouter();
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = ({
target: { value, name },
}) => {
setInput((pre) => ({ ...pre, [name]: value }));
};
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
try {
e.preventDefault();
const { email, password } = input;
if (!email || !password) return;
const { user, message } = await axios.post<any, SignUpResponse>("/api/auth/sign-up", {
email,
password,
});
setUser(user);
// ADDITION: Store the user id in a cookie 👇
document.cookie = `userId=${user?.id}`;
router.push("/");
toast.success(message);
} catch (err) {
//
}
};
return (
<div className="flex flex-col justify-center items-center gap-8 w-full">
<div className="w-full max-w-md bg-white/5 p-8 rounded-lg border border-gray-900">
<form onSubmit={handleSubmit} className="flex flex-col justify-center items-center gap-4">
<h2 className="text-2xl">Sign up</h2>
<Input
required
type="email"
onChange={handleInputChange}
name="email"
placeholder="Enter email"
/>
<Input
required
type="password"
placeholder="Enter password"
onChange={handleInputChange}
name="password"
/>
<Button type="submit" className="w-full">
Sign up
</Button>
</form>
</div>
<div>
<p>
Already have an account?{" "}
<Link className="text-blue-300 hover:underline underline-offset-4" href="/login">
Login
</Link>
</p>
</div>
</div>
);
}
Now those changes are made, this feature should be functional.
Manage subscription
Now that the user has subscribed, he should be able to cancel it anytime he wants so he will not get changed anymore also resume the subscription if he wants to continue again. We will create another component for that in the home page.
Create the ManageSubscription
component.
app\ManageSubscription.tsx
"use client";
import { useRouter } from "next/navigation";
import { toast } from "react-hot-toast";
import { Button } from "~/components/ui/button";
import { axios } from "~/lib/axios";
export default function ManageSubscription(props: {
userId: string;
isCanceled: boolean;
currentPeriodEnd?: number;
}) {
const { userId, isCanceled, currentPeriodEnd } = props;
const router = useRouter();
// If the subscription is cancelled, let the user resume his plan
if (isCanceled && currentPeriodEnd) {
const handleResumeSubscription = async () => {
try {
const { message } = await axios.post<any, { message: string }>(
"/api/payment/resume-subscription",
{ userId }
);
router.refresh();
toast.success(message);
} catch (err) {
//
}
};
return (
<div className="flex flex-col justify-between items-center gap-4">
<p>
You have cancelled the subscription but you still have access to our service until{" "}
{new Date(currentPeriodEnd).toDateString()}
</p>
<Button className="bg-blue-300 hover:bg-blue-500 w-full" onClick={handleResumeSubscription}>
Resume plan
</Button>
</div>
);
}
// If the user is subscribed, let him cancel his plan
const handleCancelSubscription = async () => {
try {
const { message } = await axios.post<any, { message: string }>(
"/api/payment/cancel-subscription",
{ userId }
);
router.refresh();
toast.success(message);
} catch (err) {
//
}
};
return (
<div className="flex flex-col justify-between items-center gap-4">
<p>You are subscribed to our product. Congratulations</p>
<Button className="bg-red-300 hover:bg-red-500 w-full" onClick={handleCancelSubscription}>
Cancel
</Button>
</div>
);
}
Let’s add this component to our home page:
app/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getUserSubscriptionPlan } from "~/lib/subscription";
import ManageSubscription from "./ManageSubscription";
import SubscribeButton from "./SubscribeButton";
export default async function Home() {
const cookiesList = cookies();
const userId = cookiesList.get("userId")?.value || "";
if (!userId) return redirect("/login");
const { isPro, isCanceled, currentPeriodEnd } = await getUserSubscriptionPlan(userId);
return (
<div className="w-full max-w-md bg-white/5 border border-gray-900 p-8 rounded-lg">
<div className="flex flex-col gap-4">
<h2 className="text-2xl">Your profile</h2>
{isPro ? (
<ManageSubscription
userId={userId}
isCanceled={isCanceled}
currentPeriodEnd={currentPeriodEnd}
/>
) : (
<SubscribeButton />
)}
</div>
</div>
);
}
Now we have to create the API routes which are being used in the ManageSubscription
component. Let’s do that.
app\api\payment\cancel-subscription\route.ts
import { NextResponse } from "next/server";
import { axios } from "~/lib/axios";
import { getUserSubscriptionPlan } from "~/lib/subscription";
import { prisma } from "~/prisma/db";
export async function POST(request: Request) {
try {
const { userId } = await request.json();
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
subscriptionId: true,
variantId: true,
currentPeriodEnd: true,
},
});
if (!user) return NextResponse.json({ message: "User not found" }, { status: 404 });
const { isPro } = await getUserSubscriptionPlan(user.id);
if (!isPro) return NextResponse.json({ message: "You are not subscribed" }, { status: 402 });
await axios.patch(
`https://api.lemonsqueezy.com/v1/subscriptions/${user.subscriptionId}`,
{
data: {
type: "subscriptions",
id: user.subscriptionId,
attributes: {
cancelled: true, // <- ALl the line of code just for this
},
},
},
{
headers: {
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
},
}
);
const endsAt = user.currentPeriodEnd?.toLocaleString();
return NextResponse.json({
message: `Your subscription has been cancelled. You will still have access to our product until '${endsAt}'`,
});
} catch (err) {
console.log({ err });
if (err instanceof Error) {
return NextResponse.json({ message: err.message }, { status: 500 });
}
}
}
This route lets the user cancel his plan. Once his plan is canceled, the subscription object will get updated which belongs to this user and a value of true
will be assigned to the cancelled
property. Remember we used this property while checking if the user has canceled his plan or not?
// lib\subscription.ts
isCanceled = subscription.data.attributes.cancelled;
app\api\payment\resume-subscription\route.ts
import { NextResponse } from "next/server";
import { axios } from "~/lib/axios";
import { getUserSubscriptionPlan } from "~/lib/subscription";
import { prisma } from "~/prisma/db";
export async function POST(request: Request) {
try {
const { userId } = await request.json();
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
subscriptionId: true,
variantId: true,
currentPeriodEnd: true,
},
});
if (!user) return NextResponse.json({ message: "User not found" }, { status: 404 });
const { isPro } = await getUserSubscriptionPlan(user.id);
if (!isPro) return NextResponse.json({ message: "You are not subscribed" }, { status: 402 });
await axios.patch(
`https://api.lemonsqueezy.com/v1/subscriptions/${user.subscriptionId}`,
{
data: {
type: "subscriptions",
id: user.subscriptionId,
attributes: {
cancelled: false, // <- Cancel
},
},
},
{
headers: {
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
},
}
);
return NextResponse.json({
message: `Your subscription has been resumed.`,
});
} catch (err) {
console.log({ err });
if (err instanceof Error) {
return NextResponse.json({ message: err.message }, { status: 500 });
}
}
}
This will do the opposite of the previous route.
We’re done. The subscription management component should now be functional.
In addition, the user should be able to change his credit card and other credentials so lets add this option.
app\ManageSubscription.tsx
"use client";
import { useRouter } from "next/navigation";
import { toast } from "react-hot-toast";
import { Button } from "~/components/ui/button";
import { axios } from "~/lib/axios";
export default function ManageSubscription(props: {
userId: string;
isCanceled: boolean;
currentPeriodEnd?: number;
updatePaymentMethodURL: string;
}) {
const { userId, isCanceled, currentPeriodEnd, updatePaymentMethodURL } = props;
const router = useRouter();
// If the subscription is cancelled, let the user resume his plan
if (isCanceled && currentPeriodEnd) {
const handleResumeSubscription = async () => {
try {
const { message } = await axios.post<any, { message: string }>(
"/api/payment/resume-subscription",
{ userId }
);
router.refresh();
toast.success(message);
} catch (err) {
//
}
};
return (
<div className="flex flex-col justify-between items-center gap-4">
<p>
You have cancelled the subscription but you still have access to our service until{" "}
{new Date(currentPeriodEnd).toDateString()}
</p>
<Button className="w-full" onClick={handleResumeSubscription}>
Resume plan
</Button>
<a
href={updatePaymentMethodURL}
className="w-full"
target="_blank"
rel="noopener noreferrer"
>
<Button className="w-full">Update payment method</Button>
</a>
</div>
);
}
// If the user is subscribed, let him cancel his plan
const handleCancelSubscription = async () => {
try {
const { message } = await axios.post<any, { message: string }>(
"/api/payment/cancel-subscription",
{ userId }
);
router.refresh();
toast.success(message);
} catch (err) {
//
}
};
return (
<div className="flex flex-col justify-between items-center gap-4">
<p>You are subscribed to our product. Congratulations</p>
<Button className="bg-red-300 hover:bg-red-500 w-full" onClick={handleCancelSubscription}>
Cancel
</Button>
<a href={updatePaymentMethodURL} className="w-full" target="_blank" rel="noopener noreferrer">
<Button className="w-full">Update payment method</Button>
</a>
</div>
);
}
Added two buttons that will redirect the user to the page where he can perform that action. Note that, this component now accepts an additional prop so make sure you are passing it from the parent component. In that case it’s the home page.
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getUserSubscriptionPlan } from "~/lib/subscription";
import ManageSubscription from "./ManageSubscription";
import SubscribeButton from "./SubscribeButton";
export default async function Home() {
const cookiesList = cookies();
const userId = cookiesList.get("userId")?.value || "";
if (!userId) return redirect("/login");
const { isPro, isCanceled, currentPeriodEnd, updatePaymentMethodURL } =
await getUserSubscriptionPlan(userId);
return (
<div className="w-full max-w-md bg-white/5 border border-gray-900 p-8 rounded-lg">
<div className="flex flex-col gap-4">
<h2 className="text-2xl">Your profile</h2>
{isPro ? (
<ManageSubscription
userId={userId}
isCanceled={isCanceled}
currentPeriodEnd={currentPeriodEnd}
updatePaymentMethodURL={updatePaymentMethodURL}
/>
) : (
<SubscribeButton />
)}
</div>
</div>
);
}
That’s all about managing subscriptions.
Deployment
We are done developing this app and it is ready to be shipped. You can use Vercel, Render, or any other platform for the deployment. It’s a straightforward process. Import your repo > Enter env variables and deploy.
Conclusion
That’s all folks. Hope that article was able to add value and you have found a way to receive payments for your SaaS. I wish you all the best in implementing that feature. The full source code can be found on my Github. If you think there is any bug in my code, please feel free to let me know or you can send a PR on GitHub. Thanks for reading this article until the end and I will see you in the next one ✌️
Connect with me
👤 Minhazur Rahman Ratul
- Twitter (@developeratul)
- Github (@developeratul)
It took me hours to write that article, but you need only a few to support it. You can support me by liking it, sharing it, and buying me a coffee.
Posted on May 27, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.