Tutorials

How to create a SaaS billing portal in Next.js with Lemon Squeezy

Dan Rowden  ·  March 3, 2024

This tutorial will take you through the steps to create a full billing portal for a SaaS app built with Next.js.

The billing will be handled by Lemon Squeezy, which is a Merchant of Record aimed at making it easy to set up globally-compliant SaaS billing and integrating it into your application.

The basics of a billing portal

When it comes to adding billing features into a SaaS application, there are a number of aspects that are required. Unfortunately it's not just a case of sending customers to a checkout and everything just works.

Here is a list of the different billing features you will need to add to a typical SaaS:

  • 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 three main topics: signup up customers, syncing data and updating subscriptions. We will work through each one in turn, with code examples.

Before we jump into this code-heavy tutorial, we should mention that Lemon Squeezy offers a drop-in no-code Customer Portal, which does everything you see below, just by adding a single link into your application.
Read more about the Customer Portal and then make sure to check out our tutorial for adding Customer Portal to your application.

Intro to the tutorial code

All 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 template is built using the following stack and tools:

You are of course allowed to swap these out for other alternatives, or to simply copy-paste code from the repo into an existing project.

Create a billing section in your app

The first step in this tutorial is to create a new section in your Next.js app, where all your billing pages will be.

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 (

Billing

) }

Install lemonsqueezy.js

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

You can install it with pnpm:

pnpm add @lemonsqueezy/lemonsqueezy.js

Set up the API

We also 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=your-api-key LEMONSQUEEZY_STORE_ID=your-store-id

Signing up customers

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 plan information from Lemon Squeezy

Before we can show our plans to customers, we need to be able to display them in the app.

You could always hard-code plan details into your billing page but it's better if you can get up-to-date, accurate data from your billing provider instead.

First, create products in Lemon Squeezy so we can use the API to sync plan data into our application.

Then, we need a Plan model in the database to store the data. We will use Drizzle ORM and Postgres in this tutorial. Here's an example of what the model could look like:

/* 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'), })

Next, we need to build a way to get the product data from Lemon Squeezy with the API. The Lemon Squeezy SDK makes it easy-peasy to get the data but we need to process it a bit to make sure we're storing the data needed to display plans nicely in the app.

The example code synchronizes our demo app Plans with Product data on Lemon Squeezy. It's implemented as a Next.js server action designed to execute on the initial page load if the plans have not been synchronized already. The server action can easily be adapted to run as a background job, API endpoint or a serverless function.

/* src/app/actions.ts */ 'use server' /** * This action will sync the product variants from Lemon Squeezy with the * Plans database model. It will only sync the 'subscription' variants. */ export async function syncPlans() { // Code for syncing plans will be added here }

Before we start sending API requests to Lemon Squeezy we need to configure the SDK with our 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, onError: (error) => { throw new Error(`Lemon Squeezy API error: ${error.message}`) }, }) }

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 .env. 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.

/* src/db/schema.ts */ ... 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 (

{productName} ({name})

{description ? (
) : null}

${price}

) }

The final code on the repo 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

No plans available.

} return (

Plans

{allPlans.map((plan, index) => { return })}
) }

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 (
Loading plans...

}>
) }

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

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 ( ) }

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

// src/app/actions.ts // ... other code /** * This action will create a checkout on Lemon Squeezy. */ 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.

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.

Alternatively, you can use the Lemon Squeezy SDK to create webhooks programmatically. The demo app provides a button on the Dashboard page that triggers a server action to create a webhook.

// src/app/actions.ts /** * This action will check if a webhook exists on Lemon Squeezy. It will return * the webhook if it exists, otherwise it will return undefined. */ export async function hasWebhook() { configureLemonSqueezy() if (!process.env.WEBHOOK_URL) { throw new Error( 'Missing required WEBHOOK_URL env variable. Please, set it in your .env file.' ) } // Check if a webhook exists on Lemon Squeezy. const allWebhooks = await listWebhooks({ filter: { storeId: process.env.LEMONSQUEEZY_STORE_ID }, }) // Check if WEBHOOK_URL ends with a slash. If not, add it. let webhookUrl = process.env.WEBHOOK_URL if (!webhookUrl.endsWith('/')) { webhookUrl += '/' } webhookUrl += 'api/webhook' const webhook = allWebhooks.data?.data.find( (wh) => wh.attributes.url === webhookUrl && wh.attributes.test_mode ) return webhook } /** * This action will set up a webhook on Lemon Squeezy to listen to * Subscription events. It will only set up the webhook if it does not exist. */ export async function setupWebhook() { configureLemonSqueezy() if (!process.env.WEBHOOK_URL) { throw new Error( 'Missing required WEBHOOK_URL env variable. Please, set it in your .env file.' ) } // Check if WEBHOOK_URL ends with a slash. If not, add it. let webhookUrl = process.env.WEBHOOK_URL if (!webhookUrl.endsWith('/')) { webhookUrl += '/' } webhookUrl += 'api/webhook' // eslint-disable-next-line no-console -- allow console.log('Setting up a webhook on Lemon Squeezy (Test Mode)...') // Do not set a webhook on Lemon Squeezy if it already exists. let webhook = await hasWebhook() // If the webhook does not exist, create it. if (!webhook) { const newWebhook = await createWebhook(process.env.LEMONSQUEEZY_STORE_ID!, { secret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET!, url: webhookUrl, testMode: true, // will create a webhook in Test mode only! events: [ 'subscription_created', 'subscription_expired', 'subscription_updated', ], }) webhook = newWebhook.data?.data } // eslint-disable-next-line no-console -- allow console.log(`Webhook ${webhook?.id} created on Lemon Squeezy.`) }

Now we need to create our webhook endpoint, making sure to check that any incoming data was 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.

Here's the 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 */ /** * This action will 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 build some new 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:

We'll create a new component, which will house all the subscription related data. This component will be used in the billing page.

/* /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 (

It appears that you do not have any subscriptions. Please sign up for a plan below.

) } // 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 (
{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 (

{plan.productName} ({plan.name})

{isValidSubscription(status) && ( )}
) } )}
) }

This component uses additional UI components to display the subscription data. View the full code on Github.

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 ( } >
}> }>
) }

Note that if a user has an active subscription, they will see the subscription details and the Plans will not be shown. If they don't have any subscriptions, they will see the plans.

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.

Adding options to cancel, pause and update payment methods

First, let's create server actions 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> }) { const [loading, setLoading] = useState(false) if ( subscription.status === 'expired' || subscription.status === 'cancelled' || subscription.status === 'unpaid' ) { return null } return ( <> {loading && (
)}

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 (

It appears that you do not have any subscriptions. Please sign up for a plan below.

) } // 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 (
{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 (

{plan.productName} ({plan.name})

{isValidSubscription(status) && ( )}
) } )}
) }

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).

This what it will look like:

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 ( ) }

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 } return (
{filteredPlans.map((plan, index) => { return ( ) })}
) }

View the full code on Github.

Now let's create a server action 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 familiarise 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.

Previous
Testing and going live