├── .env.example ├── .eslintrc.js ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── drizzle.config.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── shims.d.ts ├── src ├── app │ ├── actions.ts │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ └── webhook │ │ │ └── route.ts │ ├── dashboard │ │ ├── billing │ │ │ ├── change-plans │ │ │ │ ├── [id] │ │ │ │ │ └── page.tsx │ │ │ │ └── loading.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── auth.ts ├── components │ ├── dashboard │ │ ├── billing │ │ │ ├── plans │ │ │ │ ├── change-plan-button.tsx │ │ │ │ ├── change-plans.tsx │ │ │ │ ├── plan.tsx │ │ │ │ ├── plans.tsx │ │ │ │ └── signup-button.tsx │ │ │ └── subscription │ │ │ │ ├── actions-dropdown.tsx │ │ │ │ ├── actions.tsx │ │ │ │ ├── date.tsx │ │ │ │ ├── modal-link.tsx │ │ │ │ ├── price.tsx │ │ │ │ ├── status.tsx │ │ │ │ └── subscriptions.tsx │ │ ├── content.tsx │ │ ├── page-title-action.tsx │ │ ├── page-title.tsx │ │ ├── section.tsx │ │ ├── setup-webhook-button.tsx │ │ ├── sidebar-nav-item.tsx │ │ ├── sidebar-nav.tsx │ │ ├── sidebar.tsx │ │ ├── skeletons │ │ │ ├── card.tsx │ │ │ └── plans.tsx │ │ └── user-menu.tsx │ ├── icons │ │ ├── github.tsx │ │ └── lemonsqueezy.tsx │ ├── submit-button.tsx │ └── toaster.tsx ├── config │ └── lemonsqueezy.ts ├── db │ └── schema.ts ├── lib │ ├── typeguards.ts │ └── utils.ts ├── middleware.ts └── types │ └── types.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # https://docs.lemonsqueezy.com/guides/developer-guide/getting-started#create-an-api-key 2 | LEMONSQUEEZY_API_KEY= 3 | LEMONSQUEEZY_STORE_ID= 4 | LEMONSQUEEZY_WEBHOOK_SECRET= 5 | 6 | # Webhooks require a public URL, use ngrok to expose local server to the internet. 7 | # https://ngrok.com/ 8 | # Webhook endpoint (/api/webhook) will be automatically appended to the URL. 9 | WEBHOOK_URL= 10 | 11 | # https://console.neon.tech/signup 12 | # postgres://.... 13 | POSTGRES_URL= 14 | 15 | # https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app 16 | # Callback URL: http://your-site.com/api/auth/callback/github 17 | AUTH_GITHUB_ID= 18 | AUTH_GITHUB_SECRET= 19 | 20 | # Linux: run `openssl rand -hex 32` or go to https://generate-secret.now.sh/32 21 | AUTH_SECRET= 22 | 23 | # URL of the Next.js app 24 | # http://localhost:3000 for local development 25 | NEXT_PUBLIC_APP_URL= -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(__dirname, "tsconfig.json"); 4 | 5 | module.exports = { 6 | root: true, 7 | extends: [ 8 | require.resolve("@vercel/style-guide/eslint/node"), 9 | require.resolve("@vercel/style-guide/eslint/next"), 10 | require.resolve("@vercel/style-guide/eslint/typescript"), 11 | "plugin:tailwindcss/recommended", 12 | ], 13 | parserOptions: { 14 | project, 15 | }, 16 | settings: { 17 | "import/resolver": { 18 | typescript: { 19 | project, 20 | }, 21 | }, 22 | tailwindcss: { 23 | callees: ["cn", "clsx"], 24 | }, 25 | }, 26 | rules: { 27 | "no-console": 1, 28 | "no-unused-vars": 0, 29 | "import/no-default-export": 0, 30 | 31 | "tailwindcss/no-custom-classname": 0, 32 | "tailwindcss/classnames-order": 0, 33 | 34 | "@typescript-eslint/array-type": 0, 35 | "@typescript-eslint/no-misused-promises": 0, 36 | "@typescript-eslint/consistent-type-definitions": 0, 37 | "@typescript-eslint/explicit-function-return-type": 0, 38 | "@typescript-eslint/no-unused-vars": [1, { argsIgnorePattern: "^_" }], 39 | 40 | "@typescript-eslint/consistent-type-imports": [ 41 | 1, 42 | { 43 | prefer: "type-imports", 44 | fixStyle: "inline-type-imports", 45 | }, 46 | ], 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | pnpm-debug.log* 28 | 29 | # local env files 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # env 40 | .env -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*@lmsqueezy/* 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | yarn.lock 3 | node_modules 4 | .next 5 | **/db/migrations 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "printWidth": 80, 4 | "useTabs": false, 5 | "singleQuote": false, 6 | "tabWidth": 2, 7 | "plugins": ["prettier-plugin-packagejson", "prettier-plugin-tailwindcss"] 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Lemon Squeezy, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js Billing App with Lemon Squeezy 2 | 3 | This Next.js demo app can be used as a base for building subscription-based SaaS apps. 4 | 5 | Just clone this repo and build your app alongside the ready-made auth and billing. 6 | 7 | Using the following stack: 8 | 9 | - Framework - [Next.js 14](https://nextjs.org) 10 | - Language - [TypeScript](https://www.typescriptlang.org) 11 | - Billing - [Lemon Squeezy](https://lemonsqueezy.com) 12 | - Auth (GitHub OAuth) - [Auth.js v5](https://authjs.dev) 13 | - ORM - [Drizzle](https://orm.drizzle.team/) 14 | - Styling - [Tailwind CSS](https://tailwindcss.com) 15 | - Components - [Wedges](https://www.lemonsqueezy.com/wedges/docs) 16 | - Serverless Postgres - [Neon](https://neon.tech/) 17 | - Linting - [ESLint](https://eslint.org) 18 | - Formatting - [Prettier](https://prettier.io) 19 | 20 | This template uses the [Next.js App Router](https://nextjs.org/docs/app). This includes support for enhanced layouts, colocation of components, tests, and styles, component-level data fetching, and more. 21 | 22 | Compatbile with [Vercel Edge Functions](https://vercel.com/docs/functions/runtimes/edge-runtime) and serverless deployments. 23 | 24 | ## Customer Portal vs Integrated Billing 25 | 26 | Keep in mind that Lemon Squeezy comes with inbuilt [Customer Portal](https://www.lemonsqueezy.com/features/customer-portal) that covers all the features from this app and more. 27 | 28 | Nonetheless, should you seek a billing solution more closely integrated with your SaaS platform, this template serves as a foundation for creating a seamless, integrated SaaS billing system. 29 | 30 | ## Prerequisites 31 | 32 | ### 1. Lemon Squeezy Account and Store 33 | 34 | You need a Lemon Squeezy account and store. If you don't have one already, sign up at [Lemon Squeezy](https://app.lemonsqueezy.com/register). 35 | 36 | ### 2. Neon Account 37 | 38 | This template uses Neon + Drizzle ORM for serverless Postgres, making it compatible with the Vercel Edge functions. If you don't have an account, you can sign up for free at [Neon](https://neon.tech/). 39 | 40 | ## Getting Started 41 | 42 | ### 1. Clone the Repo 43 | 44 | Start by cloning this repo to your local machine and navigating into the directory. 45 | 46 | ### 2. Install Dependencies 47 | 48 | Then, install the project dependencies: 49 | 50 | ```bash 51 | pnpm install 52 | ``` 53 | 54 | ### 3. Set Environment Variables 55 | 56 | Copy the `.env.example` file to `.env`: 57 | 58 | ```bash 59 | cp .env.example .env 60 | ``` 61 | 62 | Then, fill in the environment variables: 63 | 64 | ``` 65 | LEMONSQUEEZY_API_KEY= 66 | LEMONSQUEEZY_STORE_ID= 67 | LEMONSQUEEZY_WEBHOOK_SECRET= 68 | 69 | WEBHOOK_URL= 70 | 71 | POSTGRES_URL= 72 | 73 | AUTH_GITHUB_ID= 74 | AUTH_GITHUB_SECRET= 75 | 76 | AUTH_SECRET= 77 | 78 | AUTH_URL= 79 | 80 | NEXT_PUBLIC_APP_URL= 81 | ``` 82 | 83 | #### Lemon Squeezy 84 | 85 | Once you have created an account and store on Lemon Squeezy, make sure you're in **Test mode**, then go to [Settings > API](https://app.lemonsqueezy.com/settings/api) and create a new API key. Copy the key and paste it into `.env` file where it says `LEMONSQUEEZY_API_KEY=`. 86 | 87 | You will also need the store ID from Lemon Squeezy for `LEMONSQUEEZY_STORE_ID`, which you can find in the list at [Settings > Stores](https://app.lemonsqueezy.com/settings/stores). 88 | 89 | Finally, you will need to add a random webhook secret in `LEMONSQUEEZY_WEBHOOK_SECRET`. A webhook secret is a security key that ensures data received from a webhook is genuine and unaltered, safeguarding against unauthorized access. 90 | 91 | #### Webhook URL 92 | 93 | Your local app will need to be able to receive webhook events, which means creating a web-accessible URL for your development project. 94 | 95 | This is not available when running your site on its local server without some sort of tunnel. 96 | 97 | An easy way to set one up is using a service like [ngrok](https://ngrok.com/) or an app like [LocalCan](https://www.localcan.com/). 98 | 99 | Once you are provided a URL by these services, simply add that in your `.env` file where it says `WEBHOOK_URL=`. 100 | 101 | #### Postgres URL 102 | 103 | You can get the Postgres URL from your Neon account. For more information refer to the [Neon documentation](https://neon.tech/docs/connect/connect-from-any-app). 104 | 105 | #### Auth 106 | 107 | You will need to set up a GitHub OAuth app in order to obtain `GITHUB_SECRET` and `GITHUB_ID` to handle authentication. 108 | 109 | Refer to the [GitHub documentation](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) for setting up GitHub OAuth. 110 | 111 | Once you have set up the OAuth app, you will need to add the `AUTH_GITHUB_SECRET` and `AUTH_GITHUB_ID` to your `.env` file. 112 | 113 | Additionally, you need to add a random secret for `AUTH_SECRET` in your `.env` file. On Linux or macOS, you can generate a random secret using the following command: 114 | 115 | ```bash 116 | openssl rand -hex 32 117 | ``` 118 | 119 | or go to https://generate-secret.now.sh/32 to generate a random secret. 120 | 121 | Next, you need to provide the URL of your app in `AUTH_URL` in format `https://your-app-url.com/api/auth`. For local development, you can use `http://localhost:3000/api/auth`. 122 | 123 | Finally, you will need to add the URL of your app in `NEXT_PUBLIC_APP_URL`. For example, `http://localhost:3000`. 124 | 125 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 126 | 127 | ### 4. Set Up the Database 128 | 129 | Run the following command to set up the database: 130 | 131 | ```bash 132 | pnpm db:push 133 | ``` 134 | 135 | With Drizzle ORM, you can access the database with [Drizzle Studio](https://orm.drizzle.team/drizzle-studio/overview). Run the following command to open Drizzle Studio: 136 | 137 | ```bash 138 | pnpm db:studio 139 | ``` 140 | 141 | Go to https://local.drizzle.studio/ to access the database. 142 | 143 | ### 5. Run the Development Server 144 | 145 | Start the development server: 146 | 147 | ```bash 148 | pnpm dev 149 | ``` 150 | 151 | That's all, you're ready to go! 152 | 153 | ## How to set up Webhooks 154 | 155 | **This is a required step.** 156 | 157 | For your app to receive data from Lemon Squeezy, you need to set up webhooks in your Lemon Squeezy store at [Settings > Webhooks](https://app.lemonsqueezy.com/settings/webhooks). 158 | 159 | **In the app we have provided an action (Setup webhook button) that demonstrates how you can create a webhook on Lemon Squeezy using the Lemon Squeezy SDK.** 160 | 161 | When you create a webhook, you should check at least these two events: 162 | 163 | - `subscription_created` 164 | - `subscription_updated` 165 | 166 | This app demo only processes these two events, but they are enough to get a billing system in place. You could, for example, extend the app to handle successful payment events to list invoices in your billing system (by subscribing to `subscription_payment_success`). 167 | 168 | The webhook endpoint in your app is `/api/webhook`, which means if you are manually setting up the webhook, you need to append `/api/webook` to your webhook URL on Lemon Squeezy. For example, `https://your-app-url.com/api/webhook` 169 | 170 | The server action for creating a webhook via SDK will do that automatically for you. 171 | 172 | In the webhook form you need to add a signing secret. Add the same value you use in the form in the `LEMONSQUEEZY_WEBHOOK_SECRET` environment variable. 173 | 174 | ## Production 175 | 176 | There are a few things to update in your code to go live. 177 | 178 | You need to turn off the **Test mode** in your Lemon Squeezy store and add a new live mode API key. Add this API key as an environment variable in your live server, using the same name `LEMONSQUEEZY_API_KEY`. Your store ID remains the same in both test and live mode, so add that to your server environment variables, as you did for your development site. 179 | 180 | You also need to create a new webhook in your live store. Make sure you add the signing secret into the `LEMONSQUEEZY_WEBHOOK_SECRET` variable on your server. 181 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion -- allow */ 2 | 3 | import { defineConfig } from "drizzle-kit"; 4 | 5 | export default defineConfig({ 6 | schema: "./src/db/schema.ts", 7 | out: "./src/db/migrations", 8 | dialect: "postgresql", 9 | dbCredentials: { url: process.env.POSTGRES_URL! }, 10 | verbose: true, 11 | strict: true, 12 | }); 13 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "billing-v2", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "db:push": "drizzle-kit push", 8 | "db:studio": "drizzle-kit studio", 9 | "dev": "next dev", 10 | "format": "prettier --check .", 11 | "format:fix": "prettier --write --list-different .", 12 | "lint": "next lint", 13 | "start": "next start", 14 | "typecheck": "tsc --noEmit" 15 | }, 16 | "dependencies": { 17 | "@auth/drizzle-adapter": "^1.4.2", 18 | "@lemonsqueezy/lemonsqueezy.js": "^3.2.0", 19 | "@lemonsqueezy/wedges": "^1.3.1", 20 | "@neondatabase/serverless": "^0.9.4", 21 | "clsx": "^2.1.1", 22 | "drizzle-orm": "^0.32.1", 23 | "geist": "^1.3.1", 24 | "lucide-react": "^0.428.0", 25 | "next": "14.2.5", 26 | "next-auth": "5.0.0-beta.19", 27 | "pg": "^8.12.0", 28 | "react": "^18.3.1", 29 | "react-dom": "^18.3.1", 30 | "sonner": "^1.5.0", 31 | "tailwind-merge": "^2.5.2" 32 | }, 33 | "devDependencies": { 34 | "@next/eslint-plugin-next": "^14.2.5", 35 | "@tailwindcss/typography": "^0.5.14", 36 | "@types/node": "^22.3.0", 37 | "@types/react": "^18.3.3", 38 | "@types/react-dom": "^18.3.0", 39 | "@typescript-eslint/eslint-plugin": "^8.1.0", 40 | "@typescript-eslint/parser": "^8.1.0", 41 | "@vercel/style-guide": "^6.0.0", 42 | "autoprefixer": "^10.4.20", 43 | "drizzle-kit": "^0.23.0", 44 | "eslint": "^8.57.0", 45 | "eslint-config-prettier": "^9.1.0", 46 | "eslint-plugin-tailwindcss": "^3.17.4", 47 | "postcss": "^8.4.41", 48 | "prettier": "^3.3.3", 49 | "prettier-plugin-packagejson": "^2.5.1", 50 | "prettier-plugin-tailwindcss": "^0.6.6", 51 | "tailwindcss": "^3.4.10", 52 | "ts-node": "^10.9.2", 53 | "typescript": "^5.5.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /shims.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | createLemonSqueezy: () => void; 3 | LemonSqueezy: { 4 | /** 5 | * Initialises Lemon.js on your page. 6 | * @param options - An object with a single property, eventHandler, which is a function that will be called when Lemon.js emits an event. 7 | */ 8 | Setup: (options: { 9 | eventHandler: (event: { event: string }) => void; 10 | }) => void; 11 | /** 12 | * Refreshes `lemonsqueezy-button` listeners on the page. 13 | */ 14 | Refresh: () => void; 15 | 16 | Url: { 17 | /** 18 | * Opens a given Lemon Squeezy URL, typically these are Checkout or Payment Details Update overlays. 19 | * @param url - The URL to open. 20 | */ 21 | Open: (url: string) => void; 22 | 23 | /** 24 | * Closes the current opened Lemon Squeezy overlay checkout window. 25 | */ 26 | Close: () => void; 27 | }; 28 | Affiliate: { 29 | /** 30 | * Retrieve the affiliate tracking ID 31 | */ 32 | GetID: () => string; 33 | 34 | /** 35 | * Append the affiliate tracking parameter to the given URL 36 | * @param url - The URL to append the affiliate tracking parameter to. 37 | */ 38 | Build: (url: string) => string; 39 | }; 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/app/actions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console -- allow logs */ 2 | /* eslint-disable @typescript-eslint/no-non-null-assertion -- checked in configureLemonSqueezy() */ 3 | "use server"; 4 | 5 | import crypto from "node:crypto"; 6 | import { 7 | cancelSubscription, 8 | createCheckout, 9 | createWebhook, 10 | getPrice, 11 | getProduct, 12 | getSubscription, 13 | listPrices, 14 | listProducts, 15 | listWebhooks, 16 | updateSubscription, 17 | type Variant, 18 | } from "@lemonsqueezy/lemonsqueezy.js"; 19 | import { eq } from "drizzle-orm"; 20 | import { revalidatePath } from "next/cache"; 21 | import { notFound } from "next/navigation"; 22 | import { 23 | db, 24 | plans, 25 | subscriptions, 26 | webhookEvents, 27 | type NewPlan, 28 | type NewSubscription, 29 | type NewWebhookEvent, 30 | } from "@/db/schema"; 31 | import { configureLemonSqueezy } from "@/config/lemonsqueezy"; 32 | import { webhookHasData, webhookHasMeta } from "@/lib/typeguards"; 33 | import { takeUniqueOrThrow } from "@/lib/utils"; 34 | import { auth, signOut } from "../auth"; 35 | 36 | /** 37 | * This action will log out the current user. 38 | */ 39 | export async function logout() { 40 | await signOut(); 41 | } 42 | 43 | /** 44 | * This action will create a checkout on Lemon Squeezy. 45 | */ 46 | export async function getCheckoutURL(variantId: number, embed = false) { 47 | configureLemonSqueezy(); 48 | 49 | const session = await auth(); 50 | 51 | if (!session?.user) { 52 | throw new Error("User is not authenticated."); 53 | } 54 | 55 | const checkout = await createCheckout( 56 | process.env.LEMONSQUEEZY_STORE_ID!, 57 | variantId, 58 | { 59 | checkoutOptions: { 60 | embed, 61 | media: false, 62 | logo: !embed, 63 | }, 64 | checkoutData: { 65 | email: session.user.email ?? undefined, 66 | custom: { 67 | user_id: session.user.id, 68 | }, 69 | }, 70 | productOptions: { 71 | enabledVariants: [variantId], 72 | redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing/`, 73 | receiptButtonText: "Go to Dashboard", 74 | receiptThankYouNote: "Thank you for signing up to Lemon Stand!", 75 | }, 76 | }, 77 | ); 78 | 79 | return checkout.data?.data.attributes.url; 80 | } 81 | 82 | /** 83 | * This action will check if a webhook exists on Lemon Squeezy. It will return 84 | * the webhook if it exists, otherwise it will return undefined. 85 | */ 86 | export async function hasWebhook() { 87 | configureLemonSqueezy(); 88 | 89 | if (!process.env.WEBHOOK_URL) { 90 | throw new Error( 91 | "Missing required WEBHOOK_URL env variable. Please, set it in your .env file.", 92 | ); 93 | } 94 | 95 | // Check if a webhook exists on Lemon Squeezy. 96 | const allWebhooks = await listWebhooks({ 97 | filter: { storeId: process.env.LEMONSQUEEZY_STORE_ID }, 98 | }); 99 | 100 | // Check if WEBHOOK_URL ends with a slash. If not, add it. 101 | let webhookUrl = process.env.WEBHOOK_URL; 102 | if (!webhookUrl.endsWith("/")) { 103 | webhookUrl += "/"; 104 | } 105 | webhookUrl += "api/webhook"; 106 | 107 | const webhook = allWebhooks.data?.data.find( 108 | (wh) => wh.attributes.url === webhookUrl && wh.attributes.test_mode, 109 | ); 110 | 111 | revalidatePath("/"); 112 | 113 | return webhook; 114 | } 115 | 116 | /** 117 | * This action will set up a webhook on Lemon Squeezy to listen to 118 | * Subscription events. It will only set up the webhook if it does not exist. 119 | */ 120 | export async function setupWebhook() { 121 | configureLemonSqueezy(); 122 | 123 | if (!process.env.WEBHOOK_URL) { 124 | throw new Error( 125 | "Missing required WEBHOOK_URL env variable. Please, set it in your .env file.", 126 | ); 127 | } 128 | 129 | // Check if WEBHOOK_URL ends with a slash. If not, add it. 130 | let webhookUrl = process.env.WEBHOOK_URL; 131 | if (!webhookUrl.endsWith("/")) { 132 | webhookUrl += "/"; 133 | } 134 | webhookUrl += "api/webhook"; 135 | 136 | // Do not set a webhook on Lemon Squeezy if it already exists. 137 | let webhook = await hasWebhook(); 138 | 139 | // If the webhook does not exist, create it. 140 | if (!webhook) { 141 | const newWebhook = await createWebhook(process.env.LEMONSQUEEZY_STORE_ID!, { 142 | secret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET!, 143 | url: webhookUrl, 144 | testMode: true, // will create a webhook in Test mode only! 145 | events: [ 146 | "subscription_created", 147 | "subscription_expired", 148 | "subscription_updated", 149 | ], 150 | }); 151 | 152 | webhook = newWebhook.data?.data; 153 | } 154 | 155 | revalidatePath("/"); 156 | } 157 | 158 | /** 159 | * This action will sync the product variants from Lemon Squeezy with the 160 | * Plans database model. It will only sync the 'subscription' variants. 161 | */ 162 | export async function syncPlans() { 163 | configureLemonSqueezy(); 164 | 165 | // Fetch all the variants from the database. 166 | const productVariants: NewPlan[] = await db.select().from(plans); 167 | 168 | // Helper function to add a variant to the productVariants array and sync it with the database. 169 | async function _addVariant(variant: NewPlan) { 170 | // Sync the variant with the plan in the database. 171 | await db 172 | .insert(plans) 173 | .values(variant) 174 | .onConflictDoUpdate({ target: plans.variantId, set: variant }); 175 | 176 | productVariants.push(variant); 177 | } 178 | 179 | // Fetch products from the Lemon Squeezy store. 180 | const products = await listProducts({ 181 | filter: { storeId: process.env.LEMONSQUEEZY_STORE_ID }, 182 | include: ["variants"], 183 | }); 184 | 185 | // Loop through all the variants. 186 | const allVariants = products.data?.included as Variant["data"][] | undefined; 187 | 188 | // for...of supports asynchronous operations, unlike forEach. 189 | if (allVariants) { 190 | for (const v of allVariants) { 191 | const variant = v.attributes; 192 | 193 | // Skip draft variants or if there's more than one variant, skip the default 194 | // variant. See https://docs.lemonsqueezy.com/api/variants 195 | if ( 196 | variant.status === "draft" || 197 | (allVariants.length !== 1 && variant.status === "pending") 198 | ) { 199 | // `return` exits the function entirely, not just the current iteration. 200 | continue; 201 | } 202 | 203 | // Fetch the Product name. 204 | const productName = 205 | (await getProduct(variant.product_id)).data?.data.attributes.name ?? ""; 206 | 207 | // Fetch the Price object. 208 | const variantPriceObject = await listPrices({ 209 | filter: { 210 | variantId: v.id, 211 | }, 212 | }); 213 | 214 | const currentPriceObj = variantPriceObject.data?.data.at(0); 215 | const isUsageBased = 216 | currentPriceObj?.attributes.usage_aggregation !== null; 217 | const interval = currentPriceObj?.attributes.renewal_interval_unit; 218 | const intervalCount = 219 | currentPriceObj?.attributes.renewal_interval_quantity; 220 | const trialInterval = currentPriceObj?.attributes.trial_interval_unit; 221 | const trialIntervalCount = 222 | currentPriceObj?.attributes.trial_interval_quantity; 223 | 224 | const price = isUsageBased 225 | ? currentPriceObj?.attributes.unit_price_decimal 226 | : currentPriceObj.attributes.unit_price; 227 | 228 | const priceString = price !== null ? (price?.toString() ?? "") : ""; 229 | 230 | const isSubscription = 231 | currentPriceObj?.attributes.category === "subscription"; 232 | 233 | // If not a subscription, skip it. 234 | if (!isSubscription) { 235 | continue; 236 | } 237 | 238 | await _addVariant({ 239 | name: variant.name, 240 | description: variant.description, 241 | price: priceString, 242 | interval, 243 | intervalCount, 244 | isUsageBased, 245 | productId: variant.product_id, 246 | productName, 247 | variantId: parseInt(v.id) as unknown as number, 248 | trialInterval, 249 | trialIntervalCount, 250 | sort: variant.sort, 251 | }); 252 | } 253 | } 254 | 255 | revalidatePath("/"); 256 | 257 | return productVariants; 258 | } 259 | 260 | /** 261 | * This action will store a webhook event in the database. 262 | * @param eventName - The name of the event. 263 | * @param body - The body of the event. 264 | */ 265 | export async function storeWebhookEvent( 266 | eventName: string, 267 | body: NewWebhookEvent["body"], 268 | ) { 269 | if (!process.env.POSTGRES_URL) { 270 | throw new Error("POSTGRES_URL is not set"); 271 | } 272 | 273 | const id = crypto.randomInt(100000000, 1000000000); 274 | 275 | const returnedValue = await db 276 | .insert(webhookEvents) 277 | .values({ 278 | id, 279 | eventName, 280 | processed: false, 281 | body, 282 | }) 283 | .onConflictDoNothing({ target: plans.id }) 284 | .returning(); 285 | 286 | return returnedValue[0]; 287 | } 288 | 289 | /** 290 | * This action will process a webhook event in the database. 291 | */ 292 | export async function processWebhookEvent(webhookEvent: NewWebhookEvent) { 293 | configureLemonSqueezy(); 294 | 295 | const dbwebhookEvent = await db 296 | .select() 297 | .from(webhookEvents) 298 | .where(eq(webhookEvents.id, webhookEvent.id)); 299 | 300 | if (dbwebhookEvent.length < 1) { 301 | throw new Error( 302 | `Webhook event #${webhookEvent.id} not found in the database.`, 303 | ); 304 | } 305 | 306 | if (!process.env.WEBHOOK_URL) { 307 | throw new Error( 308 | "Missing required WEBHOOK_URL env variable. Please, set it in your .env file.", 309 | ); 310 | } 311 | 312 | let processingError = ""; 313 | const eventBody = webhookEvent.body; 314 | 315 | if (!webhookHasMeta(eventBody)) { 316 | processingError = "Event body is missing the 'meta' property."; 317 | } else if (webhookHasData(eventBody)) { 318 | if (webhookEvent.eventName.startsWith("subscription_payment_")) { 319 | // Save subscription invoices; eventBody is a SubscriptionInvoice 320 | // Not implemented. 321 | } else if (webhookEvent.eventName.startsWith("subscription_")) { 322 | // Save subscription events; obj is a Subscription 323 | const attributes = eventBody.data.attributes; 324 | const variantId = attributes.variant_id as string; 325 | 326 | // We assume that the Plan table is up to date. 327 | const plan = await db 328 | .select() 329 | .from(plans) 330 | .where(eq(plans.variantId, parseInt(variantId, 10))); 331 | 332 | if (plan.length < 1) { 333 | processingError = `Plan with variantId ${variantId} not found.`; 334 | } else { 335 | // Update the subscription in the database. 336 | 337 | const priceId = attributes.first_subscription_item.price_id; 338 | 339 | // Get the price data from Lemon Squeezy. 340 | const priceData = await getPrice(priceId); 341 | if (priceData.error) { 342 | processingError = `Failed to get the price data for the subscription ${eventBody.data.id}.`; 343 | } 344 | 345 | const isUsageBased = attributes.first_subscription_item.is_usage_based; 346 | const price = isUsageBased 347 | ? priceData.data?.data.attributes.unit_price_decimal 348 | : priceData.data?.data.attributes.unit_price; 349 | 350 | const updateData: NewSubscription = { 351 | lemonSqueezyId: eventBody.data.id, 352 | orderId: attributes.order_id as number, 353 | name: attributes.user_name as string, 354 | email: attributes.user_email as string, 355 | status: attributes.status as string, 356 | statusFormatted: attributes.status_formatted as string, 357 | renewsAt: attributes.renews_at as string, 358 | endsAt: attributes.ends_at as string, 359 | trialEndsAt: attributes.trial_ends_at as string, 360 | price: price?.toString() ?? "", 361 | isPaused: false, 362 | subscriptionItemId: attributes.first_subscription_item.id, 363 | isUsageBased: attributes.first_subscription_item.is_usage_based, 364 | userId: eventBody.meta.custom_data.user_id, 365 | planId: plan[0].id, 366 | }; 367 | 368 | // Create/update subscription in the database. 369 | try { 370 | await db.insert(subscriptions).values(updateData).onConflictDoUpdate({ 371 | target: subscriptions.lemonSqueezyId, 372 | set: updateData, 373 | }); 374 | } catch (error) { 375 | processingError = `Failed to upsert Subscription #${updateData.lemonSqueezyId} to the database.`; 376 | console.error(error); 377 | } 378 | } 379 | } else if (webhookEvent.eventName.startsWith("order_")) { 380 | // Save orders; eventBody is a "Order" 381 | /* Not implemented */ 382 | } else if (webhookEvent.eventName.startsWith("license_")) { 383 | // Save license keys; eventBody is a "License key" 384 | /* Not implemented */ 385 | } 386 | 387 | // Update the webhook event in the database. 388 | await db 389 | .update(webhookEvents) 390 | .set({ 391 | processed: true, 392 | processingError, 393 | }) 394 | .where(eq(webhookEvents.id, webhookEvent.id)); 395 | } 396 | } 397 | 398 | /** 399 | * This action will get the subscriptions for the current user. 400 | */ 401 | export async function getUserSubscriptions() { 402 | const session = await auth(); 403 | const userId = session?.user?.id; 404 | 405 | if (!userId) { 406 | notFound(); 407 | } 408 | 409 | const userSubscriptions: NewSubscription[] = await db 410 | .select() 411 | .from(subscriptions) 412 | .where(eq(subscriptions.userId, userId)); 413 | 414 | revalidatePath("/"); 415 | 416 | return userSubscriptions; 417 | } 418 | 419 | /** 420 | * This action will get the subscription URLs (update_payment_method and 421 | * customer_portal) for the given subscription ID. 422 | * 423 | */ 424 | export async function getSubscriptionURLs(id: string) { 425 | configureLemonSqueezy(); 426 | const subscription = await getSubscription(id); 427 | 428 | if (subscription.error) { 429 | throw new Error(subscription.error.message); 430 | } 431 | 432 | revalidatePath("/"); 433 | 434 | return subscription.data.data.attributes.urls; 435 | } 436 | 437 | /** 438 | * This action will cancel a subscription on Lemon Squeezy. 439 | */ 440 | export async function cancelSub(id: string) { 441 | configureLemonSqueezy(); 442 | 443 | // Get user subscriptions 444 | const userSubscriptions = await getUserSubscriptions(); 445 | 446 | // Check if the subscription exists 447 | const subscription = userSubscriptions.find( 448 | (sub) => sub.lemonSqueezyId === id, 449 | ); 450 | 451 | if (!subscription) { 452 | throw new Error(`Subscription #${id} not found.`); 453 | } 454 | 455 | const cancelledSub = await cancelSubscription(id); 456 | 457 | if (cancelledSub.error) { 458 | throw new Error(cancelledSub.error.message); 459 | } 460 | 461 | // Update the db 462 | try { 463 | await db 464 | .update(subscriptions) 465 | .set({ 466 | status: cancelledSub.data.data.attributes.status, 467 | statusFormatted: cancelledSub.data.data.attributes.status_formatted, 468 | endsAt: cancelledSub.data.data.attributes.ends_at, 469 | }) 470 | .where(eq(subscriptions.lemonSqueezyId, id)); 471 | } catch (error) { 472 | throw new Error(`Failed to cancel Subscription #${id} in the database.`); 473 | } 474 | 475 | revalidatePath("/"); 476 | 477 | return cancelledSub; 478 | } 479 | 480 | /** 481 | * This action will pause a subscription on Lemon Squeezy. 482 | */ 483 | export async function pauseUserSubscription(id: string) { 484 | configureLemonSqueezy(); 485 | 486 | // Get user subscriptions 487 | const userSubscriptions = await getUserSubscriptions(); 488 | 489 | // Check if the subscription exists 490 | const subscription = userSubscriptions.find( 491 | (sub) => sub.lemonSqueezyId === id, 492 | ); 493 | 494 | if (!subscription) { 495 | throw new Error(`Subscription #${id} not found.`); 496 | } 497 | 498 | const returnedSub = await updateSubscription(id, { 499 | pause: { 500 | mode: "void", 501 | }, 502 | }); 503 | 504 | // Update the db 505 | try { 506 | await db 507 | .update(subscriptions) 508 | .set({ 509 | status: returnedSub.data?.data.attributes.status, 510 | statusFormatted: returnedSub.data?.data.attributes.status_formatted, 511 | endsAt: returnedSub.data?.data.attributes.ends_at, 512 | isPaused: returnedSub.data?.data.attributes.pause !== null, 513 | }) 514 | .where(eq(subscriptions.lemonSqueezyId, id)); 515 | } catch (error) { 516 | throw new Error(`Failed to pause Subscription #${id} in the database.`); 517 | } 518 | 519 | revalidatePath("/"); 520 | 521 | return returnedSub; 522 | } 523 | 524 | /** 525 | * This action will unpause a subscription on Lemon Squeezy. 526 | */ 527 | export async function unpauseUserSubscription(id: string) { 528 | configureLemonSqueezy(); 529 | 530 | // Get user subscriptions 531 | const userSubscriptions = await getUserSubscriptions(); 532 | 533 | // Check if the subscription exists 534 | const subscription = userSubscriptions.find( 535 | (sub) => sub.lemonSqueezyId === id, 536 | ); 537 | 538 | if (!subscription) { 539 | throw new Error(`Subscription #${id} not found.`); 540 | } 541 | 542 | const returnedSub = await updateSubscription(id, { pause: null }); 543 | 544 | // Update the db 545 | try { 546 | await db 547 | .update(subscriptions) 548 | .set({ 549 | status: returnedSub.data?.data.attributes.status, 550 | statusFormatted: returnedSub.data?.data.attributes.status_formatted, 551 | endsAt: returnedSub.data?.data.attributes.ends_at, 552 | isPaused: returnedSub.data?.data.attributes.pause !== null, 553 | }) 554 | .where(eq(subscriptions.lemonSqueezyId, id)); 555 | } catch (error) { 556 | throw new Error(`Failed to pause Subscription #${id} in the database.`); 557 | } 558 | 559 | revalidatePath("/"); 560 | 561 | return returnedSub; 562 | } 563 | 564 | /** 565 | * This action will change the plan of a subscription on Lemon Squeezy. 566 | */ 567 | export async function changePlan(currentPlanId: number, newPlanId: number) { 568 | configureLemonSqueezy(); 569 | 570 | // Get user subscriptions 571 | const userSubscriptions = await getUserSubscriptions(); 572 | 573 | // Check if the subscription exists 574 | const subscription = userSubscriptions.find( 575 | (sub) => sub.planId === currentPlanId, 576 | ); 577 | 578 | if (!subscription) { 579 | throw new Error( 580 | `No subscription with plan id #${currentPlanId} was found.`, 581 | ); 582 | } 583 | 584 | // Get the new plan details from the database. 585 | const newPlan = await db 586 | .select() 587 | .from(plans) 588 | .where(eq(plans.id, newPlanId)) 589 | .then(takeUniqueOrThrow); 590 | 591 | // Send request to Lemon Squeezy to change the subscription. 592 | const updatedSub = await updateSubscription(subscription.lemonSqueezyId, { 593 | variantId: newPlan.variantId, 594 | }); 595 | 596 | // Save in db 597 | try { 598 | await db 599 | .update(subscriptions) 600 | .set({ 601 | planId: newPlanId, 602 | price: newPlan.price, 603 | endsAt: updatedSub.data?.data.attributes.ends_at, 604 | }) 605 | .where(eq(subscriptions.lemonSqueezyId, subscription.lemonSqueezyId)); 606 | } catch (error) { 607 | throw new Error( 608 | `Failed to update Subscription #${subscription.lemonSqueezyId} in the database.`, 609 | ); 610 | } 611 | 612 | revalidatePath("/"); 613 | 614 | return updatedSub; 615 | } 616 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/auth"; 2 | 3 | export const { GET, POST } = handlers; 4 | -------------------------------------------------------------------------------- /src/app/api/webhook/route.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import { processWebhookEvent, storeWebhookEvent } from "@/app/actions"; 3 | import { webhookHasMeta } from "@/lib/typeguards"; 4 | 5 | export async function POST(request: Request) { 6 | if (!process.env.LEMONSQUEEZY_WEBHOOK_SECRET) { 7 | return new Response("Lemon Squeezy Webhook Secret not set in .env", { 8 | status: 500, 9 | }); 10 | } 11 | 12 | /* -------------------------------------------------------------------------- */ 13 | /* First, make sure the request is from Lemon Squeezy. */ 14 | /* -------------------------------------------------------------------------- */ 15 | 16 | // Get the raw body content. 17 | const rawBody = await request.text(); 18 | 19 | // Get the webhook secret from the environment variables. 20 | const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET; 21 | 22 | // Get the signature from the request headers. 23 | const signature = Buffer.from( 24 | request.headers.get("X-Signature") ?? "", 25 | "hex", 26 | ); 27 | 28 | // Create a HMAC-SHA256 hash of the raw body content using the secret and 29 | // compare it to the signature. 30 | const hmac = Buffer.from( 31 | crypto.createHmac("sha256", secret).update(rawBody).digest("hex"), 32 | "hex", 33 | ); 34 | 35 | if (!crypto.timingSafeEqual(hmac, signature)) { 36 | return new Response("Invalid signature", { status: 400 }); 37 | } 38 | 39 | /* -------------------------------------------------------------------------- */ 40 | /* Valid request */ 41 | /* -------------------------------------------------------------------------- */ 42 | 43 | const data = JSON.parse(rawBody) as unknown; 44 | 45 | // Type guard to check if the object has a 'meta' property. 46 | if (webhookHasMeta(data)) { 47 | const webhookEventId = await storeWebhookEvent(data.meta.event_name, data); 48 | 49 | await processWebhookEvent(webhookEventId); 50 | 51 | return new Response("OK", { status: 200 }); 52 | } 53 | 54 | return new Response("Data invalid", { status: 400 }); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/dashboard/billing/change-plans/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@lemonsqueezy/wedges"; 2 | import { eq } from "drizzle-orm"; 3 | import Link from "next/link"; 4 | import { notFound, redirect } from "next/navigation"; 5 | import { getUserSubscriptions } from "@/app/actions"; 6 | import { ChangePlans } from "@/components/dashboard/billing/plans/change-plans"; 7 | import { DashboardContent } from "@/components/dashboard/content"; 8 | import { PageTitleAction } from "@/components/dashboard/page-title-action"; 9 | import { db, plans } from "@/db/schema"; 10 | import { isValidSubscription } from "@/lib/utils"; 11 | import { type SubscriptionStatusType } from "@/types/types"; 12 | 13 | export const dynamic = "force-dynamic"; 14 | 15 | export default async function ChangePlansPage({ 16 | params, 17 | }: { 18 | params: { id?: string }; 19 | }) { 20 | if (!params.id) { 21 | notFound(); 22 | } 23 | const currentPlanId = parseInt(params.id); 24 | 25 | if (isNaN(currentPlanId)) { 26 | notFound(); 27 | } 28 | 29 | // Get user subscriptions to check the current plan. 30 | const userSubscriptions = await getUserSubscriptions(); 31 | 32 | if (!userSubscriptions.length) { 33 | notFound(); 34 | } 35 | 36 | const isCurrentPlan = userSubscriptions.find( 37 | (s) => 38 | s.planId === currentPlanId && 39 | isValidSubscription(s.status as SubscriptionStatusType), 40 | ); 41 | 42 | if (!isCurrentPlan) { 43 | redirect("/dashboard/billing"); 44 | } 45 | 46 | const currentPlan = await db 47 | .select() 48 | .from(plans) 49 | .where(eq(plans.id, currentPlanId)); 50 | 51 | if (!currentPlan.length) { 52 | notFound(); 53 | } 54 | 55 | return ( 56 | 61 | 64 | 65 | 66 | } 67 | > 68 | 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/app/dashboard/billing/change-plans/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loading } from "@lemonsqueezy/wedges"; 2 | import { DashboardContent } from "@/components/dashboard/content"; 3 | 4 | export default function LoadingComponent() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/dashboard/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { Plans } from "@/components/dashboard/billing/plans/plans"; 3 | import { Subscriptions } from "@/components/dashboard/billing/subscription/subscriptions"; 4 | import { DashboardContent } from "@/components/dashboard/content"; 5 | import { PageTitleAction } from "@/components/dashboard/page-title-action"; 6 | import { PlansSkeleton } from "@/components/dashboard/skeletons/plans"; 7 | import { CardSkeleton } from "@/components/dashboard/skeletons/card"; 8 | 9 | export const dynamic = "force-dynamic"; 10 | 11 | export default function BillingPage() { 12 | return ( 13 | } 17 | > 18 |
19 | }> 20 | 21 | 22 | 23 | }> 24 | 25 | 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import Script from "next/script"; 3 | import { Sidebar } from "@/components/dashboard/sidebar"; 4 | import { Toaster } from "@/components/toaster"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Dashboard | Lemon Squeezy Next.js Billing Template", 8 | }; 9 | 10 | export default function DashboardLayout({ 11 | children, 12 | }: Readonly<{ 13 | children: React.ReactNode; 14 | }>) { 15 | return ( 16 | <> 17 | {/* Load the Lemon Squeezy's Lemon.js script before the page is interactive. */} 18 |