DEV Community

Cover image for How to implement subscription model using LemonSqueezy in Next.js (13.4 stable app router)
Minhazur Rahman Ratul
Minhazur Rahman Ratul

Posted on

How to implement subscription model using LemonSqueezy in Next.js (13.4 stable app router)

Table of contents

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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&apos;t have an account?{" "}
          <Link className="text-blue-300 hover:underline underline-offset-4" href="/sign-up">
            Sign up
          </Link>
        </p>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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!" });
}
Enter fullscreen mode Exit fullscreen mode

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]"
Enter fullscreen mode Exit fullscreen mode

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.

Postman 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]"
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

App preview

Before proceeding furture, we need to install a package:

yarn add lemonsqueezy.ts
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Ngrok preview

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]"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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*.*
  2. 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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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&apos;re subscribed</p> : <SubscribeButton />}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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&apos;t have an account?{" "}
          <Link className="text-blue-300 hover:underline underline-offset-4" href="/sign-up">
            Sign up
          </Link>
        </p>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

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.

Top comments (3)

Collapse
 
akashp1712 profile image
Akash Panchal

Very good explanation Minhazur. Keep up the good work.

Collapse
 
developeratul profile image
Minhazur Rahman Ratul

Thank you, Akash!

Collapse
 
canburaks profile image
Can Burak Sofyalioglu • Edited

NOTE:
LemonSqueezy fixed the issue, and the problem I wrote below was no longer valid. Please ignore the text below.


I don't recommend using it.

Why you shouldn't use Lemon Squeezy?

Because their 3D payment security of Lemon Squeezy is not working correctly with debit cards. I couldn't purchase any product with my debit card.

I tried reaching out to Lemon Squeezy multiple times to understand the reason behind the issue, but I only received a response from them after a month. Their response directed me to contact Stripe for further assistance.

However, I'm a hundred percent sure that my debit card had no issue before with any Stripe payments. No, It is not my duty to contact with Stripe because I had no issue with them.

TL:DR

  • To ensure you never miss out on sales that require a debit card and 3D secure payment, it's best to avoid using Lemon Squeezy.

Image description