Building a SaaS Billing Portal in Next.js with Lemon Squeezy

In this tutorial, you’ll learn how to build a comprehensive billing portal for a SaaS application using Next.js and Lemon Squeezy.


Billing portal essentials

Adding billing features to a SaaS application involves several key components. Here are the essential features you’ll need to build:

  • UI for selecting plans and signing up
  • a way to sync plan data from the billing provider
  • a way to listen to webhooks to sync subscription data
  • UI for changing plans
  • UI for cancelling/resuming subscriptions
  • UI for pausing/unpausing subscriptions
  • UI for updating payment methods

In this tutorial we’ll group these into several topics:

  1. Introduction
  2. Signing up and selecting plans
  3. Syncing data
  4. Displaying subscription data
  5. Updating subscriptions

Introduction

Code from this tutorial is a simplified version of a fully-functioning Lemon Squeezy Next.js billing app template. It is built as a skeleton project that you can fork and use to build your SaaS application on top of.

The app is built using the following stack and tools:

Create a billing page

In your /app folder, create a new folder called dashboard and then inside that folder create anoher one called billing, finally create a new file called page.tsx, which will be the main page for all billing-related UI.

/* src/app/dashboard/billing/page.tsx */
 
export default function BillingPage() {
  return (
    <div>
      <h1>Billing</h1>
    </div>
  )
}

Install lemonsqueezy.js

We will be using the official Lemon Squeezy JS SDK in this tutorial. It fully covers the Lemon Squeezy API.

You can install it with PNPM or your preferred package manager:

pnpm install @lemonsqueezy/lemonsqueezy.js

Set up the API

Now, we need a Lemon Squeezy API key to make requests to the Lemon Squeezy API. Go to app.lemonsqueezy.com/settings/api to create a new key, then save it somewhere in the app, for example in an .env file:

LEMONSQUEEZY_API_KEY=your-api-key

For more information on how to create an API key, see the Developer Guide.

You will also need to save the ID of the store you’re selling from. Lemon Squeezy API keys are tied to users, not stores, and API requests will pull data from all stores you have access to if you have more than one. You can find your store ID at Settings » Stores.

After obtaining the store ID, your .env file could look like this:

LEMONSQUEEZY_API_KEY={API_KEY}
LEMONSQUEEZY_STORE_ID={STORE_ID}
LEMONSQUEEZY_WEBHOOK_SECRET={WEBHOOK_SECRET}

Signing up and selecting plans

This part of the billing portal lets users view your available plans and start a subscription.

With Lemon Squeezy, starting a subscription means sending users to a checkout URL. Once they pay, the subscription is started and they should then have access to your application.

Get the plans from Lemon Squeezy

Before moving on with the tutorial, make sure that you have already created your products and variants in Lemon Squeezy.

To get the plans from Lemon Squeezy, we will use the listProducts method from the Lemon Squeezy SDK. We will also include the variants in the response.

Then, we need a Plan model in the database to store the products and variations data. Here’s an example of what the model could look like (Drizzle ORM and PostgreSQL are used in this tutorial):

/* src/db/schema.ts */
export const plans = pgTable('plan', {
  id: serial('id').primaryKey(),
  productId: integer('productId').notNull(),
  productName: text('productName'),
  variantId: integer('variantId').notNull().unique(),
  name: text('name').notNull(),
  description: text('description'),
  price: text('price').notNull(),
  isUsageBased: boolean('isUsageBased').default(false),
  interval: text('interval'),
  intervalCount: integer('intervalCount'),
  trialInterval: text('trialInterval'),
  trialIntervalCount: integer('trialIntervalCount'),
  sort: integer('sort'),
})

We will use the listProducts function with include: ['variants'] to get the products and variants from Lemon Squeezy. We will then parse the data and store the plans in our database.

For the simplicity of this tutorial, the sync function is designed to execute on the initial page load if the plans have not been synchronized already, but can be easily adapted to run as a background job or an API endpoint that listens for webhook events.

Before we start sending API requests to Lemon Squeezy we need to configure the SDK with Lemon Squeezy API key. To do that we can create a new file called lemonsqueezy.ts in the /src/config folder.

// src/config/lemonsqueezy.ts
 
/**
 * Ensures that required environment variables are set and sets up the Lemon
 * Squeezy JS SDK. Throws an error if any environment variables are missing or
 * if there's an error setting up the SDK.
 */
export function configureLemonSqueezy() {
  const requiredVars = [
    'LEMONSQUEEZY_API_KEY',
    'LEMONSQUEEZY_STORE_ID',
    'LEMONSQUEEZY_WEBHOOK_SECRET',
  ]
 
  const missingVars = requiredVars.filter((varName) => !process.env[varName])
 
  if (missingVars.length > 0) {
    throw new Error(
      `Missing required LEMONSQUEEZY env variables: ${missingVars.join(
        ', '
      )}. Please, set them in your .env file.`
    )
  }
 
  lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY })
}

Now, let’s use the helper function in our code.

The syncPlans function gets Product data from Lemon Squeezy API filtered by storeID, that we set earlier in the .env file. We are also using the additional include parameter to query related variants in the same API request.

Then, we loop through all the variants and pick the relevant data from the response and save it into the database using the helper function _addVariant.

/* src/app/actions.ts */
'use server'
 
import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js'
 
export async function syncPlans() {
  configureLemonSqueezy()
 
  // Fetch all the variants from the database.
  const productVariants: NewPlan[] = await db.select().from(plans)
 
  // Helper function to add a variant to the productVariants array and sync it with the database.
  async function _addVariant(variant: NewPlan) {
    // eslint-disable-next-line no-console -- allow
    console.log(`Syncing variant ${variant.name} with the database...`)
 
    // Sync the variant with the plan in the database.
    await db
      .insert(plans)
      .values(variant)
      .onConflictDoUpdate({ target: plans.variantId, set: variant })
 
    /* eslint-disable no-console -- allow */
    console.log(`${variant.name} synced with the database...`)
 
    productVariants.push(variant)
  }
 
  // Fetch products from the Lemon Squeezy store.
  const products = await listProducts({
    filter: { storeId: process.env.LEMONSQUEEZY_STORE_ID },
    include: ['variants'],
  })
 
  // Loop through all the variants.
  const allVariants = products.data?.included as Variant['data'][] | undefined
 
  // for...of supports asynchronous operations, unlike forEach.
  if (allVariants) {
    /* eslint-disable no-await-in-loop -- allow */
    for (const v of allVariants) {
      const variant = v.attributes
 
      // Skip draft variants or if there's more than one variant, skip the default
      // variant. See https://docs.lemonsqueezy.com/api/variants
      if (
        variant.status === 'draft' ||
        (allVariants.length !== 1 && variant.status === 'pending')
      ) {
        // `return` exits the function entirely, not just the current iteration.
        // so use `continue` instead.
        continue
      }
 
      // Fetch the Product name.
      const productName =
        (await getProduct(variant.product_id)).data?.data.attributes.name ?? ''
 
      // Fetch the Price object.
      const variantPriceObject = await listPrices({
        filter: {
          variantId: v.id,
        },
      })
 
      const currentPriceObj = variantPriceObject.data?.data.at(0)
      const isUsageBased =
        currentPriceObj?.attributes.usage_aggregation !== null
      const interval = currentPriceObj?.attributes.renewal_interval_unit
      const intervalCount =
        currentPriceObj?.attributes.renewal_interval_quantity
      const trialInterval = currentPriceObj?.attributes.trial_interval_unit
      const trialIntervalCount =
        currentPriceObj?.attributes.trial_interval_quantity
 
      const price = isUsageBased
        ? currentPriceObj?.attributes.unit_price_decimal
        : currentPriceObj.attributes.unit_price
 
      const priceString = price !== null ? price?.toString() ?? '' : ''
 
      const isSubscription =
        currentPriceObj?.attributes.category === 'subscription'
 
      // If not a subscription, skip it.
      if (!isSubscription) {
        continue
      }
 
      await _addVariant({
        name: variant.name,
        description: variant.description,
        price: priceString,
        interval,
        intervalCount,
        isUsageBased,
        productId: variant.product_id,
        productName,
        variantId: parseInt(v.id) as unknown as number,
        trialInterval,
        trialIntervalCount,
        sort: variant.sort,
      })
    }
  }
 
  return productVariants
}

Now, you can go to http://localhost:3000/dashboard/billing to sync your plan information to your database.

View the full code on Github.

Show plans in the app

Now, we are ready to display the plans in our app. Before we start, we can export types for our Drizzle models by using $inferInsert.

export type NewPlan = typeof plans.$inferInsert;

Now, let’s create a new component called Plan to display the plan information. This component will be used in the Plans component, which will display all the plans.

/* src/components/dashboard/billing/plans/plan.tsx */
 
export function Plan({ plan }: { plan: NewPlan }) {
  const { description, productName, name, price } = plan
 
  return (
    <div>
      <h2>
        {productName} ({name})
      </h2>
 
      {description ? (
        <div
          dangerouslySetInnerHTML={{
            // Ideally sanitize the description first.
            __html: description,
          }}
        ></div>
      ) : null}
 
      <p>${price}</p>
    </div>
  )
}

The final code adds more styling to the Plan component, but for this tutorial we’ll keep it simple.

View the full code on Github.

Now, let’s create the Plans component to fetch the plans and display it in the app.

/* src/components/dashboard/billing/plans/plans.ts */
export async function Plans() {
  let allPlans: NewPlan[] = await db.select().from(plans)
 
  // If there are no plans in the database, sync them from Lemon Squeezy.
  // You might want to add logic to sync plans periodically or a webhook handler.
  if (!allPlans.length) {
    allPlans = await syncPlans()
  }
 
  if (!allPlans.length) {
    return <p>No plans available.</p>
  }
 
  return (
    <div>
      <h2>Plans</h2>
 
      <div className="mb-5 mt-3 grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-5">
        {allPlans.map((plan, index) => {
          return <Plan key={`plan-${index}`} plan={plan} />
        })}
      </div>
    </div>
  )
}

Then we can add this component to our billing page to show the plans. We also need to wrap the Plans component in a Suspense component to show a loading state while the plans are being fetched.

/* src/app/dashboard/billing/page.tsx */
 
import { Suspense } from 'react'
import { Plans } from '@/components/dashboard/billing/plans/plans'
 
export const dynamic = 'force-dynamic'
 
export default function BillingPage() {
  return (
    <div>
      <Suspense fallback={<p>Loading plans...</p>}>
        <Plans />
      </Suspense>
    </div>
  )
}

At this point you should see your plans on the dashboard/billing page.

Lemon Squeezy: Plans

Add sessions

Before we get too far, we should start adding session information in our app so that logged in users can be linked to purchased subscriptions.

In this tutorial we are using Auth.js v5 and GitHub for login.

Following the Auth.js installation guide, we create a src/app/auth.ts file to configure the authentication options with Drizzle Adapter and GitHub credentials.

// src/app/auth.ts
 
import NextAuth from 'next-auth'
import { DrizzleAdapter } from '@auth/drizzle-adapter'
import { authConfig } from '@/config/auth'
import { db } from '@/db/schema'
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  pages: {
    signIn: '/',
  },
  basePath: '/api/auth',
  callbacks: {
    authorized: ({ request: { nextUrl }, auth: midAuth }) => {
      const isLoggedIn = Boolean(midAuth?.user)
      const isOnDashboard = nextUrl.pathname.startsWith('/dashboard')
 
      if (isOnDashboard) {
        // Redirect unauthenticated users to the login page
        return isLoggedIn
      } else if (isLoggedIn) {
        // Redirect authenticated users to the dashboard
        return Response.redirect(new URL('/dashboard', nextUrl))
      }
 
      // Allow unauthenticated users to access other pages
      return true
    },
  },
  ...authConfig,
})

And the authConfig object is defined in a separate file, src/config/auth.ts:

import GitHub from 'next-auth/providers/github'
import type { NextAuthConfig } from 'next-auth'
 
export const authConfig = {
  providers: [GitHub],
} satisfies NextAuthConfig

Auth.js will automatically configure GitHub login as long as you provide the AUTH_GITHUB_ID and AUTH_GITHUB_SECRET in your .env file.

You can find the additional information on how to obtain GitHub credentials in the GitHub documentation.

The authorized callback is used to verify if the request is authorized to access a page via Next.js Middleware. It is called before a request is completed, and it receives an object with the auth and request properties. The auth property contains the user’s session, and the request property contains the incoming request.

For that to work correctly we need to create a new file middleware.ts in the /src folder and add the following code:

// src/middleware.ts
 
export { auth as middleware } from '@/app/auth'
 
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}
 
// Optional
export const runtime = 'edge'

View full auth code on Github.

Creating checkouts and letting customers subscribe

Now we are ready to let customers click on the “Sign up” buttons in our plan table and start paying for access to the application.

To do this we’ll create a new component called SignupButton, which you can then drop into the Plan component.

/* /components/dashboard/billing/plans/signup-button.tsx */
 
'use client'
 
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Loading } from '@lemonsqueezy/wedges'
import { getCheckoutURL } from '@/app/actions'
 
export function SignupButton(props: {
  plan: NewPlan
  currentPlan?: NewPlan
  embed?: boolean
}) {
  const { plan, currentPlan, embed = true } = props
  const router = useRouter()
  const [loading, setLoading] = useState(false)
  const isCurrent = plan.id === currentPlan?.id
 
  const label = isCurrent ? 'Your plan' : 'Sign up'
 
  // Make sure Lemon.js is loaded, you need to enqueue the Lemon Squeezy SDK in your app first.
  useEffect(() => {
    if (typeof window.createLemonSqueezy === 'function') {
      window.createLemonSqueezy()
    }
  }, [])
 
  return (
    <Button
      before={loading ? <Loading /> : null}
      disabled={loading || isCurrent}
      onClick={() => {
        // Create a checkout and open the Lemon.js modal
        let checkoutUrl: string | undefined = ''
 
        try {
          setLoading(true)
          checkoutUrl = await getCheckoutURL(plan.variantId, embed)
        } catch (error) {
          setLoading(false)
          toast('Error creating a checkout.', {
            description:
              'Please check the server console for more information.',
          })
        } finally {
          embed && setLoading(false)
        }
 
        embed
          ? checkoutUrl && window.LemonSqueezy.Url.Open(checkoutUrl)
          : router.push(checkoutUrl ?? '/')
      }}
    >
      {label}
    </Button>
  )
}

We also need to define the getCheckoutURL function in the actions.ts file. This function will create a new checkout and return the URL of the checkout page.

export async function getCheckoutURL(variantId: number, embed = false) {
  configureLemonSqueezy()
 
  const session = await auth()
 
  if (!session?.user) {
    throw new Error('User is not authenticated.')
  }
 
  // import { createCheckout } from '@lemonsqueezy/lemonsqueezy.js'
  const checkout = await createCheckout(
    process.env.LEMONSQUEEZY_STORE_ID!,
    variantId,
    {
      checkoutOptions: {
        embed,
        media: false,
        logo: !embed,
      },
      checkoutData: {
        email: session.user.email ?? undefined,
        custom: {
          user_id: session.user.id,
        },
      },
      productOptions: {
        enabledVariants: [variantId],
        redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing/`,
        receiptButtonText: 'Go to Dashboard',
        receiptThankYouNote: 'Thank you for signing up to Lemon Stand!',
      },
    }
  )
 
  return checkout.data?.data.attributes.url
}

Now when you click on a “Sign up” button, a checkout will be created and loaded in a modal 🎉

Customers can now purchase your subscriptions, but how will your application know about this? Enter, webhooks.

Syncing subscription data

Action in your Lemon Squeezy store can trigger webhooks. Events like new subscriptions, updated subscriptions and new subscription payments can all be sent to your application so that your data is up to date with what’s happening in your store.

Lemon Squeezy: Webhooks

To set up webhooks, go to Settings » Webhooks in Lemon Squeezy. Create a webhook and select at minimum subscription_created and subscription_updated from the list. This will make sure you always have the latest information about every subscription in your app’s data.

In the URL field, you will need to add a publicly-accessible URL. During development you can use a service like ngrok or LocalCan. Whatever domain you use, add /billing/webhook at the end, as this will be the path to our webhook ednpoint.

You will need to add a signing secret in the webhook form, too. This can be any random string. Make sure to copy this secret and add it into your environment variables, for example as LEMONSQUEEZY_WEBHOOK_SECRET. Read about signing requests.

Create webhooks via the SDK

Alternatively, you can use the Lemon Squeezy SDK to create webhooks. The demo app provides a button on the Dashboard page that sends an API request to create a webhook.

View the full code on Github.

Create a webhook endpoint

Now we need to create an API endpoint in our app to listen for webhook events, making sure to check that any incoming data was actually sent from Lemon Squeezy. When deployed, the endpoint will be available publicly to anyone on the internet so we use the signing secret from before to check against the X-Signature header.

In this code we verify the signature and then save the event into the database. You don’t have to save these events, but it adds a fail-safe in case the event isn’t processed properly. It’s also important to return a 200 response quickly so that Lemon Squeezy knows the request was successful.

Drizzle model:

export const webhookEvents = pgTable('webhookEvent', {
  id: integer('id').primaryKey(),
  createdAt: timestamp('createdAt', { mode: 'date' }).notNull().defaultNow(),
  eventName: text('eventName').notNull(),
  processed: boolean('processed').default(false),
  body: jsonb('body').notNull(),
  processingError: text('processingError'),
})

We also need a Subcription model to save subscription data in your application:

export const subscriptions = pgTable('subscription', {
  id: serial('id').primaryKey(),
  lemonSqueezyId: text('lemonSqueezyId').unique().notNull(),
  orderId: integer('orderId').notNull(),
  name: text('name').notNull(),
  email: text('email').notNull(),
  status: text('status').notNull(),
  statusFormatted: text('statusFormatted').notNull(),
  renewsAt: text('renewsAt'),
  endsAt: text('endsAt'),
  trialEndsAt: text('trialEndsAt'),
  price: text('price').notNull(),
  isUsageBased: boolean('isUsageBased').default(false),
  isPaused: boolean('isPaused').default(false),
  subscriptionItemId: serial('subscriptionItemId'),
  userId: text('userId')
    .notNull()
    .references(() => users.id),
  planId: integer('planId')
    .notNull()
    .references(() => plans.id),
})

Here’s the basic endpoint code:

/* src/app/api/webhook/route.ts */
import crypto from 'node:crypto'
import { processWebhookEvent, storeWebhookEvent } from '@/app/actions'
import { webhookHasMeta } from '@/lib/typeguards'
 
export async function POST(request: Request) {
  if (!process.env.LEMONSQUEEZY_WEBHOOK_SECRET) {
    return new Response('Lemon Squeezy Webhook Secret not set in .env', {
      status: 500,
    })
  }
 
  // First, make sure the request is from Lemon Squeezy.
  const rawBody = await request.text()
  const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET
 
  const hmac = crypto.createHmac('sha256', secret)
  const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8')
  const signature = Buffer.from(
    request.headers.get('X-Signature') || '',
    'utf8'
  )
 
  if (!crypto.timingSafeEqual(digest, signature)) {
    throw new Error('Invalid signature.')
  }
 
  const data = JSON.parse(rawBody) as unknown
 
  // Type guard to check if the object has a 'meta' property.
  if (webhookHasMeta(data)) {
    const webhookEventId = await storeWebhookEvent(data.meta.event_name, data)
 
    // Non-blocking call to process the webhook event.
    void processWebhookEvent(webhookEventId)
 
    return new Response('OK', { status: 200 })
  }
 
  return new Response('Data invalid', { status: 400 })
}

View the full code on Github.

To process the webhook event data, add a processWebhookEvent server action that will update the database. If there are any errors, the exception message will be saved into the database making it easier to figure out the issue and re-process the event.

/* src/app/actions.ts */
 
/**
 * Process a webhook event in the database.
 */
export async function processWebhookEvent(webhookEvent: NewWebhookEvent) {
  configureLemonSqueezy()
 
  const dbwebhookEvent = await db
    .select()
    .from(webhookEvents)
    .where(eq(webhookEvents.id, webhookEvent.id))
 
  if (dbwebhookEvent.length < 1) {
    throw new Error(
      `Webhook event #${webhookEvent.id} not found in the database.`
    )
  }
 
  if (!process.env.WEBHOOK_URL) {
    throw new Error(
      'Missing required WEBHOOK_URL env variable. Please, set it in your .env file.'
    )
  }
 
  let processingError = ''
  const eventBody = webhookEvent.body
 
  if (!webhookHasMeta(eventBody)) {
    processingError = "Event body is missing the 'meta' property."
  } else if (webhookHasData(eventBody)) {
    if (webhookEvent.eventName.startsWith('subscription_payment_')) {
      // Save subscription invoices; eventBody is a SubscriptionInvoice
      // Not implemented.
    } else if (webhookEvent.eventName.startsWith('subscription_')) {
      // Save subscription events; obj is a Subscription
      const attributes = eventBody.data.attributes
      const variantId = attributes.variant_id as string
 
      // We assume that the Plan table is up to date.
      const plan = await db
        .select()
        .from(plans)
        .where(eq(plans.variantId, parseInt(variantId, 10)))
 
      if (plan.length < 1) {
        processingError = `Plan with variantId ${variantId} not found.`
      } else {
        // Update the subscription in the database.
 
        const priceId = attributes.first_subscription_item.price_id
 
        // Get the price data from Lemon Squeezy.
        const priceData = await getPrice(priceId)
        if (priceData.error) {
          processingError = `Failed to get the price data for the subscription ${eventBody.data.id}.`
        }
 
        const isUsageBased = attributes.first_subscription_item.is_usage_based
        const price = isUsageBased
          ? priceData.data?.data.attributes.unit_price_decimal
          : priceData.data?.data.attributes.unit_price
 
        const updateData: NewSubscription = {
          lemonSqueezyId: eventBody.data.id,
          orderId: attributes.order_id as number,
          name: attributes.user_name as string,
          email: attributes.user_email as string,
          status: attributes.status as string,
          statusFormatted: attributes.status_formatted as string,
          renewsAt: attributes.renews_at as string,
          endsAt: attributes.ends_at as string,
          trialEndsAt: attributes.trial_ends_at as string,
          price: price?.toString() ?? '',
          isPaused: false,
          subscriptionItemId: attributes.first_subscription_item.id,
          isUsageBased: attributes.first_subscription_item.is_usage_based,
          userId: eventBody.meta.custom_data.user_id,
          planId: plan[0].id,
        }
 
        // Create/update subscription in the database.
        try {
          await db.insert(subscriptions).values(updateData).onConflictDoUpdate({
            target: subscriptions.lemonSqueezyId,
            set: updateData,
          })
        } catch (error) {
          processingError = `Failed to upsert Subscription #${updateData.lemonSqueezyId} to the database.`
          console.error(error)
        }
      }
    } else if (webhookEvent.eventName.startsWith('order_')) {
      // Save orders; eventBody is a "Order"
      /* Not implemented */
    } else if (webhookEvent.eventName.startsWith('license_')) {
      // Save license keys; eventBody is a "License key"
      /* Not implemented */
    }
 
    // Update the webhook event in the database.
    await db
      .update(webhookEvents)
      .set({
        processed: true,
        processingError,
      })
      .where(eq(webhookEvents.id, webhookEvent.id))
  }
}

Now when a customer goes through the checkout, their subscription data will be saved automatically into your database behind the scenes.

Displaying subscription data

The next step is to allow existing customers to view their current subscription details.

Let’s add UI into our billing page to show the current plan, the price the customer is paying as well as the next renewal date.

This is what we’ll end up with:

Lemon Squeezy: Billing

See the code

Here is the updated billing page:

/* src/app/dashboard/billing/page.tsx */
import { Suspense } from 'react'
import { Plans } from '@/components/dashboard/billing/plans/plans'
import { Subscriptions } from '@/components/dashboard/billing/subscription/subscriptions'
import { DashboardContent } from '@/components/dashboard/content'
import { PageTitleAction } from '@/components/dashboard/page-title-action'
import { PlansSkeleton } from '@/components/dashboard/skeletons/plans'
import { CardSkeleton } from '@/components/dashboard/skeletons/card'
 
export const dynamic = 'force-dynamic'
 
export default function BillingPage() {
  return (
    <DashboardContent
      title="Billing"
      subtitle="View and manage your billing information."
      action={<PageTitleAction />}
    >
      <div>
        <Suspense fallback={<CardSkeleton className="h-[106px]" />}>
          <Subscriptions />
        </Suspense>
 
        <Suspense fallback={<PlansSkeleton />}>
          <Plans />
        </Suspense>
      </div>
    </DashboardContent>
  )
}

Updating subscriptions

Now that customers can sign up and view their subscriptions, we need to add typical billing features like changing plans, cancelling subscriptions and updating billing details.

Lemon Squeezy: Update Subscription

Cancel, pause and update payment method

First, let’s add functions for handling:

  • subscription cancellation
  • subscription pausing
  • subscription resuming

and also one for getting the update_payment_method and customer_portal URLs from the Lemon Squeezy API.

/* src/app/actions.ts */
 
/**
 * This action will cancel a subscription on Lemon Squeezy.
 */
export async function cancelSub(id: string) {
  configureLemonSqueezy()
 
  // Get user subscriptions
  const userSubscriptions = await getUserSubscriptions()
 
  // Check if the subscription exists
  const subscription = userSubscriptions.find(
    (sub) => sub.lemonSqueezyId === id
  )
 
  if (!subscription) {
    throw new Error(`Subscription #${id} not found.`)
  }
 
  const cancelledSub = await cancelSubscription(id)
 
  if (cancelledSub.error) {
    throw new Error(cancelledSub.error.message)
  }
 
  // Update the db
  try {
    await db
      .update(subscriptions)
      .set({
        status: cancelledSub.data?.data.attributes.status,
        statusFormatted: cancelledSub.data?.data.attributes.status_formatted,
        endsAt: cancelledSub.data?.data.attributes.ends_at,
      })
      .where(eq(subscriptions.lemonSqueezyId, id))
  } catch (error) {
    throw new Error(`Failed to cancel Subscription #${id} in the database.`)
  }
 
  revalidatePath('/')
 
  return cancelledSub
}
 
/**
 * This action will pause a subscription on Lemon Squeezy.
 */
export async function pauseUserSubscription(id: string) {
  configureLemonSqueezy()
 
  // Get user subscriptions
  const userSubscriptions = await getUserSubscriptions()
 
  // Check if the subscription exists
  const subscription = userSubscriptions.find(
    (sub) => sub.lemonSqueezyId === id
  )
 
  if (!subscription) {
    throw new Error(`Subscription #${id} not found.`)
  }
 
  const returnedSub = await updateSubscription(id, {
    pause: {
      mode: 'void',
    },
  })
 
  // Update the db
  try {
    await db
      .update(subscriptions)
      .set({
        status: returnedSub.data?.data.attributes.status,
        statusFormatted: returnedSub.data?.data.attributes.status_formatted,
        endsAt: returnedSub.data?.data.attributes.ends_at,
        isPaused: returnedSub.data?.data.attributes.pause !== null,
      })
      .where(eq(subscriptions.lemonSqueezyId, id))
  } catch (error) {
    throw new Error(`Failed to pause Subscription #${id} in the database.`)
  }
 
  revalidatePath('/')
 
  return returnedSub
}
 
/**
 * This action will unpause a subscription on Lemon Squeezy.
 */
export async function unpauseUserSubscription(id: string) {
  configureLemonSqueezy()
 
  // Get user subscriptions
  const userSubscriptions = await getUserSubscriptions()
 
  // Check if the subscription exists
  const subscription = userSubscriptions.find(
    (sub) => sub.lemonSqueezyId === id
  )
 
  if (!subscription) {
    throw new Error(`Subscription #${id} not found.`)
  }
 
  const returnedSub = await updateSubscription(id, {
    // @ts-expect-error -- null is a valid value for pause
    pause: null,
  })
 
  // Update the db
  try {
    await db
      .update(subscriptions)
      .set({
        status: returnedSub.data?.data.attributes.status,
        statusFormatted: returnedSub.data?.data.attributes.status_formatted,
        endsAt: returnedSub.data?.data.attributes.ends_at,
        isPaused: returnedSub.data?.data.attributes.pause !== null,
      })
      .where(eq(subscriptions.lemonSqueezyId, id))
  } catch (error) {
    throw new Error(`Failed to pause Subscription #${id} in the database.`)
  }
 
  revalidatePath('/')
 
  return returnedSub
}
 
/**
 * This action will get the subscription URLs (including `update_payment_method` for the given subscription ID.
 *
 */
export async function getSubscriptionURLs(id: string) {
  configureLemonSqueezy()
  const subscription = await getSubscription(id)
 
  if (subscription.error) {
    throw new Error(subscription.error.message)
  }
 
  return subscription.data?.data.attributes.urls
}

And now for the links in the UI, we will add them in a dropdown menu in the SubscriptionsActionsDropdown component. We will include the following links:

  • “Pause payments” link.
  • “Unpause payments” link, shown only if the subscription is paused.
  • “Customer portal” link which opens the Lemon Squeezy customer portal in a new tab. This is an example how you can use the Lemon Squeezy API to get the customer portal URL.
  • “Update payment method” link which opens the Lemon Squeezy update payment method modal.
  • “Cancel subscription” link.
/* src/components/dashboard/billing/subscription/actions-dropdown.tsx */
 
'use client'
 
import { Button, DropdownMenu, Loading } from '@lemonsqueezy/wedges'
import { MoreVerticalIcon } from 'lucide-react'
import { useState } from 'react'
import { LemonSqueezyModalLink } from './modal-link'
import { type NewSubscription } from '@/db/schema'
import {
  cancelSub,
  pauseUserSubscription,
  unpauseUserSubscription,
  type getSubscriptionURLs,
} from '@/app/actions'
 
export function SubscriptionActionsDropdown({
  subscription,
  urls,
}: {
  subscription: NewSubscription
  urls: Awaited<ReturnType<typeof getSubscriptionURLs>>
}) {
  const [loading, setLoading] = useState(false)
 
  if (
    subscription.status === 'expired' ||
    subscription.status === 'cancelled' ||
    subscription.status === 'unpaid'
  ) {
    return null
  }
 
  return (
    <>
      {loading && (
        <div className="bg-surface-50/50 absolute inset-0 z-10 flex items-center justify-center rounded-md">
          <Loading size="sm" />
        </div>
      )}
 
      <DropdownMenu>
        <DropdownMenu.Trigger asChild>
          <Button
            size="sm"
            variant="transparent"
            className="size-8 data-[state=open]:bg-surface-50"
            before={<MoreVerticalIcon className="size-4" />}
          />
        </DropdownMenu.Trigger>
 
        <DropdownMenu.Content side="bottom" className="z-10" align="end">
          <DropdownMenu.Group>
            {!subscription.isPaused && (
              <DropdownMenu.Item
                onClick={async () => {
                  setLoading(true)
                  await pauseUserSubscription(subscription.lemonSqueezyId).then(
                    () => {
                      setLoading(false)
                    }
                  )
                }}
              >
                Pause payments
              </DropdownMenu.Item>
            )}
 
            {subscription.isPaused && (
              <DropdownMenu.Item
                onClick={async () => {
                  setLoading(true)
                  await unpauseUserSubscription(
                    subscription.lemonSqueezyId
                  ).then(() => {
                    setLoading(false)
                  })
                }}
              >
                Unpause payments
              </DropdownMenu.Item>
            )}
 
            <DropdownMenu.Item asChild>
              <a href={urls?.customer_portal}>Customer portal ↗</a>
            </DropdownMenu.Item>
 
            <LemonSqueezyModalLink href={urls?.update_payment_method}>
              Update payment method
            </LemonSqueezyModalLink>
          </DropdownMenu.Group>
 
          <DropdownMenu.Separator />
 
          <DropdownMenu.Group>
            <DropdownMenu.Item
              onClick={async () => {
                if (
                  // eslint-disable-next-line no-alert -- allow
                  confirm(
                    `Please confirm if you want to cancel your subscription.`
                  )
                ) {
                  setLoading(true)
                  await cancelSub(subscription.lemonSqueezyId).then(() => {
                    setLoading(false)
                  })
                }
              }}
              destructive
            >
              Cancel subscription
            </DropdownMenu.Item>
          </DropdownMenu.Group>
        </DropdownMenu.Content>
      </DropdownMenu>
    </>
  )
}

Now we can update our Subscriptions component to display these links, and show different messaging and options depending on the subscription’s status value.

/* src/components/dashboard/billing/subscription/subscriptions.tsx */
 
import { Section } from '../../section'
import { ChangePlan } from '../plans/change-plan-button'
import { SubscriptionActions } from './actions'
import { SubscriptionDate } from './date'
import { SubscriptionPrice } from './price'
import { SubscriptionStatus } from './status'
import { type SubscriptionStatusType } from '@/types/types'
import { cn, isValidSubscription } from '@/lib/utils'
import { db, plans, type NewSubscription } from '@/db/schema'
import { getUserSubscriptions } from '@/app/actions'
 
export async function Subscriptions() {
  const userSubscriptions = await getUserSubscriptions()
  const allPlans = await db.select().from(plans)
 
  if (userSubscriptions.length === 0) {
    return (
      <p className="not-prose mb-2">
        It appears that you do not have any subscriptions. Please sign up for a
        plan below.
      </p>
    )
  }
 
  // Show active subscriptions first, then paused, then canceled
  const sortedSubscriptions = userSubscriptions.sort((a, b) => {
    if (a.status === 'active' && b.status !== 'active') {
      return -1
    }
 
    if (a.status === 'paused' && b.status === 'cancelled') {
      return -1
    }
 
    return 0
  })
 
  return (
    <Section className="not-prose relative">
      {sortedSubscriptions.map(
        (subscription: NewSubscription, index: number) => {
          const plan = allPlans.find((p) => p.id === subscription.planId)
          const status = subscription.status as SubscriptionStatusType
 
          if (!plan) {
            throw new Error('Plan not found')
          }
 
          return (
            <Section.Item
              key={index}
              className="flex-col items-stretch justify-center gap-2"
            >
              <header className="flex items-center justify-between gap-3">
                <div className="min-h-8 flex flex-wrap items-center gap-x-3 gap-y-1">
                  <h2
                    className={cn(
                      'text-surface-900 text-lg',
                      !isValidSubscription(status) && 'text-inherit'
                    )}
                  >
                    {plan.productName} ({plan.name})
                  </h2>
                </div>
 
                <div className="flex items-center gap-2">
                  {isValidSubscription(status) && (
                    <ChangePlan planId={subscription.planId} />
                  )}
 
                  <SubscriptionActions subscription={subscription} />
                </div>
              </header>
 
              <div className="flex flex-wrap items-center gap-2">
                <SubscriptionPrice
                  endsAt={subscription.endsAt}
                  interval={plan.interval}
                  intervalCount={plan.intervalCount}
                  price={subscription.price}
                  isUsageBased={plan.isUsageBased ?? false}
                />
 
                <SubscriptionStatus
                  status={status}
                  statusFormatted={subscription.statusFormatted}
                  isPaused={Boolean(subscription.isPaused)}
                />
 
                <SubscriptionDate
                  endsAt={subscription.endsAt}
                  renewsAt={subscription.renewsAt}
                  status={status}
                  trialEndsAt={subscription.trialEndsAt}
                />
              </div>
            </Section.Item>
          )
        }
      )}
    </Section>
  )
}

The code above uses several helper functions to format the subscription data and display the correct messaging and options. View the full code on Github.

Adding the ability to change plans

The final step is to add a way for customers to change their plan. This should only be available to active and on_trial subscriptions (you may have noticed the “Change plan” buttons in the code above).

Lemon Squeezy: Changing Plans

Let’s create a new page at src/app/dashboard/billing/change-plans. This page displays the similar plans picker that we built before.

/* src/app/dashboard/billing/change-plans/[id]/page.tsx */
 
import { Button } from '@lemonsqueezy/wedges'
import { eq } from 'drizzle-orm'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getUserSubscriptions } from '@/app/actions'
import { ChangePlans } from '@/components/dashboard/billing/plans/change-plans'
import { DashboardContent } from '@/components/dashboard/content'
import { PageTitleAction } from '@/components/dashboard/page-title-action'
import { db, plans } from '@/db/schema'
import { isValidSubscription } from '@/lib/utils'
import { type SubscriptionStatusType } from '@/types/types'
 
export const dynamic = 'force-dynamic'
 
export default async function ChangePlansPage({
  params,
}: {
  params: { id?: string }
}) {
  if (!params.id) {
    notFound()
  }
  const currentPlanId = parseInt(params.id)
 
  if (isNaN(currentPlanId)) {
    notFound()
  }
 
  // Get user subscriptions to check the current plan.
  const userSubscriptions = await getUserSubscriptions()
 
  if (!userSubscriptions.length) {
    notFound()
  }
 
  const isCurrentPlan = userSubscriptions.find(
    (s) =>
      s.planId === currentPlanId &&
      isValidSubscription(s.status as SubscriptionStatusType)
  )
 
  if (!isCurrentPlan) {
    redirect('/dashboard/billing')
  }
 
  const currentPlan = await db
    .select()
    .from(plans)
    .where(eq(plans.id, currentPlanId))
 
  if (!currentPlan.length) {
    notFound()
  }
 
  return (
    <DashboardContent
      title="Change Plans"
      subtitle="Choose a plan that works for you."
    >
      <ChangePlans currentPlan={currentPlan.at(0)} />
    </DashboardContent>
  )
}

and the ChangePlans component:

// src/components/dashboard/billing/plans/change-plans.tsx
 
/* eslint-disable @typescript-eslint/prefer-optional-chain -- allow */
import { redirect } from 'next/navigation'
import { InfoMessage, NoPlans, Plan } from './plan'
import { getUserSubscriptions } from '@/app/actions'
import { db, plans, type NewPlan } from '@/db/schema'
 
export async function ChangePlans({ currentPlan }: { currentPlan?: NewPlan }) {
  const allPlans: NewPlan[] = await db.select().from(plans)
  const userSubscriptions = await getUserSubscriptions()
 
  // If user does not have a valid subscription, redirect to the billing page, or
  // if there are no plans in the database, redirect to the billing page to fetch.
  if (!userSubscriptions.length || !allPlans.length) {
    redirect('/dashboard/billing')
  }
 
  const isCurrentPlanUsageBased = currentPlan?.isUsageBased
 
  const filteredPlans = allPlans
    .filter((plan) => {
      return isCurrentPlanUsageBased
        ? Boolean(plan.isUsageBased)
        : Boolean(!plan.isUsageBased)
    })
    .sort((a, b) => {
      if (
        a.sort === undefined ||
        a.sort === null ||
        b.sort === undefined ||
        b.sort === null
      ) {
        return 0
      }
 
      return a.sort - b.sort
    })
 
  if (filteredPlans.length < 2) {
    return <NoPlans></NoPlans>
  }
 
  return (
    <div className="flex flex-col gap-5">
      <div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-5">
        {filteredPlans.map((plan, index) => {
          return (
            <Plan
              isChangingPlans={true}
              key={`plan-${index}`}
              plan={plan}
              currentPlan={currentPlan}
            ></Plan>
          )
        })}
      </div>
    </div>
  )
}

View the full code on Github.

Now let’s create a function to actually make the plan change when the change plan button is clicked.

/* src/app/actions.ts */
 
/**
 * This action will change the plan of a subscription on Lemon Squeezy.
 */
export async function changePlan(currentPlanId: number, newPlanId: number) {
  configureLemonSqueezy()
 
  // Get user subscriptions
  const userSubscriptions = await getUserSubscriptions()
 
  // Check if the subscription exists
  const subscription = userSubscriptions.find(
    (sub) => sub.planId === currentPlanId
  )
 
  if (!subscription) {
    throw new Error(`No subscription with plan id #${currentPlanId} was found.`)
  }
 
  // Get the new plan details from the database.
  const newPlan = await db
    .select()
    .from(plans)
    .where(eq(plans.id, newPlanId))
    .then(takeUniqueOrThrow)
 
  // Send request to Lemon Squeezy to change the subscription.
  const updatedSub = await updateSubscription(subscription.lemonSqueezyId, {
    variantId: newPlan.variantId,
  })
 
  // Save in db
  try {
    await db
      .update(subscriptions)
      .set({
        planId: newPlanId,
        price: newPlan.price,
        endsAt: updatedSub.data?.data.attributes.ends_at,
      })
      .where(eq(subscriptions.lemonSqueezyId, subscription.lemonSqueezyId))
  } catch (error) {
    throw new Error(
      `Failed to update Subscription #${subscription.lemonSqueezyId} in the database.`
    )
  }
 
  revalidatePath('/')
 
  return updatedSub
}

Extending the portal

This tutorial covers the basics of a billing portal but there are a few extra features you could add.

For example, you could listen to subscription_payment_* webhook events and save invoice data locally so you can display a handy billing history.

Or you could save card_brand and card_last_four into the app’s Subscription model to display those details to your customers.

Read through our API documentation to familiarize yourself with what’s possible.


Read more

Now you have a fully-functioning billing portal built right into your Next.js application, ready to serve customers with Lemon Squeezy-powered subscriptions.

You can view the full Next.js Billing demo app code, including the snippets from this turtorial at github.com/lmsqueezy/nextjs-billing.

If you would like to read more about Lemon Squeezy’s billing capabilities, head over to our Help Docs.

Make sure to also read through our Developer Guide and Tutorials for more tips.


Was this page helpful?