How to create a SaaS billing portal in Next.js with Lemon Squeezy
Dan Rowden · September 29, 2023
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.
It is built as a skeleton project that you can fork and use to build your SaaS application on top of.
The demo app is built with Next.js' app router, using Prisma as the ORM and some styling with Tailwind CSS. It also uses the sonner package for notification toasts and Lucide for icons.
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 to 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 billing, then create a new page.jsx, which will be the main page for all billing-related UI.
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.
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.
We will need a Plan model in the database to store the data:
/* /prisma/schema.prisma */
model Plan {
id Int @id @default(autoincrement())
productId Int
variantId Int @unique
name String? // Product name
description String?
variantName String
sort Int
status String
price Int
interval String
intervalCount Int @default(1)
subscriptions Subscription[]
}
Now 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 script is written as a page that can be triggered with an HTTP GET request (for example from a cron job) but it can easily be adapted to run as a background job or serverless function.
/* /app/billing/refresh-plans/page.jsx */
import prisma from '@/lib/prisma'
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
const ls = new LemonSqueezy(process.env.LEMONSQUEEZY_API_KEY)
export const dynamic = 'force-dynamic' // Don't cache API results
async function getPlans() {
// Code to fetch and save product data will be here
}
export default async function Page() {
await getPlans()
return (
Done!
)
}
The first part of the code we need to write gets variant data from Lemon Squeezy, using the include parameter to also query related product data in the same API request. We'll do this inside the getPlans() function.
We then process the variants and products to merge the two data sets together. We will end up saving both variant and produc data points together in the Plan model in the database.
async function getPlans() {
const params = { include: ['product'], perPage: 50 }
let hasNextPage = true;
let page = 1;
let variants = []
let products = []
while (hasNextPage) {
const resp = await ls.getVariants(params);
variants = variants.concat(resp['data'])
products = products.concat(resp['included'])
if (resp['meta']['page']['lastPage'] > page) {
page += 1
params['page'] = page
} else {
hasNextPage = false
}
}
// Nest products inside variants
const prods = {};
for (let i = 0; i < products.length; i++) {
prods[products[i]['id']] = products[i]['attributes']
}
for (let i = 0; i < variants.length; i++) {
variants[i]['product'] = prods[variants[i]['attributes']['product_id']]
}
}
Now we have a list of variants with their related products nested inside the product key, like this:
Next we need to save the data into the database. So, continuing inside getPlans() we can loop every variant, making sure that it's a subscription product and that the store ID matches the one we set in a LEMONSQUEEZY_STORE_ID environment variable.
You could also add a filter in this code to only save certain plans by their Lemon Squeezy ID, if you have a large library of plans and only want a few to be publicly available to customers.
async function getPlans() {
// Previous code here
// Save locally
let variantId,
variant,
product,
productId
for (let i = 0; i < variants.length; i++) {
variant = variants[i]
if ( !variant['attributes']['is_subscription'] ) {
console.log('Not a subscription')
continue
}
if ( String(variant['product']['store_id']) !== process.env.LEMONSQUEEZY_STORE_ID ) {
console.log(`Store ID ${variant['product']['store_id']} does not match (${process.env.LEMONSQUEEZY_STORE_ID})`)
continue
}
variantId = parseInt(variant['id'])
product = variant['product']
productId = parseInt(variant['attributes']['product_id'])
// Get variant's Price objects
let prices = await ls.getPrices({ variantId: variantId, perPage: 100 })
// The first object is the latest/current price
let variant_price = prices['data'][0]['attributes']['unit_price']
variant = variant['attributes']
const updateData = {
productId: productId,
name: product['name'],
variantName: variant['name'],
status: variant['status'],
sort: variant['sort'],
description: variant['description'],
price: variant_price, // display price in the app matches current Price object in LS
interval: variant['interval'],
intervalCount: variant['interval_count'],
}
const createData = updateData
createData.variantId = variantId
try {
await prisma.plan.upsert({
where: {
variantId: variantId
},
update: updateData,
create: createData
})
} catch (error) {
console.log(variant)
console.log(error)
}
}
}
Now you can go to http://localhost:3000/billing/refresh-plans to sync your plan information to your database.
Let's first create a reusable function to get plan data from the database:
/* /lib/data.js */
import prisma from '@/lib/prisma'
export async function getPlans() {
// Gets all active plans
return await prisma.plan.findMany({
where: {
NOT: {
status: 'draft'
}
}
})
}
Next we'll create a client component called Plans, which will display all plan-related information plus buttons for customers sign up. This example also has a Monthly/Yearly switch.
/* /componens/plan.jsx */
'use client';
import { useState } from 'react'
function createMarkup(html) {
return {__html: html}
}
function formatPrice(price) {
return price / 100
}
function formatInterval(interval, intervalCount) {
return intervalCount > 1 ? `${intervalCount} ${interval}s` : interval
}
function IntervalSwitcher({ intervalValue, changeInterval }) {
return (
Then we can add this component (and some styling) to our billing page to show the plans.
We also add a subscription variable, which will hold the current user's subscription in a later step.
/* /app/billing/page.jsx */
import Plans from '@/components/plan'
export default async function Page() {
const plans = await getPlans()
const subscription = null // TODO
return (
Billing
)
}
At this point you should see the month/year switch and your 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 NextAuth.js but any auth provider will work as well.
After following the installation guide, we create a /lib/auth.js file to contain the authOptions and also create a helper function getSession() to reduce the number of imports needed across the app.
Note the custom session callback, which adds the current user's ID into the session from the JWT token. We will need this to link customers to their purchases in the next step.
/* /lib/auth.js */
import { getServerSession } from 'next-auth/next'
import EmailProvider from 'next-auth/providers/email'
import { PrismaAdapter } from '@auth/prisma-adapter'
import prisma from '@/lib/prisma'
import { sendVerificationRequest } from '@/utils/send-verification-request'
export const authOptions = {
adapter: PrismaAdapter(prisma),
providers: [
EmailProvider({
name: 'email',
server: '',
sendVerificationRequest,
}),
],
pages: {
signIn: '/login',
verifyRequest: '/login/verify',
},
session: {
strategy: 'jwt'
},
callbacks: {
// Add user ID to session from token
session: async ({ session, token }) => {
if (session?.user) {
session.user.id = token.sub
}
return session
}
}
}
export function getSession() {
return getServerSession(authOptions)
}
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 PlanButton, which you can then drop into the Plan component in place of the "Sign up" button.
Now replace the previous Sign up button with the component...
/* /components/plan.jsx */
import PlanButton from '@/components/plan-button'
function Plan(( plan, subscription, intervalValue }) {
return (
// Other code here
)
}
You'll notice a fetch request to /api/checkouts in the PlanButton code. Because your API key would be exposed if you used the Lemon Squeezy SDK in your browser (which would give anyone access to your store over the API), we need to create a proxy endpoint that can use your API key on the server instead:
/* /app/api/checkouts/route.js */
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
const ls = new LemonSqueezy(process.env.LEMONSQUEEZY_API_KEY)
export async function POST(request) {
const session = await getSession()
if (!session) {
return NextResponse.json({ error: true, message: 'Not logged in.' }, { status: 401 })
}
const res = await request.json()
if ( !res.variantId ) {
return NextResponse.json({ error: true, message: 'No variant ID was provided.' }, { status: 400 })
}
// Customise the checkout experience
// All the options: https://docs.lemonsqueezy.com/api/checkouts#create-a-checkout
const attributes = {
'checkout_options': {
'embed': true, // Use the checkout overlay
'media': false,
'button_color': '#fde68a'
},
'checkout_data': {
'email': session.user.email, // Displays in the checkout form
'custom': {
'user_id': session.user.id // Sent in the background; visible in webhooks and API calls
}
},
'product_options': {
'enabled_variants': [res.variantId], // Only show the selected variant in the checkout
'redirect_url': `${process.env.NEXT_PUBLIC_APP_URL}/billing/`,
'receipt_link_url': `${process.env.NEXT_PUBLIC_APP_URL}/billing/`,
'receipt_button_text': 'Go to your account',
'receipt_thank_you_note': 'Thank you for signing up to Lemonstand!'
}
}
try {
const checkout = await ls.createCheckout({
storeId: process.env.LEMONSQUEEZY_STORE_ID,
variantId: res.variantId,
attributes
})
return NextResponse.json({'error': false, 'url': checkout['data']['attributes']['url']}, {status: 200})
} catch (e) {
return NextResponse.json({'error': true, 'message': e.message}, {status: 400})
}
}
Now when you click on a "Sign up" button, a checkout will be created and loaded in an overlay 🎉
Customers can now purchase your subscriptions, but how will your application know about this? Enter, webhooks.
Syncing subscription data
When anything happens in your Lemon Squeezy store your application can be alerted by 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.
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 Prisma model:
model WebhookEvent {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
eventName String
processed Boolean @default(false)
body Json
processingError String?
}
We also need a Subcription model to save subscription data in your application:
model Subscription {
id Int @id @default(autoincrement())
lemonSqueezyId Int @unique
orderId Int @unique
name String
email String
status String
renewsAt DateTime?
endsAt DateTime?
trialEndsAt DateTime?
resumesAt DateTime?
price Int
plan Plan @relation(fields: [planId], references: [id])
planId Int
user User @relation(fields: [userId], references: [id])
userId String
isUsageBased Boolean @default(false)
subscriptionItemId Int? @unique
@@index([planId, lemonSqueezyId])
}
Here's the basic endpoint code:
/* /app/billing/webhook/route.js */
import prisma from '@/lib/prisma'
export async function POST(request) {
// Make sure request is from Lemon Squeezy
const crypto = require('crypto')
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.')
}
// Save the event
const data = JSON.parse(rawBody)
const event = await prisma.webhookEvent.create({
data: {
eventName: data['meta']['event_name'],
body: data
},
})
// Process the event
// This could be done out of the main thread
// TODO
// processEvent(event)
return new Response('Done')
}
To process the webhook event data, add a processEvent() function to the top of the file that will update the database. If there is any error, the exception message will be saved into the database to make it easy to figure out the issue and re-process the event.
/* /app/billing/webhook/route.js */
import prisma from '@/lib/prisma'
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
const ls = new LemonSqueezy(process.env.LEMONSQUEEZY_API_KEY)
async function processEvent(event) {
let processingError = ''
const customData = event.body['meta']['custom_data'] || null
if (!customData || !customData['user_id']) {
processingError = 'No user ID, can\'t process'
} else {
const obj = event.body['data']
if ( event.eventName.startsWith('subscription_payment_') ) {
// Save subscription invoices; obj is a "Subscription invoice"
/* Not implemented */
} else if ( event.eventName.startsWith('subscription_') ) {
// Save subscriptions; obj is a "Subscription"
const data = obj['attributes']
// We assume the Plan table is up to date
const plan = await prisma.plan.findUnique({
where: {
variantId: data['variant_id']
},
})
if (!plan) {
processingError = 'Plan not found in DB. Could not process webhook event.'
} else {
// Update the subscription
const lemonSqueezyId = parseInt(obj['id'])
// Get subscription's Price object
// We save the Price value to the subscription so we can display it in the UI
let priceData = await ls.getPrice({ id: data['first_subscription_item']['price_id'] })
const updateData = {
orderId: data['order_id'],
name: data['user_name'],
email: data['user_email'],
status: data['status'],
renewsAt: data['renews_at'],
endsAt: data['ends_at'],
trialEndsAt: data['trial_ends_at'],
planId: plan['id'],
userId: customData['user_id'],
price: priceData['data']['attributes']['unit_price'],
subscriptionItemId: data['first_subscription_item']['id'],
// Save this for usage-based billing reporting; no need to if you use quantity-based billing
isUsageBased: data['first_subscription_item']['is_usage_based'],
}
const createData = updateData
createData.lemonSqueezyId = lemonSqueezyId
createData.price = plan.price
try {
// Create/update subscription
await prisma.subscription.upsert({
where: {
lemonSqueezyId: lemonSqueezyId
},
update: updateData,
create: createData,
})
} catch (error) {
processingError = error
console.log(error)
}
}
} else if ( event.eventName.startsWith('order_') ) {
// Save orders; obj is a "Order"
/* Not implemented */
} else if ( event.eventName.startsWith('license_') ) {
// Save license keys; obj is a "License key"
/* Not implemented */
}
try {
// Mark event as processed
await prisma.webhookEvent.update({
where: {
id: event.id
},
data: {
processed: true,
processingError
}
})
} catch (error) {
console.log(error)
}
}
}
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 client component, which will house all the different states of the billing page, including the initial plan selector that we already built.
At the same time we can pull in the current user's subscription (if they have one) so we can show different page states.
/* /components/subscription.jsx */
'use client'
import { useState, useEffect } from 'react'
import Plans from '@/components/plan'
// Main component
export const SubscriptionComponent = ({ sub, plans }) => {
// Make sure Lemon.js is loaded
useEffect(() => {
window.createLemonSqueezy()
}, [])
// Create a data object that can be passed around the child components
const [subscription, setSubscription] = useState(() => {
if (sub) {
return {
id: sub.lemonSqueezyId,
planName: sub.plan?.variantName,
planInterval: sub.plan?.interval,
productId: sub.plan?.productId,
variantId: sub.plan?.variantId,
status: sub.status,
renewalDate: sub.renewsAt,
trialEndDate: sub.trialEndsAt,
expiryDate: sub.endsAt,
unpauseDate: sub.resumesAt,
price: sub.price / 100,
}
} else {
return {}
}
})
if (sub) {
switch(subscription.status) {
case 'active':
return
// TODO
// case 'on_trial':
// return
// case 'past_due':
// return
// case 'cancelled':
// return
// case 'paused':
// return
// case 'unpaid':
// return
// case 'expired':
// return
}
} else {
return (
<>
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.
We can create a new component for each of these. We'll also need to make another internal API endpoint to send subscription change requests to the Lemon Squeezy API.
Adding options to cancel, pause and update payment methods
First, let's create that new internal endpoint. This one endpoint will handle all subscription changes initiated by customers.
/* /app/api/subscriptions/[id]/route.js */
import { NextResponse } from 'next/server'
import { getPlan } from '@/lib/data'
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
const ls = new LemonSqueezy(process.env.LEMONSQUEEZY_API_KEY)
export async function GET(request, { params }) {
/**
* Used by some buttons to get subscription update billing and customer portal URLs
*/
try {
const subscription = await ls.getSubscription({ id: params.id })
return NextResponse.json({ error: false, subscription: {
update_billing_url: subscription['data']['attributes']['urls']['update_payment_method'],
customer_portal_url: subscription['data']['attributes']['urls']['customer_portal']
} }, { status: 200 })
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
}
export async function POST(request, { params }) {
const res = await request.json()
let subscription
if (res.variantId && res.productId) {
// Update plan
try {
subscription = await ls.updateSubscription({
id: params.id,
productId: res.productId,
variantId: res.variantId,
})
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
} else if (res.action == 'resume') {
// Resume
try {
subscription = await ls.resumeSubscription({ id: params.id })
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
} else if (res.action == 'cancel') {
// Cancel
try {
subscription = await ls.cancelSubscription({ id: params.id })
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
} else if (res.action == 'pause') {
// Pause
try {
subscription = await ls.pauseSubscription({ id: params.id })
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
} else if (res.action == 'unpause') {
// Unpause
try {
subscription = await ls.unpauseSubscription({ id: params.id })
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
} else {
// Missing data in request
return NextResponse.json({ error: true, message: 'Valid data not found.' }, { status: 400 })
}
// Return values needed to refresh state in UI
// DB will be updated in the background with webhooks
// Get price
let resp = await ls.getPrice({ id: subscription['data']['attributes']['first_subscription_item']['price_id'] })
let subItemPrice = resp['data']['attributes']['unit_price']
// Return a filtered subscription object to the UI
const sub = {
product_id: subscription['data']['attributes']['product_id'],
variant_id: subscription['data']['attributes']['variant_id'],
status: subscription['data']['attributes']['status'],
card_brand: subscription['data']['attributes']['card_brand'],
card_last_four: subscription['data']['attributes']['card_last_four'],
trial_ends_at: subscription['data']['attributes']['trial_ends_at'],
renews_at: subscription['data']['attributes']['renews_at'],
ends_at: subscription['data']['attributes']['ends_at'],
resumes_at: subscription['data']['attributes']['resumes_at'],
plan: {},
price: subItemPrice,
}
// Get plan's data
const plan = await getPlan(sub.variant_id)
sub.plan = {
interval: plan.interval,
name: plan.variantName
}
return NextResponse.json({ error: false, subscription: sub }, { status: 200 })
}
This requires a new data fetcher to get a single plan, so add the following code to /lib/data.js:
export async function getPlan(variantId) {
// Gets single active plan by ID
return await prisma.plan.findFirst({
where: {
variantId: variantId,
NOT: {
status: 'draft'
}
}
})
}
And now for the buttons themselves. Here we add
an "Update your payment method" link, which opens the form in a modal using Lemon.js
a "Cancel" link, which cancels a subscription
a "Resume" button, which re-activates a cancelled subscription
a "Pause" link, which pauses a subscription
an "Unpause" button, which unpauses a subscription
a PlansComponent, which displays a list of plans in the page
/* /components/manage.jsx */
'use client'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import Plans from '@/components/plan'
export function UpdateBillingLink({ subscription, elementType }) {
const [isMutating, setIsMutating] = useState(false)
async function openUpdateModal(e) {
e.preventDefault()
setIsMutating(true)
/* Send request */
const res = await fetch(`/api/subscriptions/${subscription.id}`)
const result = await res.json()
if (result.error) {
alert(result.message)
setIsMutating(false)
} else {
LemonSqueezy.Url.Open(result.subscription.update_billing_url)
setIsMutating(false)
}
}
if (elementType == 'button') {
return (
Update your payment method
)
} else {
return (
Update your payment method
)
}
}
export function CancelLink({ subscription, setSubscription }) {
const [isMutating, setIsMutating] = useState(false)
async function handleCancel(e) {
e.preventDefault()
if (confirm(`Please confirm you want to cancel your subscription.`)) {
setIsMutating(true)
/* Send request */
const res = await fetch(`/api/subscriptions/${subscription.id}`, {
method: 'POST',
body: JSON.stringify({
action: 'cancel'
})
})
const result = await res.json()
if (result.error) {
alert(result.message)
setIsMutating(false)
} else {
setSubscription({
...subscription,
status: result['subscription']['status'],
expiryDate: result['subscription']['ends_at'],
})
toast.success('Your subscription has been cancelled.')
}
}
}
return (
Cancel
)
}
export function ResumeButton({ subscription, setSubscription }) {
const [isMutating, setIsMutating] = useState(false)
const resumeSubscription = async (e) => {
e.preventDefault()
if (confirm(`Please confirm you want to resume your subscription. You will be charged the regular subscription fee.`)) {
setIsMutating(true)
/* Send request */
const res = await fetch(`/api/subscriptions/${subscription.id}`, {
method: 'POST',
body: JSON.stringify({
action: 'resume'
})
})
const result = await res.json()
if (result.error) {
alert(result.message)
setIsMutating(false)
} else {
setSubscription({
...subscription,
status: result['subscription']['status'],
renewalDate: result['subscription']['renews_at'],
})
toast.success('Your subscription is now active again!')
}
}
}
return (
Resume your subscription
)
}
export function PauseLink({ subscription, setSubscription }) {
const [isMutating, setIsMutating] = useState(false)
async function handlePause(e) {
e.preventDefault()
if (confirm(`Please confirm you want to pause your subscription.`)) {
setIsMutating(true)
/* Send request */
const res = await fetch(`/api/subscriptions/${subscription.id}`, {
method: 'POST',
body: JSON.stringify({
action: 'pause'
})
})
const result = await res.json()
if (result.error) {
alert(result.message)
setIsMutating(false)
} else {
setSubscription({
...subscription,
status: result['subscription']['status'],
unpauseDate: result['subscription']['resumes_at'],
})
toast.success('Your subscription has been paused.')
}
}
}
return (
Pause payments
)
}
export function UnpauseButton({ subscription, setSubscription }) {
const [isMutating, setIsMutating] = useState(false)
const unpauseSubscription = async (e) => {
e.preventDefault()
if (confirm(`Please confirm you want to unpause your subscription. Your payments will reactivate on their original schedule.`)) {
setIsMutating(true)
/* Send request */
const res = await fetch(`/api/subscriptions/${subscription.id}`, {
method: 'POST',
body: JSON.stringify({
action: 'unpause'
})
})
const result = await res.json()
if (result.error) {
alert(result.message)
setIsMutating(false)
} else {
setSubscription({
...subscription,
status: result['subscription']['status'],
renewalDate: result['subscription']['renews_at'],
})
toast.success('Your subscription is now active again!')
}
}
}
return (
Unpause your subscription
)
}
export function PlansComponent({ plans, sub }) {
const [subscription, setSubscription] = useState(() => {
if (sub) {
return {
id: sub.lemonSqueezyId,
planName: sub.plan?.variantName,
planInterval: sub.plan?.interval,
productId: sub.plan?.productId,
variantId: sub.plan?.variantId,
status: sub.status,
renewalDate: sub.renewsAt,
trialEndDate: sub.trialEndsAt,
expiryDate: sub.endsAt,
}
} else {
return {}
}
})
return (
)
}
Now we can update our SubscriptionComponent to display these new links and buttons, and show different messaging and options depending on the subscription's status value.
/* /components/subscription.jsx */
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import Plans from '@/components/plan'
import {
UpdateBillingLink,
CancelLink,
ResumeButton,
PauseLink,
UnpauseButton
} from '@/components/manage'
export const SubscriptionComponent = ({ sub, plans }) => {
// Make sure Lemon.js is loaded
useEffect(() => {
window.createLemonSqueezy()
}, [])
const [subscription, setSubscription] = useState(() => {
if (sub) {
return {
id: sub.lemonSqueezyId,
planName: sub.plan?.variantName,
planInterval: sub.plan?.interval,
productId: sub.plan?.productId,
variantId: sub.plan?.variantId,
status: sub.status,
renewalDate: sub.renewsAt,
trialEndDate: sub.trialEndsAt,
expiryDate: sub.endsAt,
unpauseDate: sub.resumesAt,
price: sub.price / 100,
}
} else {
return {}
}
})
if (sub) {
switch(subscription.status) {
case 'active':
return
case 'on_trial':
return
case 'past_due':
return
case 'cancelled':
return
case 'paused':
return
case 'unpaid':
return
case 'expired':
return
}
} else {
return (
<>
You are currently on a free trial of the {subscription.planName} {subscription.planInterval}ly plan, paying ${subscription.price}/{subscription.planInterval}.
Your trial ends on {formatDate(subscription.trialEndDate)}. You can cancel your subscription before this date and you won't be charged.
Your latest payment failed. We will re-try this payment up to four times, after which your subscription will be cancelled.
If you need to update your billing details, you can do so below.
You are currently on the {subscription.planName} {subscription.planInterval}ly plan, paying ${subscription.price}/{subscription.planInterval}.
We will attempt a payment on {formatDate(subscription.renewalDate)}.
>
)
}
const UnpaidSubscription = ({ subscription, setSubscription }) => {
/*
Unpaid subscriptions have had four failed recovery payments.
If you have dunning enabled in your store settings, customers will be sent emails trying to reactivate their subscription.
If you don't have dunning enabled the subscription will remain "unpaid".
*/
return (
<>
We haven't been able to make a successful payment and your subscription is currently marked as unpaid.
Please update your billing information to regain access.
Your subscription expired on {formatDate(subscription.expiryDate)}.
Please create a new subscription to regain access.
>
)
}
function formatDate(date) {
if (!date) return ''
return new Date(date).toLocaleString('en-US', {
month: 'short',
day: "2-digit",
year: 'numeric'
})
}
Adding the ability to change plans
The final step is to add a way for customers to change 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 /billing/change-plan. This page displays the same plans picker that we built before.
/* /app/billing/change-plan/page.jsx */
import { getSession } from '@/lib/auth'
import Link from 'next/link'
import { PlansComponent } from '@/components/manage'
import { getPlans, getSubscription } from '@/lib/data'
import { redirect } from 'next/navigation'
export const metadata = {
title: 'Change plan'
}
export default async function Page() {
const session = await getSession()
const sub = await getSubscription(session?.user?.id)
if (!sub) {
redirect('/billing')
}
const plans = await getPlans()
return (
← Back to billing
Change plan
{sub.status == 'on_trial' && (
You are currently on a free trial. You will not be charged when changing plans during a trial.
)}
)
}
Now we can update the PlanButton component to fire off a request to the API in changePlan() to actually make the plan change when the button is clicked.
We also make a change to show a "Your current plan" label in the customer's current plan box.
Here's the updated components/plan-button.jsx file:
/* /components/plan-button.jsx */
'use client'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function PlanButton({ plan, subscription, setSubscription }) {
const [isMutating, setIsMutating] = useState(false)
async function createCheckout(e, variantId) {
e.preventDefault()
setIsMutating(true)
// Create a checkout
const res = await fetch('/api/checkouts', {
method: 'POST',
body: JSON.stringify({
variantId: variantId
})
})
const checkout = await res.json()
if (checkout.error) {
alert(checkout.message)
} else {
LemonSqueezy.Url.Open(checkout['url'])
}
setIsMutating(false)
}
async function changePlan(e, subscription, plan) {
e.preventDefault()
if (confirm(`Please confirm you want to change to the ${plan.variantName} ${plan.interval}ly plan. \
For upgrades you will be charged a prorated amount.`)) {
setIsMutating(true)
// Send request
const res = await fetch(`/api/subscriptions/${subscription.id}`, {
method: 'POST',
body: JSON.stringify({
variantId: plan.variantId,
productId: plan.productId
})
})
const result = await res.json()
if (result.error) {
toast.error(result.message)
} else {
setSubscription({
...subscription,
productId: result['subscription']['product_id'],
variantId: result['subscription']['variant_id'],
planName: result['subscription']['plan']['name'],
planInterval: result['subscription']['plan']['interval'],
status: result['subscription']['status'],
renewalDate: result['subscription']['renews_at'],
price: result['subscription']['price']
})
toast.success('Your subscription plan has changed!')
// Webhooks will update the DB in the background
}
setIsMutating(false)
}
}
return (
<>
{(!subscription || subscription.status == 'expired') ? (
// Show a "Sign up" button to customers with no subscription
createCheckout(e, plan.variantId)}
className="block text-center py-2 px-5 bg-amber-200 rounded-full font-bold text-amber-800 shadow-md shadow-gray-300/30 select-none"
disabled={isMutating}
>
Sign up
) : (
<>
{subscription?.variantId == plan.variantId ? (
Your current plan
) : (
changePlan(e, subscription, plan)}
className="block text-center py-2 px-5 bg-amber-200 rounded-full font-bold text-amber-800 shadow-md shadow-gray-300/30 select-none"
disabled={isMutating}
>
Change to this plan
)}
>
)}
>
)
}
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.
Now you have a fully-functioning billing portal built right into your Next.js application, ready to serve customers with Lemon Squeezy-powered subscriptions.