Using Lemon Squeezy for SaaS subscriptions with Makerkit

In this recipe, we provide a step-by-step guide to replace Stripe with Lemon Squeezy for Makerkit.

·16 min read
Cover Image for Using Lemon Squeezy for SaaS subscriptions with Makerkit

Lemon Squeezy is a Merchant of Record (MoR) that allows you to accept payments from your customers without having to worry about Sales Tax. Lemon Squeezy is a great alternative to Stripe for SaaS businesses.

In fact, it has become incredibly popular thanks to its sleek interface, quick setup, and low fees.

Due to high demand, I am excited to make it easy for you to use Lemon Squeezy with Makerkit. In this recipe, we provide a step-by-step guide to replace Stripe with Lemon Squeezy for Makerkit.

Step 1: Create a Lemon Squeezy account

First, you need to create a Lemon Squeezy account. You can do so by visiting https://lemonsqueezy.io.

Get your store setup (start in Test mode, so you don't need to wait for activation).

Step 2: Create a Product/Variant in Lemon Squeezy

You can see this guide for setting up SaaS subscription plans in Lemon Squeezy.

If you want, set up a monthly/annual plan for your product, or just a single plan.

To replicate the basic Makerkit subscription plans, you can create a plan for each of the following: Basic, Pro and Premium plans.

To get started quickly, you can also set up a single plan for your product.

Step 3: Create a Lemon Squeezy API key

Now, we need to setup an API Key. Go to your Lemon Squeezy settings page and click on the API Keys tab.

Click on the "Create API Key" button and give it a very specific name, so you won't confuse it with your production keys. Copy the API key and ensure to store it. We will be adding this to your Makerkit's local environment variables file.

Depending on which kit you're using, you will add this key to either .env.local (Next.js) or .env (Remix). This key is not supposed to be committed to your repository, so we add them safely to our local files.

Additionally, we need to add the LEMON_SQUEEZY_STORE_ID to the local environment variables file, and a secret string LEMONS_SQUEEZY_SIGNATURE_SECRET that we use to verify the webhook signature.

Add them to your local environment variables file:

LEMON_SQUEEZY_API_KEY=<YOUR_KEY>
LEMON_SQUEEZY_STORE_ID=<YOUR_STORE_ID>
LEMONS_SQUEEZY_SIGNATURE_SECRET=<a random string>

Find your Lemon Squeezy Store ID

To find your Lemon Squeezy Store ID, we use the API.

Submit a GET request to the following endpoint using curl or Postman and insert your API key:

GET https://api.lemonsqueezy.com/v1/stores
Accept: application/vnd.api+json
Authorization: Bearer {api_key}
Content-Type: application/vnd.api+json

Proceed by copying the id from the response into the LEMON_SQUEEZY_STORE_ID environment variable.

Step 4: Configure Lemon Squeezy plans in the Makerkit configuration

Now, we need to configure the Lemon Squeezy plans in the Makerkit configuration, and enter the correct plan IDs.

To do so, open the ~/configuration.ts file and edit the stripe object. We can convert this to something more abstract, such as subscriptions:

{
  subscriptions: {
    products: [
      {
        name: 'Basic',
        description: 'Description of your Basic plan',
        badge: `Up to 20 users`,
        features: [
          'Basic Reporting',
          'Up to 20 users',
          '1GB for each user',
          'Chat Support',
        ],
        plans: [
          {
            name: 'Monthly',
            price: '$9',
            variantId: 1,
            trialPeriodDays: 0,
          },
          {
            name: 'Yearly',
            price: '$90',
            variantId: 2,
            trialPeriodDays: 0,
          },
        ],
      }
    ]
  }
}

Then, replace the stripePriceId with the correct plan ID from Lemon Squeezy, and rename the key to variantId. This will cause Typescript errors, which is good, because it will force you to update the key in all the files.

Pro Tip: use your IDE, and it will automatically replace the key for you in all the files. If the IDE can't replace them all, please do it manually.

To find your Lemon Squeezy variant ID, we use the API.

Submit a GET request to the following endpoint using curl or Postman and insert your API key:

GET https://api.lemonsqueezy.com/v1/variants
Accept: application/vnd.api+json
Authorization: Bearer {api_key}
Content-Type: application/vnd.api+json

Now, copy the id from the response, and paste it into the variantId key as a number.

Step 5: Update the checkout API endpoint

Now, we need to update the checkout API endpoint to use Lemon Squeezy instead of Stripe.

From the ~/configuration.ts file, we need to update the paths.api.checkout object:

api: {
  checkout: `/api/ls/checkout`
}

For example, we can change the checkout endpoint to /api/ls/checkout (Lemon Squeezy).

Additionally, we remove the billingPortal endpoint, as Lemon Squeezy doesn't support it.

Step 6: Update the checkout API handler

Now, we can update the checkout API handler to replace Stripe with Lemon Squeezy.

We create a new file at pages/api/ls/checkout.ts. This endpoint will create a new Lemon Squuezy checkout session, and will redirect the current user to Lemon Squeezy's checkout page.

Lemon Squeezy Client

We create a basic client for Lemon Squeezy to handle the API calls. We can create a new file at lib/ls/lemon-squeezy-client.ts:

const BASE_URL = 'https://api.lemonsqueezy.com';
 
export function getLemonSqueezyClient() {
  const apiKey = process.env.LEMON_SQUEEZY_API_KEY;
 
  if (!apiKey) {
    throw new Error('Missing LEMON_SQUEEZY_API_KEY environment variable');
  }
 
  const request = async function <Data = unknown>(params: {
    path: string;
    body: string;
    method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
  }) {
    const url = [BASE_URL, params.path].join('/');
 
    const response = await fetch(url, {
      headers: getHeaders(apiKey),
      body: params.body,
      method: params.method,
    });
 
    if (!response.ok) {
      throw new Error(
        `Request failed with status code ${response.status}: ${response.statusText}`
      );
    }
 
    const data = await response.json();
 
    return data as Data;
  };
 
  return {
    request,
  };
}
 
function getHeaders(apiKey: string) {
  return {
    Accept: 'application/vnd.api+json',
    'Content-Type': 'application/vnd.api+json',
    Authorization: `Bearer ${apiKey}`,
  };
}

We can now write a utility to create a new checkout session using the Lemon Squeezy API:

import { getLemonSqueezyClient } from '~/lib/ls/lemon-squeezy-client';
import CreateCheckoutResponse from '~/lib/ls/types/create-checkout-response';
 
export async function createLemonSqueezyCheckout(params: {
  organizationId: string;
  variantId: number;
  storeId: number;
  returnUrl: string;
}) {
  const client = getLemonSqueezyClient();
  const path = 'v1/checkouts';
 
  return client.request<CreateCheckoutResponse>({
    path,
    method: 'POST',
    body: JSON.stringify({
      data: {
        type: 'checkouts',
        attributes: {
          checkout_data: {
            custom: {
              organization_id: params.organizationId,
            },
          },
          product_options: {
            redirect_url: params.returnUrl,
          },
        },
        relationships: {
          store: {
            data: {
              type: 'stores',
              id: params.storeId.toString(),
            },
          },
          variant: {
            data: {
              type: 'variants',
              id: params.variantId.toString(),
            },
          },
        },
      },
    }),
  });
}

As you may have noticed, we have also added some custom data containing the organization ID submitting the request. We will use this data to be able to identify the organization that is purchasing the subscription in the webhook.

We will provide three different examples, depending on which kit you're using. You can choose the one that best suits your needs.

Since these examples are gonna be rather long and involve files that are not in the scope of this tutorial. Please use these as reference, but it's best to copy the code from the repository, or use the Git patches to automate the process.

The example below is valid for the Next.js Firebase kit.

const SUPPORTED_METHODS: HttpMethod[] = ['POST'];
 
async function checkoutsSessionHandler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { headers, firebaseUser } = req;
 
  const bodyResult = getBodySchema().safeParse(req.body);
  const userId = firebaseUser.uid;
  const currentOrganizationId = req.cookies.organizationId;
 
  const redirectToErrorPage = () => {
    const referer = getApiRefererPath(headers);
    const url = join(referer, `?error=true`);
 
    return res.redirect(url);
  };
 
  if (!bodyResult.success) {
    return redirectToErrorPage();
  }
 
  const { organizationId, returnUrl, variantId } = bodyResult.data;
 
  const matchesSessionOrganizationId = currentOrganizationId === organizationId;
 
  if (!matchesSessionOrganizationId) {
    return redirectToErrorPage();
  }
 
  // check the user's role has access to the checkout
  const canChangeBilling = await getUserCanAccessCheckout({
    organizationId,
    userId,
  });
 
  // disallow if the user doesn't have permissions to change
  // billing settings based on its role. To change the logic, please update
  // {@link canChangeBilling}
  if (!canChangeBilling) {
    logger.debug(
      {
        userId,
        organizationId,
      },
      `User attempted to access checkout but lacked permissions`
    );
 
    return redirectToErrorPage();
  }
 
  try {
    const storeId = getStoreId();
 
    const response = await createLemonSqueezyCheckout({
      organizationId,
      variantId,
      returnUrl,
      storeId,
    });
 
    const url = response.data.attributes.url;
 
    // redirect user back based on the response
    res.redirect(HttpStatusCode.SeeOther, url);
  } catch (e) {
    logger.error(e, `Lemon Squeezy Checkout error`);
 
    return redirectToErrorPage();
  }
}
 
export default withPipe(
  withCsrf((req) => req.body.csrfToken),
  withMethodsGuard(SUPPORTED_METHODS),
  withAuthedUser,
  checkoutsSessionHandler
);
 
async function getUserCanAccessCheckout(params: {
  organizationId: string;
  userId: string;
}) {
  try {
    const userRole = await getUserRoleByOrganization(params);
 
    if (userRole === undefined) {
      return false;
    }
 
    return canChangeBilling(userRole);
  } catch (e) {
    logger.error(e, `Could not retrieve user role`);
 
    return false;
  }
}
 
function getStoreId() {
  const storeId = process.env.LEMON_SQUEEZY_STORE_ID;
 
  if (storeId === undefined) {
    throw new Error(`LEMON_SQUEEZY_STORE_ID is not defined`);
  }
 
  return Number(storeId);
}
 
function getBodySchema() {
  return z.object({
    organizationId: z.string().min(1),
    variantId: z.number().min(1),
    returnUrl: z.string().min(1),
  });
}

The user will then get redirected to the Lemon Squeezy checkout page. After the user completes the checkout, they will be redirected back to the returnUrl we provided.

This takes care of the checkout flow. The next step is to handle the webhook that will be triggered when the user completes the checkout.

Step 6: Handle the webhooks

To receive updates from Lemon Squeezy after the checkout is processed, we need to create a webhook. We will use the webhook to update the user's subscription status, or to cancel it.

First, let's define the webhooks enum containing the event topics sent by Lemon Squeezy:

enum LemonSqueezyWebhooks {
  SubscriptionCreated = 'subscription_created',
  SubscriptionUpdated = 'subscription_updated',
  SubscriptionPaymentSuccess = 'subscription_payment_success',
}
 
export default LemonSqueezyWebhooks;

Then, we update the function that builds an organization, and the organization subscription objects, so we can update these with the data sent by Lemon Squeezy:

export type OrganizationSubscriptionStatus =
  | 'on_trial'
  | 'active'
  | 'paused'
  | 'past_due'
  | 'unpaid'
  | 'cancelled'
  | 'expired';
 
export interface OrganizationSubscription {
  id: string;
  variantId: number;
 
  status: OrganizationSubscriptionStatus;
  cancelAtPeriodEnd: boolean;
  billingAnchor: number;
 
  createdAt: UnixTimestamp;
  endsAt: UnixTimestamp | null;
  renewsAt: UnixTimestamp;
  trialEndsAt: UnixTimestamp | null;
}

And below, is the function that maps a response from the Lemon Squeezy API to an organization subscription object:

import { OrganizationSubscription } from '~/lib/organizations/types/organization-subscription';
import { SubscriptionWebhookResponse } from '~/lib/ls/types/subscription-webhook-response';
 
export function buildOrganizationSubscription(
  subscription: SubscriptionWebhookResponse
): OrganizationSubscription {
  const attrs = subscription.data.attributes;
  const status = attrs.status;
  const variantId = attrs.variant_id;
  const id = subscription.data.id;
  const createdAt = new Date(attrs.created_at).getTime();
  const endsAt = attrs.ends_at ? new Date(attrs.ends_at).getTime() : null;
  const renewsAt = new Date(attrs.renews_at).getTime();
  const trialEndsAt = new Date(attrs.trial_ends_at).getTime();
 
  return {
    id,
    variantId,
    status,
    billingAnchor: attrs.billing_anchor,
    cancelAtPeriodEnd: attrs.cancelled,
    endsAt,
    createdAt,
    renewsAt,
    trialEndsAt,
  };
}
Update the types

These changes will require you to update the types. To do so, run the TS checker, and then fix the errors.

Following these changes, we can now write the webhook handler:

import { NextApiRequest, NextApiResponse } from 'next';
import getRawBody from 'raw-body';
 
import logger from '~/core/logger';
import { throwInternalServerErrorException } from '~/core/http-exceptions';
 
import { withPipe } from '~/core/middleware/with-pipe';
import { withMethodsGuard } from '~/core/middleware/with-methods-guard';
import { withAdmin } from '~/core/middleware/with-admin';
import { withExceptionFilter } from '~/core/middleware/with-exception-filter';
 
import {
  deleteOrganizationSubscription,
  setOrganizationSubscription,
  updateSubscriptionById,
} from '~/lib/server/organizations/subscriptions';
 
import { buildOrganizationSubscription } from '~/lib/ls/build-organization-subscription';
import { createHmac, timingSafeEqual } from 'crypto';
import LemonSqueezyWebhooks from '~/lib/ls/types/webhooks.enum';
 
import { SubscriptionWebhookResponse } from '~/lib/ls/types/subscription-webhook-response';
 
const SUPPORTED_HTTP_METHODS: HttpMethod[] = ['POST'];
 
// NB: we disable body parser to receive the raw body string. The raw body
// is fundamental to verify that the request is genuine
export const config = {
  api: {
    bodyParser: false,
  },
};
 
/**
 * @description Handle the webhooks from Lemon Squeezy related to checkouts
 */
async function checkoutWebhooksHandler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const eventName = req.headers['x-event-name'];
  const signature = req.headers['x-signature'] as string;
 
  const rawBody = await getRawBody(req);
 
  if (req.method !== 'POST') {
    return res.status(405).send('Method Not Allowed');
  }
 
  if (!signature) {
    console.error(`Signature header not found`);
 
    return res.status(401).send('Signature header not found');
  }
 
  if (!isSigningSecretValid(rawBody, signature)) {
    console.error(`Signing secret is invalid`);
 
    return res.status(401).send('Unauthorized');
  }
 
  const body = JSON.parse(rawBody.toString());
 
  logger.info(
    {
      type: eventName,
    },
    `[Lemon Squeezy] Received Webhook`
  );
 
  console.log(body);
 
  try {
    switch (eventName) {
      case LemonSqueezyWebhooks.SubscriptionCreated: {
        await onCheckoutCompleted(body as SubscriptionWebhookResponse);
 
        break;
      }
 
      case LemonSqueezyWebhooks.SubscriptionUpdated: {
        const isSubscriptionCanceled =
          body.data.attributes.status === 'canceled';
 
        const id = body.data.id;
 
        if (isSubscriptionCanceled) {
          await deleteOrganizationSubscription(id);
        } else {
          await onSubscriptionUpdated(body as SubscriptionWebhookResponse);
        }
 
        break;
      }
    }
 
    return respondOk(res);
  } catch (e) {
    logger.error(
      {
        type: eventName,
      },
      `[Lemon Squeezy] Webhook handling failed`
    );
 
    logger.debug(e);
 
    return throwInternalServerErrorException();
  }
}
 
export default function lemonSqueezyCheckoutsWebhooksHandler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const handler = withPipe(
    withMethodsGuard(SUPPORTED_HTTP_METHODS),
    withAdmin,
    checkoutWebhooksHandler
  );
 
  return withExceptionFilter(req, res)(handler);
}
 
/**
 * @description When the checkout is completed, we store the order. The
 * subscription is only activated if the order was paid successfully.
 * Otherwise, we have to wait for a further webhook
 */
async function onCheckoutCompleted(response: SubscriptionWebhookResponse) {
  const attrs = response.data.attributes;
 
  // we have passed this data in the checkout
  const organizationId = response.meta.custom_data.organization_id;
  const customerId = attrs.customer_id;
 
  // build organization subscription and set on the organization document
  // we add just enough data in the DB, so we do not query
  // LS for every bit of data
  // if you need your DB record to contain further data
  // add it to {@link buildOrganizationSubscription}
  const subscriptionData = buildOrganizationSubscription(response);
 
  return setOrganizationSubscription({
    customerId,
    organizationId,
    subscription: subscriptionData,
  });
}
 
async function onSubscriptionUpdated(
  subscription: SubscriptionWebhookResponse
) {
  const subscriptionData = buildOrganizationSubscription(subscription);
 
  return updateSubscriptionById(subscriptionData);
}
 
function respondOk(res: NextApiResponse) {
  res.status(200).send({ success: true });
}
 
function isSigningSecretValid(rawBody: Buffer, signatureHeader: string) {
  const SIGNING_SECRET = process.env.LEMON_SQUEEZY_SIGNING_SECRET;
 
  if (!SIGNING_SECRET) {
    throw new Error('Missing signing secret. Add "SIGNING_SECRET"');
  }
 
  const hmac = createHmac('sha256', SIGNING_SECRET);
 
  const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8');
  const signature = Buffer.from(signatureHeader, 'utf8');
 
  return timingSafeEqual(digest, signature);
}

🎉 Et voilá! We have now a webhook handler that can handle the subscription created and subscription updated events from Lemon Squeezy!

Testing Locally

To test the webhook handler locally, we can use the ngrok tool.

We add the following command to the package.json file:

"ngrok": "npx ngrok http 3000"

And you can run it with:

npm run ngrok

You can also use other alternatives, such as Localtunnel or Cloudflare Tunnel.

Creating the Webhook In Lemon Squeezy

We need to create a Webhook from the Lemon Squeezy Dashboard so that we can receive the events to our webhook handler.

You will be prompted for the following:

  • URL: point it to the ngrok URL
  • Secret: the secret LEMONS_SQUEEZY_SIGNATURE_SECRET that you have set in the .env file
  • Events: select the events that you want to receive. In our case, we want to receive the subscription_created and subscription_updated events.

Step 7: Removing the Billing Portal from the Checkout components

Since Lemon Squeezy does not support the billing portal, we have to remove it from the checkout components.

Please remove all the instances of the component BillingPortalRedirectButton from the checkout components.

Step 8: Updating subscriptions

We have to update the subscription when the user updates the subscription plan.

To do so, we will create:

  1. a new endpoint at api/ls/subscription/[subscription].tsx that can handle the PATCH, PUT and DELETE HTTP methods
  2. a React hook that will call the endpoint and update the subscription
  3. a new component that will use the hook to update the subscription. It will show the pricing table and the button to update the subscription.

Creating the endpoint

Below is the code for the endpoint, also including the methods for switching the subscription plan, canceling the subscription and resuming the subscription.

import { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';
 
import logger from '~/core/logger';
 
import { withAuthedUser } from '~/core/middleware/with-authed-user';
import { withPipe } from '~/core/middleware/with-pipe';
import { withMethodsGuard } from '~/core/middleware/with-methods-guard';
 
import { canChangeBilling } from '~/lib/organizations/permissions';
import withCsrf from '~/core/middleware/with-csrf';
 
import { getUserRoleByOrganization } from '~/lib/server/organizations/memberships';
 
import {
  throwForbiddenException,
  throwInternalServerErrorException,
} from '~/core/http-exceptions';
 
import { unsubscribePlan } from '~/lib/ls/unsubscribe-plan';
import { updateSubscription } from '~/lib/ls/update-subscription';
 
import configuration from '~/configuration';
import { getOrganizationBySubscriptionId } from '~/lib/server/organizations/subscriptions';
import { resumeSubscription } from '~/lib/ls/resume-subscription';
 
const SUPPORTED_METHODS: HttpMethod[] = ['PUT', 'PATCH', 'DELETE'];
 
async function subscriptionHandler(req: NextApiRequest, res: NextApiResponse) {
  const { firebaseUser } = req;
 
  const userId = firebaseUser.uid;
  const subscriptionId = req.query.subscription as string;
 
  try {
    const organization = await getOrganization(subscriptionId);
 
    const organizationId = organization.id;
    const subscription = organization?.subscription;
 
    if (!organization || !subscription) {
      logger.error(
        {
          subscriptionId,
        },
        `Subscription not found. Cannot unsubscribe.`
      );
 
      return throwInternalServerErrorException();
    }
 
    // check the user's role has access to the checkout
    const canChangeBilling = await getUserCanAccessCheckout({
      organizationId,
      userId,
    });
 
    // disallow if the user doesn't have permissions to change
    // billing settings based on its role. To change the logic, please update
    // {@link canChangeBilling}
    if (!canChangeBilling) {
      logger.debug(
        {
          userId,
          organizationId,
        },
        `User attempted to update the subscription but lacked permissions`
      );
 
      return throwForbiddenException();
    }
 
    switch (req.method) {
      case 'PUT': {
        const variantId = getVariantSchema().parse(req.body).variantId;
        const product = findProductByVariantId(variantId);
 
        if (!product || !product.productId) {
          logger.error(
            {
              organizationId,
              subscription,
            },
            `Subscription product not found. Cannot update subscription. Did you add the ID to the configuration?`
          );
 
          return throwInternalServerErrorException();
        }
 
        logger.info(
          {
            organizationId,
            subscriptionId: subscription.id,
            variantId,
            productId: product.productId,
          },
          `Updating subscription plan.`
        );
 
        await updateSubscription({
          subscriptionId,
          productId: product.productId,
          variantId,
        });
 
        logger.info(
          {
            organizationId,
            subscriptionId: subscription.id,
            variantId,
            productId: product.productId,
          },
          `Plan successfully updated.`
        );
 
        break;
      }
 
      case 'PATCH':
        logger.info(
          {
            organizationId,
            subscriptionId: subscription.id,
          },
          `Resuming subscription plan.`
        );
 
        await resumeSubscription({
          subscriptionId: subscription.id,
        });
 
        logger.info(
          {
            organizationId,
            subscriptionId: subscription.id,
          },
          `Subscription plan successfully resumed.`
        );
 
        break;
 
      case 'DELETE': {
        logger.info(
          {
            organizationId,
            subscriptionId: subscription.id,
          },
          `Deleting subscription plan.`
        );
 
        await unsubscribePlan({
          subscriptionId: subscription.id,
        });
 
        logger.info(
          {
            organizationId,
            subscriptionId: subscription.id,
          },
          `Subscription plan successfully deleted.`
        );
 
        break;
      }
    }
 
    res.status(200).json({
      success: true,
    });
  } catch (e) {
    logger.error(e, `Lemon Squeezy Subscription error`);
 
    return throwInternalServerErrorException();
  }
}
 
export default withPipe(
  withCsrf(),
  withMethodsGuard(SUPPORTED_METHODS),
  withAuthedUser,
  subscriptionHandler
);
 
async function getUserCanAccessCheckout(params: {
  organizationId: string;
  userId: string;
}) {
  try {
    const userRole = await getUserRoleByOrganization(params);
 
    if (userRole === undefined) {
      return false;
    }
 
    return canChangeBilling(userRole);
  } catch (e) {
    logger.error(e, `Could not retrieve user role`);
 
    return false;
  }
}
 
function getBodySchema() {
  return z.object({
    organizationId: z.string().min(1),
    customerId: z.coerce.number().min(1),
  });
}
 
function getVariantSchema() {
  return z.object({
    variantId: z.coerce.number().min(1),
  });
}
 
async function getOrganization(subscriptionId: string) {
  return getOrganizationBySubscriptionId(subscriptionId)
    .then((ref) => ref.get())
    .then((doc) => {
      return {
        id: doc.id,
        ...doc.data(),
      };
    });
}
 
function findProductByVariantId(variantId: number) {
  const products = configuration.subscriptions.products;
 
  for (const product of products) {
    for (const plan of product.plans) {
      if (plan.variantId === variantId) {
        return product;
      }
    }
  }
}

Then, we create the hook that will call the endpoint and update the subscription.

import useSWRMutation from 'swr/mutation';
import { useApiRequest } from '~/core/hooks/use-api';
 
interface Params {
  variantId: number;
  subscriptionId: number;
}
 
export function useUpdatePlan() {
  const fetcher = useApiRequest<
    void,
    {
      variantId: number;
    }
  >();
 
  const key = ['update-plan'];
 
  return useSWRMutation(key, async (_, { arg }: { arg: Params }) => {
    return fetcher({
      method: 'PUT',
      path: `/api/ls/subscription/${arg.subscriptionId}`,
      body: {
        variantId: arg.variantId,
      },
    });
  });
}

Finally, we create the component that we use to let users switch to another plan:

import React, { useState } from 'react';
import { useRouter } from 'next/router';
import { Trans } from 'next-i18next';
 
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
import classNames from 'classnames';
import { Close as DialogPrimitiveClose } from '@radix-ui/react-dialog';
 
import { useUpdatePlan } from '~/lib/ls/hooks/use-update-plan';
 
import { Dialog, DialogContent, DialogTrigger } from '~/core/ui/Dialog';
import Button from '~/core/ui/Button';
import PricingTable from '~/components/PricingTable';
import IconButton from '~/core/ui/IconButton';
 
import Heading from '~/core/ui/Heading';
import SubHeading from '~/core/ui/SubHeading';
import If from '~/core/ui/If';
 
function UpdateSubscriptionPlanContainer(
  props: React.PropsWithChildren<{
    subscriptionId: number;
    currentPlanVariantId: number;
  }>
) {
  const router = useRouter();
  const [updateRequested, setUpdateRequested] = useState(false);
  const { isMutating, trigger } = useUpdatePlan();
 
  return (
    <>
      <Dialog open={updateRequested} onOpenChange={setUpdateRequested}>
        <DialogTrigger asChild>
          <Button variant={'flat'}>
            <Trans i18nKey={'subscription:switchPlan'} />
          </Button>
        </DialogTrigger>
 
        <DialogContent
          className={
            'flex h-auto w-full !max-w-none bg-white/95 py-8 lg:w-9/12' +
            ' rounded-xl dark:bg-black-500/95' +
            ' flex-col items-center'
          }
        >
          <DialogPrimitiveClose asChild>
            <IconButton
              className={'absolute right-6 top-6 flex items-center'}
              label={'Close Modal'}
              onClick={() => setUpdateRequested(false)}
            >
              <XMarkIcon className={'h-8'} />
              <span className="sr-only">Close</span>
            </IconButton>
          </DialogPrimitiveClose>
 
          <div className={'flex flex-col space-y-8'}>
            <div className={'flex flex-col space-y-2'}>
              <Heading type={3}>
                <Trans i18nKey={'subscription:updatePlanModalHeading'} />
              </Heading>
 
              <SubHeading>
                <Trans i18nKey={'subscription:updatePlanModalSubheading'} />
              </SubHeading>
            </div>
 
            <div className={'flex flex-col space-y-2 text-sm'}>
              <PricingTable
                CheckoutButton={({ variantId, recommended }) => {
                  if (!variantId || variantId === props.currentPlanVariantId) {
                    return null;
                  }
 
                  const subscriptionId = props.subscriptionId;
 
                  return (
                    <UpdatePricingPlanCheckoutButton
                      recommended={recommended}
                      loading={isMutating}
                      onClick={async () => {
                        await trigger({ variantId, subscriptionId });
                        await router.reload();
                      }}
                    />
                  );
                }}
              />
            </div>
 
            <div>
              <Button
                color={'transparent'}
                onClick={() => setUpdateRequested(false)}
              >
                <Trans i18nKey={'common:cancel'} />
              </Button>
            </div>
          </div>
        </DialogContent>
      </Dialog>
    </>
  );
}
 
function UpdatePricingPlanCheckoutButton(
  props: React.PropsWithChildren<{
    onClick: () => void;
    recommended?: boolean;
    loading?: boolean;
  }>
) {
  const [confirm, setConfirm] = useState(false);
 
  return (
    <Button
      color={props.recommended ? 'custom' : 'secondary'}
      className={classNames({
        ['bg-primary-contrast hover:bg-primary-contrast/90' +
        ' font-bold text-gray-900']: props.recommended,
      })}
      loading={props.loading}
      onClick={confirm ? props.onClick : () => setConfirm(true)}
    >
      <span className={'flex w-full justify-center space-x-2'}>
        <If
          condition={confirm}
          fallback={<Trans i18nKey={'subscription:switchToPlan'} />}
        >
          <CheckIcon className={'h-5'} />
 
          <span>
            <Trans i18nKey={'subscription:confirmSwitchPlan'} />
          </span>
        </If>
      </span>
    </Button>
  );
}
 
export default UpdateSubscriptionPlanContainer;

Conclusion

In this recipe, we have migrated a Makerkit kit from Stripe to Lemon Squeezy.

While the above is not a complete guide, it should give you a good idea of how to migrate from Stripe to Lemon Squeezy.

If you are a Makerkit license holder, you can get a git patch with the changes we made to the kit. This patch will automatically migrate your kit to Lemon Squeezy.

To do so, simply download the patch, and apply it from the root of your kit

git apply makerkit-next-fire-stripe-to-ls.patch

Subscribe to our Newsletter
Get the latest updates about React, Remix, Next.js, Firebase, Supabase and Tailwind CSS