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:
- Introduction
- Signing up and selecting plans
- Syncing data
- Displaying subscription data
- Updating subscriptions
Before we jump into this code-heavy tutorial, keep in mind that Lemon Squeezy offers a drop-in no code Customer Portal, which does everything covered in this tutorial out of the box. Read more about the Customer Portal and then make sure to check out our tutorial for adding Customer Portal to your application.
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.
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.
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.

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.

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

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

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

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