Guides
Tutorials
Typescript
Featured

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.

Felix Vemmer
Felix Vemmer
June 26, 2023
Lemon Squeezy and Next.js 13: A Detailed Guide to Effortless Payment Integration

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 🍋

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.

Lemon Squeezy Product Details View

Next, let's generate an API Key at https://app.lemonsqueezy.com/settings/api to connect to our store:

Lemon Squeezy API Key

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

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:

Lemon Squeezy Variant Description Plan with Lemon Squeezy Product Fetch

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! 🥳

Lemon Squeezy Copy to Live Mode

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.

Recommended minimum webhooks for Lemon Squeezy

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.

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.

Lemon Squeezy 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.

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

  1. Check if the type of event is really a subscription
  2. Pare and validate the request body with our zod schema
  3. 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

Forwarding Lemon Squeezy Webhooks with ngrok

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:

Lemon Squeezy Webhook Endpoint

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.

Debugging Webhook Requests in Lemon Squeezy

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:

Simulating Lemon Squeezy Event

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.

Account Settings page on BacklinkGPT.com

Using Server Actions to Manage Subscriptions

As you can see in the above screenshot, customers already have two options:

  1. Update payment method
  2. 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.

Lemon Squeezy Roadmap

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.