Lemon Squeezy and Next.js 13: A Detailed Guide to Effortless Payment Integration
Discover how to integrate payments into a Next.js SaaS App using Lemon Squeezy. Our guide covers creating API keys, managing subscriptions, handling webhooks, and storing updates. Learn to build a robust subscription setup with this powerful tech stack.
Dive into the power of Lemon Squeezy as we take a journey through setting up a fully functional SaaS App with subscriptions using Next.js App Directory. In this hands-on tutorial, you'll learn how to create an API key, display products in a pricing table using TailwindUI, and manage subscriptions with Lemon Squeezy.
We'll also cover how to handle Lemon Squeezy webhook events, using zod for input parsing, and how to store subscription updates in a Postgres database using drizzle ORM. Additionally, you'll learn how to synchronize product data between Lemon Squeezy and your own database, and manage user subscriptions using Vercel's server actions.
By the end of this guide, you'll be equipped with the skills to create a robust subscription setup, leveraging Lemon Squeezy's functionalities and a powerful tech stack. Let's get started!
Setting up Lemon Squeezy 🍋
As we walk through this process, ensure you've enabled the test mode - this allows us to experiment freely without the risk of errors.
In order to get started you should have created a store in Lemon Squeezy as well as some products and variants. In this tutorial I will focus on subscriptions, but it should work equally well for other products.
Next, let's generate an API Key at https://app.lemonsqueezy.com/settings/api to connect to our store:
Lemon Squeezy API Key
Add this as an environment variable to your Next.js project:
LEMON_SQUEEZY_API_KEY={YOUR-API-KEY}
Creating a Dynamic Pricing Table with Product Info
Now that we've laid the groundwork, our next step involves fetching all the product and variant information via API and presenting it in a pricing table. I am a huge fan of TailwindUI so I picked one of their pricing tables from here. This is what our final pricing table will look like:
Pricing table fetching data from Lemon Squeezy for BacklinkGPT.com
First, we need to fetch all the products with the getProductVariants
function, which also has the createHeaders
and createRequestOptions
utility functions to build out the request:
'use server'
import { SLemonSqueezyRequest, TLemonSqueezyRequest } from './zod-lemon-squeezy'
const lemonSqueezyBaseUrl = 'https://api.lemonsqueezy.com/v1'
const lemonSqueezyApiKey = process.env.LEMON_SQUEEZY_API_KEY
if (!lemonSqueezyApiKey) throw new Error('No LEMON_SQUEEZY_API_KEY environment variable set')
function createHeaders() {
const headers = new Headers()
headers.append('Accept', 'application/vnd.api+json')
headers.append('Content-Type', 'application/vnd.api+json')
headers.append('Authorization', `Bearer ${lemonSqueezyApiKey}`)
return headers
}
function createRequestOptions(method: string, headers: Headers): RequestInit {
return {
method,
headers,
redirect: 'follow',
cache: 'no-store',
}
}
export async function getProductVariants(productId: string): Promise<TLemonSqueezyRequest> {
const url = `${lemonSqueezyBaseUrl}/variants?filter[product_id]=${productId}`
const headers = createHeaders()
const requestOptions = createRequestOptions('GET', headers)
const response: Response = await fetch(url, requestOptions)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const data = await response.json()
const parsedData = SLemonSqueezyRequest.parse(data)
return parsedData
}
view rawlemon-squeezy.ts hosted with ❤ by GitHub
Furthermore, with the help of GPT4 I also quickly created a zod
model SLemonSqueezyRequest
and the corresponding type TLemonSqueezyRequest
to help me parsing the API output and enhanced type safety and auto-completion functionality with the data:
import { z } from 'zod'
const SPagination = z.object({
currentPage: z.number(),
from: z.number(),
lastPage: z.number(),
perPage: z.number(),
to: z.number(),
total: z.number(),
})
const SMeta = z.object({
page: SPagination,
})
const SJsonapi = z.object({
version: z.string(),
})
const SLinks = z.object({
first: z.string(),
last: z.string(),
})
const SProductRelationships = z.object({
links: z.object({
related: z.string(),
self: z.string(),
}),
})
const SAttributes = z.object({
product_id: z.number(),
name: z.string(),
slug: z.string(),
description: z.string(),
price: z.number(),
is_subscription: z.boolean(),
interval: z.string().nullable(),
interval_count: z.number().nullable(),
has_free_trial: z.boolean(),
trial_interval: z.string(),
trial_interval_count: z.number(),
pay_what_you_want: z.boolean(),
min_price: z.number(),
suggested_price: z.number(),
has_license_keys: z.boolean(),
license_activation_limit: z.number(),
is_license_limit_unlimited: z.boolean(),
license_length_value: z.number(),
license_length_unit: z.string(),
is_license_length_unlimited: z.boolean(),
sort: z.number(),
status: z.string(),
status_formatted: z.string(),
created_at: z.string(),
updated_at: z.string(),
})
const SVariants = z.object({
type: z.string(),
id: z.string(),
attributes: SAttributes,
relationships: z.object({
product: SProductRelationships,
}),
links: z.object({
self: z.string(),
}),
})
export const SLemonSqueezyRequest = z.object({
meta: SMeta,
jsonapi: SJsonapi,
links: SLinks,
data: z.array(SVariants),
})
export type TLemonSqueezyRequest = z.infer<typeof SLemonSqueezyRequest>
view rawzod-lemon-squeezy.ts hosted with ❤ by GitHub
With the function and zod models ready, I created a pricing.tsx
component to fetch all the data, which I then pass to the pricing table. One more important convenience function I created is the createCheckoutLink
function to generate the hosted checkout link.
import { currentUser } from '@clerk/nextjs'
import { User } from '@clerk/nextjs/dist/types/server'
import { CheckIcon } from '@heroicons/react/20/solid'
import { getProductVariants } from '@lib/lemon-squeezy'
import { cn } from '@lib/utils'
import { JSDOM } from 'jsdom'
import { CheckoutButton } from './checkout-button'
import { Badge } from './ui/badge'
export default async function Pricing({
title,
subtitle,
sectionTitle,
}: {
title: string
subtitle: string
sectionTitle: string
}) {
// const frequencies = [
// { value: 'monthly', label: 'Monthly', priceSuffix: '/month' },
// // { value: 'annually', label: 'Annually', priceSuffix: '/year' },
// ]
const user = await currentUser()
let productId = process.env.PRODUCT_ID
if (!productId) {
throw new Error('No product ID found')
}
const productVariants = await getProductVariants(productId)
function createCheckoutLink({
variantId,
user,
}: {
variantId: string
user: User | null | undefined
}): string {
const baseUrl = new URL(`https://backlinkgpt.lemonsqueezy.com/checkout/buy/${variantId}`)
if (!user) return '/sign-in'
const email = user.emailAddresses?.[0]?.emailAddress
const name =
user.firstName || user?.lastName ? `${user?.firstName} ${user?.lastName}` : undefined
const userId = user.id
const url = new URL(baseUrl)
url.searchParams.append('checkout[custom][user_id]', userId)
if (email) url.searchParams.append('checkout[email]', email)
if (name) url.searchParams.append('checkout[name]', name)
return url.toString()
}
return (
// Pricing Table JSX Code
)
view rawpricing.tsx hosted with ❤ by GitHub
Finally, I ran into a small problem when I tried to show the variant description with checkmarks in my pricing table:
To do this, I used the JSDOM parser to pull out all the feature elements:
{productVariants.data.map((variant, index) => {
const dom = new JSDOM(variant.attributes.description)
const document = dom.window.document
// the first paragraph becomes the description
const description = document.querySelector('p')?.textContent
// the li elements become the features
const features = Array.from(document.querySelectorAll('ul li p')).map(
(li) => (li as HTMLElement).textContent,
)
return (
// Rendering Logic...
)
})}
When you're satisfied with your pricing table in the dev environment
, it's easy to move the products from test
mode to live
mode. All you need to do is create a new API key
for the live environment, add it to your environment variables, and you're done with the pricing table! 🥳
Listening to Subscription Webhook Events
Now that the pricing table is ready, we need to know about any new subscription updates in our app to manage access and such. To do this, we can create a Route Handler
. It listens for certain Lemon Squeezy Webhook Requests and saves the data in our database. Then, we can use this info to develop any logic we want.
In the example below, I use zod
for input parsing and drizzle ORM
to create the tables and store everything in my Postgres Neon Database
.
Lemon Squeezy has some excellent documentation on webhooks, and I followed their recommendation of listening to the subscription_created
webhook.
In their documentation they also do a great job in explaining the whole subscription's lifecycle and how and when which event will be fired.
I did not implement the logic for subscription_payment_success since I figured I just tell customers to log into their Lemon Squeezy Account to view the invoices. I might add this though in the future and update the blog post.
Setting up the Route Handler
To be able to receive the Lemon Squeezy webhooks in my Next.js
app directory, I created the new Route Handler under /src/app/api/lemon-squeezy/route.ts
.
Just like in the previous example, I want to easily parse and consume the data with type safety and auto-completion, so I again create a few new zod
models with the help of GPT4 and the Lemon Squeezy documentation on the subscription object.
I placed the zod
models right next to the route.ts
under this path:
/app/api/lemon-squeezy/models.ts
import camelcaseKeys from 'camelcase-keys'
import { z } from 'zod'
const camelize = <T extends readonly unknown[] | Record<string, unknown>>(val: T) =>
camelcaseKeys(val)
const Urls = z
.object({
update_payment_method: z.string(),
})
.transform(camelize)
const Attributes = z
.object({
urls: z
.object({
update_payment_method: z.string(),
})
.transform(camelize),
pause: z.null().optional(),
status: z.enum(['on_trial', 'active', 'paused', 'past_due', 'unpaid', 'cancelled', 'expired']),
ends_at: z.coerce.date().optional(),
order_id: z.number(),
store_id: z.number(),
cancelled: z.boolean(),
renews_at: z.coerce.date(),
test_mode: z.boolean(),
user_name: z.string(),
card_brand: z
.enum(['visa', 'mastercard', 'american_express', 'discover', 'jcb', 'diners_club'])
.nullable(),
created_at: z.coerce.date(),
product_id: z.number(),
updated_at: z.coerce.date(),
user_email: z.string(),
variant_id: z.number(),
customer_id: z.number(),
product_name: z.string(),
variant_name: z.string(),
order_item_id: z.number(),
trial_ends_at: z.coerce.date().optional(),
billing_anchor: z.number(),
card_last_four: z.string().nullable(),
status_formatted: z.string(),
})
.transform(camelize)
const Links = z
.object({
self: z.string(),
related: z.string().optional(),
})
.transform(camelize)
const Order = z
.object({
links: Links,
})
.transform(camelize)
const Relationships = z
.object({
order: Order,
store: Order,
product: Order,
variant: Order,
customer: Order,
'order-item': Order,
'subscription-invoices': Order,
})
.transform(camelize)
const Data = z
.object({
id: z.coerce.number(),
type: z.string(),
links: Links,
attributes: Attributes,
relationships: Relationships,
})
.transform(camelize)
const Meta = z
.object({
test_mode: z.boolean(),
event_name: z.string(),
custom_data: z
.object({
user_id: z.string(),
})
.transform(camelize),
})
.transform(camelize)
export const SLemonSqueezyWebhookRequest = z
.object({
data: Data,
meta: Meta,
})
.transform(camelize)
view rawzod-lemon-squeezy-subscription.ts hosted with ❤ by GitHub
In the preceding code, you might spot the camelize
function. This becomes useful with drizzle ORM which stores table names in snake_case
but provides objects in camelCase
.
Shifting our focus, let's take a look at how I set up the tables using Drizzle Kit
in my schema.ts
file. If you're new to Drizzle and need a head start, you might find this link helpful.
// declaring enum in database for subscription status
export const subscriptionStatusEnum = pgEnum('subscription_status', [
'on_trial',
'active',
'paused',
'past_due',
'unpaid',
'cancelled',
'expired',
])
export const pauseModeEnum = pgEnum('pause_mode', ['void', 'free'])
// declaring enum in database for card brand
export const cardBrandEnum = pgEnum('card_brand', [
'visa',
'mastercard',
'american_express',
'discover',
'jcb',
'diners_club',
])
export const subscriptions = pgTable('subscriptions', {
id: serial('id').primaryKey(),
userId: text('user_id').notNull(),
storeId: integer('store_id'),
customerId: integer('customer_id'),
orderId: integer('order_id'),
orderItemId: integer('order_item_id'),
productId: integer('product_id'),
variantId: integer('variant_id'),
productName: text('product_name'),
variantName: text('variant_name'),
userName: text('user_name'),
userEmail: text('user_email'),
status: subscriptionStatusEnum('status'),
statusFormatted: text('status_formatted'),
cardBrand: cardBrandEnum('card_brand'),
cardLastFour: varchar('card_last_four', { length: 4 }),
pause: jsonb('pause'),
cancelled: boolean('cancelled'),
trialEndsAt: timestamp('trial_ends_at'),
billingAnchor: integer('billing_anchor'),
urls: jsonb('urls'),
renewsAt: timestamp('renews_at'),
endsAt: timestamp('ends_at'),
createdAt: timestamp('created_at'),
updatedAt: timestamp('updated_at'),
testMode: boolean('test_mode'),
})
// declaring enum in database for billing reason
export const billingReasonEnum = pgEnum('billing_reason', ['initial', 'renewal', 'updated'])
// declaring enum in database for invoice status
export const invoiceStatusEnum = pgEnum('invoice_status', [
'paid',
'open',
'void',
'uncollectible',
'draft',
])
export const subscriptionInvoices = pgTable('subscription_invoices', {
id: serial('id').primaryKey(),
storeId: integer('store_id'),
subscriptionId: integer('subscription_id'),
billingReason: billingReasonEnum('billing_reason'),
cardBrand: cardBrandEnum('card_brand'),
cardLastFour: varchar('card_last_four', { length: 4 }),
currency: varchar('currency', { length: 3 }),
currencyRate: numeric('currency_rate', { precision: 10, scale: 8 }),
subtotal: integer('subtotal'),
discountTotal: integer('discount_total'),
tax: integer('tax'),
total: integer('total'),
subtotalUsd: integer('subtotal_usd'),
discountTotalUsd: integer('discount_total_usd'),
taxUsd: integer('tax_usd'),
totalUsd: integer('total_usd'),
status: invoiceStatusEnum('status'),
statusFormatted: text('status_formatted'),
refunded: boolean('refunded'),
refundedAt: timestamp('refunded_at'),
urls: jsonb('urls'),
createdAt: timestamp('created_at'),
updatedAt: timestamp('updated_at'),
testMode: boolean('test_mode'),
})
export const storePlanEnum = pgEnum('store_plan', ['fresh', 'sweet', 'free'])
export const stores = pgTable('stores', {
id: text('id').primaryKey().notNull(),
name: text('name').notNull(),
slug: text('slug').notNull(),
domain: text('domain').notNull(),
url: text('url').notNull(),
avatarUrl: text('avatar_url').notNull(),
plan: storePlanEnum('plan').notNull(),
country: text('country').notNull(),
countryNicename: text('country_nicename').notNull(),
currency: varchar('currency', { length: 3 }).notNull(),
totalSales: integer('total_sales').notNull(),
totalRevenue: integer('total_revenue').notNull(),
thirtyDaySales: integer('thirty_day_sales').notNull(),
thirtyDayRevenue: integer('thirty_day_revenue').notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).notNull(),
})
export const productStatusEnum = pgEnum('product_status', ['draft', 'published'])
export const products = pgTable('products', {
id: text('id').primaryKey().notNull(),
storeId: integer('store_id').notNull(),
name: text('name').notNull(),
slug: text('slug').notNull(),
description: text('description').notNull(),
status: productStatusEnum('status').notNull(),
statusFormatted: text('status_formatted').notNull(),
thumbUrl: text('thumb_url'),
largeThumbUrl: text('large_thumb_url'),
price: integer('price').notNull(),
payWhatYouWant: boolean('pay_what_you_want').notNull(),
fromPrice: integer('from_price'),
toPrice: integer('to_price'),
buyNowUrl: text('buy_now_url').notNull(),
priceFormatted: text('price_formatted').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
testMode: boolean('test_mode').notNull(),
})
export const variantStatusEnum = pgEnum('variant_status', ['pending', 'draft', 'published'])
export const variantIntervalEnum = pgEnum('variant_interval', ['day', 'week', 'month', 'year'])
export const licenseLengthUnitEnum = pgEnum('license_length_unit', ['days', 'months', 'years'])
export const productVariants = pgTable('product_variants', {
id: text('id').primaryKey().notNull(),
productId: integer('product_id').notNull(),
name: text('name').notNull(),
slug: text('slug').notNull(),
description: text('description').notNull(),
price: integer('price').notNull(),
isSubscription: boolean('is_subscription').notNull(),
interval: variantIntervalEnum('interval'),
intervalCount: integer('interval_count'),
hasFreeTrial: boolean('has_free_trial').notNull(),
trialInterval: variantIntervalEnum('trial_interval').notNull(),
trialIntervalCount: integer('trial_interval_count').notNull(),
payWhatYouWant: boolean('pay_what_you_want').notNull(),
minPrice: integer('min_price'),
suggestedPrice: integer('suggested_price'),
hasLicenseKeys: boolean('has_license_keys').notNull(),
licenseActivationLimit: integer('license_activation_limit'),
isLicenseLimitUnlimited: boolean('is_license_limit_unlimited').notNull(),
licenseLengthValue: integer('license_length_value'),
licenseLengthUnit: licenseLengthUnitEnum('license_length_unit').notNull(),
isLicenseLengthUnlimited: boolean('is_license_length_unlimited').notNull(),
sort: integer('sort').notNull(),
status: variantStatusEnum('status').notNull(),
statusFormatted: text('status_formatted').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
})
view rawlemon-squeezy-schema.ts hosted with ❤ by GitHub
Once you have these schema declarations go ahead with creating and running the migration so we can find all the tables in our db.
As shown above you will notice that I also created stores
, products
, and productVariants
tables. I will synch those with API routes on a cron
schedule to have all the necessary information on each subscription. More on this later.
Now that we have the underlying data parsing and storing logic covered, let's create the actual API route
which will process any new subscription data.
The logic works like this.
- Verify the signature.
To ensure that webhook requests are coming from Lemon Squeezy, you will be asked to enter a signing secret when creating your webhook.
When the webhook request is sent, Lemon Squeezy will use the signing secret to generate a hash of the payload and send the hash in the
X-Signature
header of the request. You can use the same secret to calculate the hash in your application and check it against the request signature to verify that the hashes match.
- Check if the type of event is really a
subscription
- Pare and validate the request body with our
zod schema
- Check the exact event name to either insert or upsert data into our
subscriptions
table
import { db } from '@lib/db'
import PostHogClient from '@lib/posthog'
import { subscriptions } from '@schema'
import camelcaseKeys from 'camelcase-keys'
import crypto from 'crypto'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { CamelCasedPropertiesDeep } from 'type-fest' // need CamelCasedPropertiesDeep because of https://github.com/sindresorhus/camelcase-keys/issues/77#issuecomment-1339844470
import { ZodEffects, z } from 'zod'
import { SLemonSqueezyWebhookRequest } from './models'
export const zodToCamelCase = <T extends z.ZodTypeAny>(
zod: T,
): ZodEffects<z.ZodTypeAny, CamelCasedPropertiesDeep<T['_output']>> =>
zod.transform((val) => camelcaseKeys(val) as CamelCasedPropertiesDeep<T>)
export const runtime = 'nodejs' // 'nodejs' is the default
export async function POST(request: Request) {
const posthog = PostHogClient()
const secret = process.env.LEMON_SQUEEZY_SIGNING_SECRET
if (!secret) {
throw new Error('LEMON_SQUEEZY_SIGNING_SECRET is not set')
}
// Get the raw body text
const rawBody = await request.text()
if (!rawBody) {
throw new Error('No body')
}
const xSignature = request.headers.get('X-Signature')
const hmac = crypto.createHmac('sha256', secret)
hmac.update(rawBody)
const digest = hmac.digest('hex')
if (
!xSignature ||
!crypto.timingSafeEqual(Buffer.from(digest, 'hex'), Buffer.from(xSignature, 'hex'))
) {
throw new Error('Invalid signature.')
}
const body = JSON.parse(rawBody)
const type = body.data.type
if (type === 'subscriptions') {
const parsedBody = SLemonSqueezyWebhookRequest.parse(body)
posthog.identify({
distinctId: parsedBody.meta.customData.userId,
properties: {
subscription: {
id: parsedBody.data.id,
...parsedBody.data.attributes,
},
},
})
if (parsedBody.meta.eventName === 'subscription_created') {
const insertedData = await db
.insert(subscriptions)
.values({
userId: parsedBody.meta.customData.userId,
id: parsedBody.data.id,
...parsedBody.data.attributes,
})
.returning()
console.log(`Inserted subscription with id ${insertedData[0].id}`)
return NextResponse.json({
status: 'ok',
})
}
if (parsedBody.meta.eventName === 'subscription_updated') {
const updatedData = await db
.update(subscriptions)
.set({
id: parsedBody.data.id,
...parsedBody.data.attributes,
})
.where(eq(subscriptions.id, parsedBody.data.id))
.returning()
console.log(`Updated subscription with id: ${updatedData[0].id}`)
return NextResponse.json({
status: 'ok',
})
}
}
}
view rawroute.ts hosted with ❤ by GitHub
How to Test Your Subscriptions Webhook Locally
In order to test our implementation locally, you can use ngrok
to forward the requests to your localhost:3000
by running: ngrok http 3000
This will give you a forwarding address that you can set now under https://app.lemonsqueezy.com/settings/webhooks create also a new webhook endpoint. You will also need to set a signing secret
. I just picked one using this website.
Make sure to also add your signing secret as an environment variable
so we can verify the signature in our route.ts
.
LEMON_SQUEEZY_SIGNING_SECRET="KyiOKvS8FcYMGECzmIQ7Vaixxxxx"
Your final filled form should look like this:
Now start your local dev server
and go through the flow of clicking on a check out button in your pricing table to create a subscription in test mode
.
You should begin to see some events and can easily resend them to troubleshoot any possible problems.
Also, you can fake events with each specific subscription. This can be really useful for fixing application logic or learning about the full subscription lifecycle:
Synchronizing Product Data: Bridging Lemon Squeezy and Your Database
As a last step, I wanted to display more detailed info with each subscription. So, I made three Route Handerls
as Cron Jobs to sync data between Lemon Squeezy and my database.
import { NextResponse } from 'next/server'
import { db } from '@lib/db'
import { products } from '@schema'
import camelcaseKeys from 'camelcase-keys'
import { z } from 'zod'
export const runtime = 'edge' // 'nodejs' is the default
const camelize = <T extends readonly unknown[] | Record<string, unknown>>(val: T) =>
camelcaseKeys(val, { deep: true })
const singleProductSchema = z
.object({
type: z.string(),
id: z.string(),
attributes: z
.object({
store_id: z.number().positive().int(),
name: z.string(),
slug: z.string(),
description: z.string(),
status: z.enum(['draft', 'published']),
status_formatted: z.string(),
thumb_url: z.string().url().nullable(),
large_thumb_url: z.string().url().nullable(),
price: z.number().positive().int(),
pay_what_you_want: z.boolean(),
from_price: z.union([z.number().positive().int(), z.null()]),
to_price: z.union([z.number().positive().int(), z.null()]),
buy_now_url: z.string().url(),
price_formatted: z.string(),
created_at: z.coerce.date(),
updated_at: z.coerce.date(),
test_mode: z.boolean(),
})
.transform(camelize),
relationships: z
.object({
store: z
.object({
links: z
.object({
related: z.string().url(),
self: z.string().url(),
})
.transform(camelize),
})
.transform(camelize),
variants: z
.object({
links: z
.object({
related: z.string().url(),
self: z.string().url(),
})
.transform(camelize),
})
.transform(camelize),
})
.transform(camelize),
links: z
.object({
self: z.string().url(),
})
.transform(camelize),
})
.transform(camelize)
const productSchema = z
.object({
meta: z
.object({
page: z
.object({
currentPage: z.number().int(),
from: z.number().int(),
lastPage: z.number().int(),
perPage: z.number().int(),
to: z.number().int(),
total: z.number().int(),
})
.transform(camelize),
})
.transform(camelize),
jsonapi: z
.object({
version: z.string(),
})
.transform(camelize),
links: z
.object({
first: z.string().url(),
last: z.string().url(),
self: z.string().url().optional(),
})
.transform(camelize),
data: z.array(singleProductSchema),
})
.transform(camelize)
export async function GET(request: Request) {
const lemonSqueezyBaseUrl = 'https://api.lemonsqueezy.com/v1'
const lemonSqueezyApiKey = process.env.LEMON_SQUEEZY_API_KEY
if (!lemonSqueezyApiKey) throw new Error('No LEMON_SQUEEZY_API_KEY environment variable set')
function createHeaders() {
const headers = new Headers()
headers.append('Accept', 'application/vnd.api+json')
headers.append('Content-Type', 'application/vnd.api+json')
headers.append('Authorization', `Bearer ${lemonSqueezyApiKey}`)
return headers
}
function createRequestOptions(method: string, headers: Headers): RequestInit {
return {
method,
headers,
redirect: 'follow',
cache: 'no-store',
}
}
// try {
const headers = createHeaders()
const requestOptions = createRequestOptions('GET', headers)
const response = await fetch(`${lemonSqueezyBaseUrl}/products`, requestOptions)
const data = await response.json()
// console.log(JSON.stringify(data, null, 2));
// parse and validate the data against the schema
const parsedData = productSchema.parse(data)
parsedData.data.map(async (product) => {
await db
.insert(products)
.values({
...product.attributes,
id: product.id,
})
.onConflictDoUpdate({
target: [products.id],
set: {
...product.attributes,
},
})
})
return NextResponse.json(parsedData)
}
view rawsync-products-route.ts hosted with ❤ by GitHub |
import { db } from '@lib/db'
import { stores } from '@schema'
import camelcaseKeys from 'camelcase-keys'
import { NextResponse } from 'next/server'
import { z } from 'zod'
export const runtime = 'edge' // 'nodejs' is the default
const camelize = <T extends readonly unknown[] | Record<string, unknown>>(val: T) =>
camelcaseKeys(val, { deep: true })
const pageSchema = z
.object({
currentPage: z.number(),
from: z.number(),
lastPage: z.number(),
perPage: z.number(),
to: z.number(),
total: z.number(),
})
.transform(camelize)
const linksSchema = z
.object({
first: z.string().url(),
last: z.string().url(),
})
.transform(camelize)
const relationshipLinksSchema = z
.object({
related: z.string().url(),
self: z.string().url(),
})
.transform(camelize)
const relationshipsSchema = z
.object({
products: z.object({
links: relationshipLinksSchema,
}),
orders: z.object({
links: relationshipLinksSchema,
}),
subscriptions: z.object({
links: relationshipLinksSchema,
}),
discounts: z.object({
links: relationshipLinksSchema,
}),
'license-keys': z.object({
links: relationshipLinksSchema,
}),
webhooks: z.object({
links: relationshipLinksSchema,
}),
})
.transform(camelize)
const storeSchema = z
.object({
type: z.string(),
id: z.string(),
attributes: z
.object({
name: z.string(),
slug: z.string(),
domain: z.string(),
url: z.string().url(),
avatar_url: z.string().url(),
plan: z.union([z.literal('fresh'), z.literal('sweet'), z.literal('free')]),
country: z.string(),
country_nicename: z.string(),
currency: z.string(),
total_sales: z.number().min(0),
total_revenue: z.number().min(0),
thirty_day_sales: z.number().min(0),
thirty_day_revenue: z.number().min(0),
created_at: z.string(),
updated_at: z.string(),
})
.transform(camelize),
relationships: relationshipsSchema,
links: z
.object({
self: z.string().url(),
})
.transform(camelize),
})
.transform(camelize)
const storesSchema = z
.object({
meta: z
.object({
page: pageSchema,
})
.transform(camelize),
jsonapi: z
.object({
version: z.string(),
})
.transform(camelize),
links: linksSchema,
data: z.array(storeSchema),
})
.transform(camelize)
export async function GET(request: Request) {
const lemonSqueezyBaseUrl = 'https://api.lemonsqueezy.com/v1'
const lemonSqueezyApiKey = process.env.LEMON_SQUEEZY_API_KEY
if (!lemonSqueezyApiKey) throw new Error('No LEMON_SQUEEZY_API_KEY environment variable set')
function createHeaders() {
const headers = new Headers()
headers.append('Accept', 'application/vnd.api+json')
headers.append('Content-Type', 'application/vnd.api+json')
headers.append('Authorization', `Bearer ${lemonSqueezyApiKey}`)
return headers
}
function createRequestOptions(method: string, headers: Headers): RequestInit {
return {
method,
headers,
redirect: 'follow',
cache: 'no-store',
}
}
const headers = createHeaders()
const requestOptions = createRequestOptions('GET', headers)
const response = await fetch(`${lemonSqueezyBaseUrl}/stores`, requestOptions)
const data = await response.json()
// console.log(JSON.stringify(data, null, 2));
// return
const parsedData = storesSchema.parse(data)
parsedData.data.map(async (store) => {
await db
.insert(stores)
.values({
...store.attributes,
id: store.id,
})
.onConflictDoUpdate({
target: [stores.id],
set: {
...store.attributes,
},
})
})
return NextResponse.json(parsedData)
}
view rawsync-stores-route.ts hosted with ❤ by GitHub
import { db } from '@lib/db'
import { productVariants } from '@schema'
import camelcaseKeys from 'camelcase-keys'
import { NextResponse } from 'next/server'
import { z } from 'zod'
export const runtime = 'edge' // 'nodejs' is the default
const camelize = <T extends readonly unknown[] | Record<string, unknown>>(val: T) =>
camelcaseKeys(val, { deep: true })
const singleVariantSchema = z
.object({
type: z.string(),
id: z.string(),
attributes: z
.object({
product_id: z.number().positive().int(),
name: z.string(),
slug: z.string(),
description: z.string(),
price: z.number().positive().int(),
is_subscription: z.boolean(),
interval: z.union([z.enum(['day', 'week', 'month', 'year']), z.null()]),
interval_count: z.number().int().nullable(),
has_free_trial: z.boolean(),
trial_interval: z.enum(['day', 'week', 'month', 'year']),
trial_interval_count: z.number().int(),
pay_what_you_want: z.boolean(),
min_price: z.number().int(),
suggested_price: z.number().int(),
has_license_keys: z.boolean(),
license_activation_limit: z.number().int(),
is_license_limit_unlimited: z.boolean(),
license_length_value: z.number().int(),
license_length_unit: z.enum(['days', 'months', 'years']),
is_license_length_unlimited: z.boolean(),
sort: z.number().int(),
status: z.enum(['pending', 'draft', 'published']),
status_formatted: z.string(),
created_at: z.coerce.date(),
updated_at: z.coerce.date(),
})
.transform(camelize),
relationships: z
.object({
product: z
.object({
links: z
.object({
related: z.string().url(),
self: z.string().url(),
})
.transform(camelize),
})
.transform(camelize),
})
.transform(camelize),
links: z
.object({
self: z.string().url(),
})
.transform(camelize),
})
.transform(camelize)
const variantSchema = z
.object({
meta: z
.object({
page: z
.object({
currentPage: z.number().int(),
from: z.number().int(),
lastPage: z.number().int(),
perPage: z.number().int(),
to: z.number().int(),
total: z.number().int(),
})
.transform(camelize),
})
.transform(camelize),
jsonapi: z
.object({
version: z.string(),
})
.transform(camelize),
links: z
.object({
first: z.string().url(),
last: z.string().url(),
})
.transform(camelize),
data: z.array(singleVariantSchema),
})
.transform(camelize)
export async function GET(request: Request) {
const lemonSqueezyBaseUrl = 'https://api.lemonsqueezy.com/v1'
const lemonSqueezyApiKey = process.env.LEMON_SQUEEZY_API_KEY
if (!lemonSqueezyApiKey) throw new Error('No LEMON_SQUEEZY_API_KEY environment variable set')
function createHeaders() {
const headers = new Headers()
headers.append('Accept', 'application/vnd.api+json')
headers.append('Content-Type', 'application/vnd.api+json')
headers.append('Authorization', `Bearer ${lemonSqueezyApiKey}`)
return headers
}
function createRequestOptions(method: string, headers: Headers): RequestInit {
return {
method,
headers,
redirect: 'follow',
cache: 'no-store',
}
}
// try {
const headers = createHeaders()
const requestOptions = createRequestOptions('GET', headers)
const response = await fetch(`${lemonSqueezyBaseUrl}/variants`, requestOptions)
const data = await response.json()
// console.log(JSON.stringify(data, null, 2));
// parse and validate the data against the schema
const parsedData = variantSchema.parse(data)
parsedData.data.map(async (productVariant) => {
await db
.insert(productVariants)
.values({
...productVariant.attributes,
id: productVariant.id,
})
.onConflictDoUpdate({
target: [productVariants.id],
set: {
...productVariant.attributes,
},
})
})
return NextResponse.json(parsedData)
}
view rawsync-variants-route.ts hosted with ❤ by GitHub
This lets me display more product details in the Dashboard.
Using Server Actions to Manage Subscriptions
As you can see in the above screenshot, customers already have two options:
- Update payment method
- Resume/Cancel a Subscription
Fortunately, when updating payment methods, a URL to a hosted version is already provided with the webhook subscription request. We can simply link to it.
For managing subscriptions, Lemon Squeezy doesn't currently offer a hosted version. However, they're working on it, so this may soon change. That is unless you prefer customization and keeping customers within your app.
To handle subscriptions, I'm using the new server actions
that Vercel recently rolled out.
'use server'
import { revalidatePath } from 'next/cache'
import { SLemonSqueezyRequest, TLemonSqueezyRequest } from './zod-lemon-squeezy'
const lemonSqueezyBaseUrl = 'https://api.lemonsqueezy.com/v1'
const lemonSqueezyApiKey = process.env.LEMON_SQUEEZY_API_KEY
if (!lemonSqueezyApiKey) throw new Error('No LEMON_SQUEEZY_API_KEY environment variable set')
function createHeaders() {
const headers = new Headers()
headers.append('Accept', 'application/vnd.api+json')
headers.append('Content-Type', 'application/vnd.api+json')
headers.append('Authorization', `Bearer ${lemonSqueezyApiKey}`)
return headers
}
function createRequestOptions(method: string, headers: Headers): RequestInit {
return {
method,
headers,
redirect: 'follow',
cache: 'no-store',
}
}
type DeleteSubscriptionParams = {
subscriptionId: number
}
export async function deleteSubscription(params: DeleteSubscriptionParams): Promise<void> {
const url = `${lemonSqueezyBaseUrl}/subscriptions/${params.subscriptionId}`
const headers = createHeaders()
const requestOptions = createRequestOptions('DELETE', headers)
const response: Response = await fetch(url, requestOptions)
if (!response.ok) {
const responseBody = await response.json()
throw new Error(`HTTP error! status: ${response.status}, body: ${JSON.stringify(responseBody)}`)
}
// Add delay
await new Promise((resolve) => setTimeout(resolve, 2000))
revalidatePath('/account/billing')
}
type UpdateSubscriptionParams = {
subscriptionId: number
cancelled: boolean
}
export async function updateSubscription(params: UpdateSubscriptionParams): Promise<void> {
const url = `${lemonSqueezyBaseUrl}/subscriptions/${params.subscriptionId}`
const headers = createHeaders()
const body = {
data: {
type: 'subscriptions',
id: params.subscriptionId.toString(),
attributes: {
cancelled: params.cancelled,
},
},
}
const requestOptions = createRequestOptions('PATCH', headers)
requestOptions.body = JSON.stringify(body)
const response: Response = await fetch(url, requestOptions)
if (!response.ok) {
const responseBody = await response.json()
throw new Error(`HTTP error! status: ${response.status}, body: ${JSON.stringify(responseBody)}`)
}
// console.log(await response.json());
// Add delay
await new Promise((resolve) => setTimeout(resolve, 2000))
revalidatePath('/account/billing')
}
export async function getProductVariants(productId: string): Promise<TLemonSqueezyRequest> {
const url = `${lemonSqueezyBaseUrl}/variants?filter[product_id]=${productId}`
const headers = createHeaders()
const requestOptions = createRequestOptions('GET', headers)
const response: Response = await fetch(url, requestOptions)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const data = await response.json()
const parsedData = SLemonSqueezyRequest.parse(data)
return parsedData
}
view rawlemon-squeezy-server-actions.ts hosted with ❤ by GitHub
Using the awesome shadcn/UI, I import these actions into my account-button.tsx
and call them based on the subscription state. |
'use client'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@components/ui/alert-dialog'
import { buttonVariants } from '@components/ui/button'
import { deleteSubscription, updateSubscription } from '@lib/lemon-squeezy'
import Link from 'next/link'
import { useSelectedLayoutSegment } from 'next/navigation'
import { useTransition } from 'react'
import { SubscriptionType } from './header'
import { cn } from '@lib/utils'
export const AccountButtons = ({ subscription }: { subscription: SubscriptionType }) => {
let [isPending, startTransition] = useTransition()
const segment = useSelectedLayoutSegment()
if (segment !== 'billing') return null
if (!subscription) return null
let title = ''
let description = ''
let action = async () => {}
let subStatus = ''
let variant = buttonVariants({
variant: 'errorOutline',
})
// console.log(subscription.status)
switch (subscription.status) {
case 'active':
case 'on_trial':
title = 'Cancel Subscription'
description = 'Are you sure you want to cancel the subscription?'
subStatus = 'Subscription'
action = () => deleteSubscription({ subscriptionId: subscription.id })
break
case 'paused':
case 'past_due':
case 'unpaid':
case 'cancelled':
title = 'Resume Subscription'
description = 'Are you sure you want to resume the subscription?'
subStatus = 'Subscription'
action = async () =>
await updateSubscription({ subscriptionId: subscription.id, cancelled: false })
variant = buttonVariants({
variant: 'primary',
})
break
case 'expired':
return null
default:
return null
}
return (
<>
<AlertDialog>
<AlertDialogTrigger className={cn(variant, isPending && 'loading cursor-not-allowed')}>
{title}
</AlertDialogTrigger>
<AlertDialogContent className="bg-base-200">
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Go Back</AlertDialogCancel>
<AlertDialogAction onClick={() => startTransition(() => action())} className={variant}>
{title}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{typeof subscription.urls?.updatePaymentMethod === 'string' && (
<Link
target="_blank"
rel="noopener noreferrer"
href={subscription.urls.updatePaymentMethod}
className={buttonVariants({
variant: 'primaryOutline',
})}
>
Update Payment Method
</Link>
)}
</>
)
}
view rawaccount-button.tsx hosted with ❤ by GitHub
And that's all! 🥳 You just learned how to create a pricing table, sync products, and listen to webhook requests using the best tech stack out there.