16 | Sign in to Lemon Stand 17 |
18 | 19 |20 | Lemon Stand is a Next.js billing app template powered by Lemon 21 | Squeezy. 22 |
23 | 24 | 40 |├── .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 |
16 | This page is protected by the auth
middleware. Navigate to
17 | the Billing page to get started.
18 |
25 | This app relies on webhooks to listen for changes made on Lemon 26 | Squeezy. Make sure that you have entered all the required 27 | environment variables (.env). This section is an example of how 28 | you'd use the Lemon Squeezy API to interact with webhooks. 29 |
30 | 31 |32 | Configure the webhook on{" "} 33 | 37 | Lemon Squeezy 38 | 39 | , or simply click the button below to do that automatically with the 40 | Lemon Squeezy SDK. 41 |
42 | 43 |20 | Lemon Stand is a Next.js billing app template powered by Lemon 21 | Squeezy. 22 |
23 | 24 | 40 |73 | There are no plans available at the moment. 74 |
75 |{message}
36 | > 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/dashboard/billing/subscription/modal-link.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DropdownMenu } from "@lemonsqueezy/wedges"; 4 | import { type ReactNode, useEffect } from "react"; 5 | 6 | export function LemonSqueezyModalLink({ 7 | href, 8 | children, 9 | }: { 10 | href?: string; 11 | children: ReactNode; 12 | }) { 13 | useEffect(() => { 14 | window.createLemonSqueezy(); 15 | }, []); 16 | 17 | return ( 18 |{`${formattedPrice} ${formattedIntervalCount} ${interval}`}
; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/dashboard/billing/subscription/status.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, type BadgeProps } from "@lemonsqueezy/wedges"; 2 | import { type SubscriptionStatusType } from "@/types/types"; 3 | 4 | export function SubscriptionStatus({ 5 | status, 6 | statusFormatted, 7 | isPaused, 8 | }: { 9 | status: SubscriptionStatusType; 10 | statusFormatted: string; 11 | isPaused?: boolean; 12 | }) { 13 | const statusColor: Record19 | It appears that you do not have any subscriptions. Please sign up for a 20 | plan below. 21 |
22 | ); 23 | } 24 | 25 | // Show active subscriptions first, then paused, then canceled 26 | const sortedSubscriptions = userSubscriptions.sort((a, b) => { 27 | if (a.status === "active" && b.status !== "active") { 28 | return -1; 29 | } 30 | 31 | if (a.status === "paused" && b.status === "cancelled") { 32 | return -1; 33 | } 34 | 35 | return 0; 36 | }); 37 | 38 | return ( 39 |{props.subtitle}
28 | )} 29 |