Lemon Squeezy x Supabase x Next JS - Part 6
Updating your Lemon Squeezy database with the right data
The complete guide is a work in progress, this first part is completed.
This is part two of the guide, for the full guide, start here
If you are looking for a guide to help you out with setting up your very own Subscription using Next.JS, Lemon Squeezy and Supabase, you have come to the right place. In this first guide, we will look at how you can fetch products from Lemon Squeezy, so that you can populate your pricing table!
This guide is specifically created for Subscriptions with one product and multiple variants. You can see the difference in pricing strategy here.
The goal of this guide is that by the end you will be able to fetch your own products and render them using Next.js 13 App Router.
As I have mentioned in the first part of the guide, I prefer to create a component per function. If you prefer to create most in one single file, this is also possible. Remember that not all RSC can be used in an Async (Server) component, so you might have to sometimes create a client component.
Receiving Lemon Squeezy webhook data
In part 5 of the guide, we have seen that we can retrieve the data from Lemon Squeezy in a structured manner. This data can be used to populate our Supabase database. The interface is not a user, so RLS can not apply, this means we will have to use something that can overwrite all Row Level Security: Service Key.
In your projects root folder, create a folder called utils if you dont already have one. In this folder, add file called supabase.js.
The content of your supabase file should look like this:
import { createClient } from "@supabase/supabase-js";
export const getServiceSupabase = () => createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SERVICE_KEY,
{ persistSession: false }
)
In the above file, we receive the URL and the service key from our .env file. These are used to create a Supabase client using the auth helpers createClient function.
With the createClient function in combination with the Service Key, we can update our Database from the api route we are about to create.
Create Lemon Squeezy API route
The following route will be very long. I will break down each part in detail, so you will know what each part of the route does, and how you can update it to make it your own.
It is important to understand that I will update my code with different information, as I use a different table structure in my application. If you want you can copy it one to one, just know I use the variant information later to determine what I render to the users in the frontend.
I took a lot of inspiration (and copied a large part) from a Twitter/X friend '@amosbastian'
import { getServiceSupabase } from "@/utils/supabase";
import crypto from "crypto";
import { NextRequest } from "next/server";
export interface LemonsqueezySubscription {
id: string;
type: string;
attributes: Attributes;
relationships: any;
}
export interface Attributes {
store_id: number;
customer_id: number;
order_id: number;
order_item_id: number;
product_id: number;
variant_id: number;
product_name: string;
variant_name: string;
user_name: string;
user_email: string;
status: string;
status_formatted: string;
card_brand: string;
card_last_four: string;
pause: string | null;
cancelled: boolean;
trial_ends_at: string | null;
billing_anchor: number;
urls: {
update_payment_method: string;
};
renews_at: string;
ends_at: string | null;
created_at: string;
updated_at: string;
test_mode: boolean;
}
const isError = (error: unknown): error is Error => {
return error instanceof Error;
};
// Add more events here if you want
// https://docs.lemonsqueezy.com/api/webhooks#event-types
type EventName =
| "order_created"
| "order_refunded"
| "subscription_created"
| "subscription_updated"
| "subscription_cancelled"
| "subscription_resumed"
| "subscription_expired"
| "subscription_paused"
| "subscription_unpaused"
| "subscription_payment_failed"
| "subscription_payment_success"
| "subscription_payment_recovered";
type Payload = {
meta: {
test_mode: boolean;
event_name: EventName;
custom_data: {
teamId: string;
};
};
data: LemonsqueezySubscription;
};
export const POST = async (req: NextRequest) => {
const supabase = getServiceSupabase()
try {
const rawBody = await req.text();
const hmac = crypto.createHmac("sha256", process.env.LEMON_SQUEEZY_WEBHOOK_SECRET || "");
const digest = Buffer.from(hmac.update(rawBody).digest("hex"), "utf8");
const signature = Buffer.from(req.headers.get("x-signature") as string, "utf8");
if (!crypto.timingSafeEqual(digest, signature)) {
return new Response("Invalid signature.", {
status: 400,
});
}
const payload = JSON.parse(rawBody);
const {
meta: {
event_name: eventName,
},
data: subscription,
} = payload as Payload;
switch (eventName) {
case "order_created":
case "order_refunded":
case "subscription_created":
const companyId = payload.meta.custom_data.company_id
await supabase
.from('company_info')
.update({
customer_id: payload.data.attributes.customer_id,
status: payload.data.attributes.status,
product_id: payload.data.attributes.product_id,
variant_id: payload.data.attributes.variant_id,
ends_at: payload.data.attributes.ends_at,
renews_at: payload.data.attributes.renews_at,
subscription_id: payload.data.id,
update_payment_method: payload.data.attributes.urls.update_payment_method,
})
.eq('company_id', companyId)
break;
case "subscription_updated":
let customerId = payload.data.attributes.customer_id
await supabase
.from('company_info')
.update({
customer_id: payload.data.attributes.customer_id,
status: payload.data.attributes.status,
product_id: payload.data.attributes.product_id,
variant_id: payload.data.attributes.variant_id,
ends_at: payload.data.attributes.ends_at,
renews_at: payload.data.attributes.renews_at,
subscription_id: payload.data.id,
update_payment_method: payload.data.attributes.urls.update_payment_method,
})
.eq('customer_id', customerId)
break;
case "subscription_cancelled":
let customerId2 = payload.data.attributes.customer_id
await supabase
.from('company_info')
.update({
customer_id: payload.data.attributes.customer_id,
status: payload.data.attributes.status,
product_id: payload.data.attributes.product_id,
variant_id: payload.data.attributes.variant_id,
ends_at: payload.data.attributes.ends_at,
renews_at: null,
subscription_id: payload.data.id,
update_payment_method: payload.data.attributes.urls.update_payment_method,
})
.eq('customer_id', customerId2)
break;
case "subscription_resumed":
case "subscription_expired":
let customerId3 = payload.data.attributes.customer_id
await supabase
.from('company_info')
.update({
customer_id: payload.data.attributes.customer_id,
status: null,
product_id: null,
variant_id: null,
ends_at: null,
renews_at: null,
subscription_id: payload.data.id,
update_payment_method: null,
})
.eq('customer_id', customerId3)
break;
case "subscription_paused":
case "subscription_unpaused":
case "subscription_payment_failed":
case "subscription_payment_success":
case "subscription_payment_recovered":
break;
default:
throw new Error(`🤷♀️ Unhandled event: ${eventName}`);
}
} catch (error: unknown) {
if (isError(error)) {
console.error(error.message);
return new Response(`Webhook error: ${error.message}`, {
status: 400,
});
}
console.error(error);
return new Response("Webhook error", {
status: 400,
});
}
return new Response(null, {
status: 200,
});
};
Creating type safety
As we saw in Creating the webhook