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.

Subscribe to continue reading.

Become a free member to get access to all subscriber-only content.

Already a reader?