
Next.js-Supabase Stripe Subscriptions Integration
I've been working on a Next.js project with my team, a powerful trading journal designed to help monitor emotions during trading. If you're interested in learning more, visitwww.mindtrajour.com. As we approach the full release, integrating Stripe into our app became essential.
I've used Stripe numerous times in dummy apps like Netflix clones and Airbnb clones but never in a production app. Most online instructions focus on using starter kits, likethe one created by Vercel and while theStripe Docs guide you through integrating subscriptions, there’s no specific documentation for Next.js, particularly for the App Router. Although we can mimic the instructions for React + Node.js, the documentation lacks essential features, especially regarding Stripe triggers. This is why I've decided to document the process for you.
This guide assumes you already have a Next.js App Router setup with Supabase DB and Auth running. If you're starting from scratch, I recommend using the Vercel starter kit.
Table of Contents
- Prerequisites
- Create Required Tables in Supabase
- Get Supabase Service Role Key
- Add Required Packages
- Setup Stripe Code Locally
- Add Webhooks to Sync Your Database with Stripe
- Create Your Stripe Account and Connect the Webhook
- Create Product and Price
- Add Front-end to Test
Prerequisites
- Next.js App Router setup
- TypeScript (optional)
- Supabase setup with Auth
1. Create Required Tables in Supabase
There are a few ways to accomplish this:
- Create & Run Supabase Migration: Use below script to create the migration file
- Run the SQL script in the SQL editor: You can run the script provided below directly.
- Use the Quick Start template: Navigate to the SQL Editor, select "Quick Start," and run the Stripe Subscriptions template.
/** * USERS* Note: This table contains user data. Users should only be able to view and update their own data.*/createtableusers(-- UUID from auth.usersiduuidreferencesauth.usersnotnullprimarykey,full_nametext,avatar_urltext,-- The customer's billing address, stored in JSON format.billing_addressjsonb,-- Stores your customer's payment instruments.payment_methodjsonb);altertableusersenablerowlevelsecurity;createpolicy"Can view own user data."onusersforselectusing(auth.uid()=id);createpolicy"Can update own user data."onusersforupdateusing(auth.uid()=id);/*** This trigger automatically creates a user entry when a new user signs up via Supabase Auth.*/createfunctionpublic.handle_new_user()returnstriggeras$$begininsertintopublic.users(id,full_name,avatar_url)values(new.id,new.raw_user_meta_data->>'full_name',new.raw_user_meta_data->>'avatar_url');returnnew;end;$$languageplpgsqlsecuritydefiner;createtriggeron_auth_user_createdafterinsertonauth.usersforeachrowexecuteprocedurepublic.handle_new_user();/*** CUSTOMERS* Note: this is a private table that contains a mapping of user IDs to Stripe customer IDs.*/createtablecustomers(-- UUID from auth.usersiduuidreferencesauth.usersnotnullprimarykey,-- The user's customer ID in Stripe. User must not be able to update this.stripe_customer_idtext);altertablecustomersenablerowlevelsecurity;-- No policies as this is a private table that the user must not have access to./** * PRODUCTS* Note: products are created and managed in Stripe and synced to our DB via Stripe webhooks.*/createtableproducts(-- Product ID from Stripe, e.g. prod_1234.idtextprimarykey,-- Whether the product is currently available for purchase.activeboolean,-- The product's name, meant to be displayable to the customer. Whenever this product is sold via a subscription, name will show up on associated invoice line item descriptions.nametext,-- The product's description, meant to be displayable to the customer. Use this field to optionally store a long form explanation of the product being sold for your own rendering purposes.descriptiontext,-- A URL of the product image in Stripe, meant to be displayable to the customer.imagetext,-- Set of key-value pairs, used to store additional information about the object in a structured format.metadatajsonb);altertableproductsenablerowlevelsecurity;createpolicy"Allow public read-only access."onproductsforselectusing(true);/*** PRICES* Note: prices are created and managed in Stripe and synced to our DB via Stripe webhooks.*/createtypepricing_typeasenum('one_time','recurring');createtypepricing_plan_intervalasenum('day','week','month','year');createtableprices(-- Price ID from Stripe, e.g. price_1234.idtextprimarykey,-- The ID of the prduct that this price belongs to.product_idtextreferencesproducts,-- Whether the price can be used for new purchases.activeboolean,-- A brief description of the price.descriptiontext,-- The unit amount as a positive integer in the smallest currency unit (e.g., 100 cents for US$1.00 or 100 for ¥100, a zero-decimal currency).unit_amountbigint,-- Three-letter ISO currency code, in lowercase.currencytextcheck(char_length(currency)=3),-- One of `one_time` or `recurring` depending on whether the price is for a one-time purchase or a recurring (subscription) purchase.typepricing_type,-- The frequency at which a subscription is billed. One of `day`, `week`, `month` or `year`.intervalpricing_plan_interval,-- The number of intervals (specified in the `interval` attribute) between subscription billings. For example, `interval=month` and `interval_count=3` bills every 3 months.interval_countinteger,-- Default number of trial days when subscribing a customer to this price using [`trial_from_plan=true`](https://stripe.com/docs/api#create_subscription-trial_from_plan).trial_period_daysinteger,-- Set of key-value pairs, used to store additional information about the object in a structured format.metadatajsonb);altertablepricesenablerowlevelsecurity;createpolicy"Allow public read-only access."onpricesforselectusing(true);/*** SUBSCRIPTIONS* Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks.*/createtypesubscription_statusasenum('trialing','active','canceled','incomplete','incomplete_expired','past_due','unpaid','paused');createtablesubscriptions(-- Subscription ID from Stripe, e.g. sub_1234.idtextprimarykey,user_iduuidreferencesauth.usersnotnull,-- The status of the subscription object, one of subscription_status type above.statussubscription_status,-- Set of key-value pairs, used to store additional information about the object in a structured format.metadatajsonb,-- ID of the price that created this subscription.price_idtextreferencesprices,-- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.quantityinteger,-- If true the subscription has been canceled by the user and will be deleted at the end of the billing period.cancel_at_period_endboolean,-- Time at which the subscription was created.createdtimestampwithtimezonedefaulttimezone('utc'::text,now())notnull,-- Start of the current period that the subscription has been invoiced for.current_period_starttimestampwithtimezonedefaulttimezone('utc'::text,now())notnull,-- End of the current period that the subscription has been invoiced for. At the end of this period, a new invoice will be created.current_period_endtimestampwithtimezonedefaulttimezone('utc'::text,now())notnull,-- If the subscription has ended, the timestamp of the date the subscription ended.ended_attimestampwithtimezonedefaulttimezone('utc'::text,now()),-- A date in the future at which the subscription will automatically get canceled.cancel_attimestampwithtimezonedefaulttimezone('utc'::text,now()),-- If the subscription has been canceled, the date of that cancellation. If the subscription was canceled with `cancel_at_period_end`, `canceled_at` will still reflect the date of the initial cancellation request, not the end of the subscription period when the subscription is automatically moved to a canceled state.canceled_attimestampwithtimezonedefaulttimezone('utc'::text,now()),-- If the subscription has a trial, the beginning of that trial.trial_starttimestampwithtimezonedefaulttimezone('utc'::text,now()),-- If the subscription has a trial, the end of that trial.trial_endtimestampwithtimezonedefaulttimezone('utc'::text,now()));altertablesubscriptionsenablerowlevelsecurity;createpolicy"Can only view own subs data."onsubscriptionsforselectusing(auth.uid()=user_id);/** * REALTIME SUBSCRIPTIONS * Only allow realtime listening on public tables. */droppublicationifexistssupabase_realtime;createpublicationsupabase_realtimefortableproducts,prices;
2. Get Supabase Service Role Key
If you're running a local Supabase instance:
- Start your Supabase container and copy the service_role key.
If you're using Supabase cloud:
- Go to your Supabase project dashboard.
- Navigate to "Project Settings."
- Click on "API."
- Copy the service_role key under "Project API keys."
Add this key to your.env
file asSUPABASE_SERVICE_ROLE_KEY
. Ensure this key is not exposed, as it could allow anyone to manipulate your data.
3. Add Required Packages
Install the Stripe and micro packages:
yarn add microyarn add stripe @stripe/react-stripe-js @stripe/stripe-js
4. Setup Stripe Code Locally
This step will create the backbone of our app. We will use these configurations in the next stages. Essentially, this step configures our Stripe setup and provides functions for the front-end. Theserver.ts
file is particularly important, containing two significant functions. The Supabase code will provide admin scripts for the webhook to sync the database and queries to fetch data for the front-end.
Create astripe
folder in your project and add the following files:
//config.tsimportStripefrom'stripe';exportconststripe=newStripe(process.env.STRIPE_SECRET_KEY_LIVE??process.env.STRIPE_SECRET_KEY??'',{apiVersion:'2024-06-20',// Register this as an official Stripe plugin.// https://stripe.com/docs/building-plugins#setappinfoappInfo:{name:'Your App Name',version:'0.0.0',url:'https://github.com/vercel/nextjs-subscription-payments',},});
//client.tsimportStripefrom'stripe';exportconststripe=newStripe(process.env.STRIPE_SECRET_KEY_LIVE??process.env.STRIPE_SECRET_KEY??'',{// https://github.com/stripe/stripe-node#configuration// https://stripe.com/docs/api/versioning// @ts-ignoreapiVersion:null,// Register this as an official Stripe plugin.// https://stripe.com/docs/building-plugins#setappinfoappInfo:{name:'Next.js Subscription Starter',version:'0.0.0',url:'https://github.com/vercel/nextjs-subscription-payments'}});
//server.ts'use server';importStripefrom'stripe';import{stripe}from'@/utils/stripe/config';import{createClient}from'@/utils/supabase/server';import{createOrRetrieveCustomer}from'@/utils/supabase/admin';import{getURL,getErrorRedirect,calculateTrialEndUnixTimestamp}from'@/utils/helpers';import{Tables}from'@/types_db';typePrice=Tables<'prices'>;typeCheckoutResponse={errorRedirect?:string;sessionId?:string;};exportasyncfunctioncheckoutWithStripe(price:Price,redirectPath:string='/account'):Promise<CheckoutResponse>{try{// Get the user from Supabase authconstsupabase=createClient();const{error,data:{user}}=awaitsupabase.auth.getUser();if(error||!user){console.error(error);thrownewError('Could not get user session.');}// Retrieve or create the customer in Stripeletcustomer:string;try{customer=awaitcreateOrRetrieveCustomer({uuid:user?.id||'',email:user?.email||''});}catch(err){console.error(err);thrownewError('Unable to access customer record.');}letparams:Stripe.Checkout.SessionCreateParams={allow_promotion_codes:true,billing_address_collection:'required',customer,customer_update:{address:'auto'},line_items:[{price:price.id,quantity:1}],cancel_url:getURL(),success_url:getURL(redirectPath)};console.log('Trial end:',calculateTrialEndUnixTimestamp(price.trial_period_days));if(price.type==='recurring'){params={...params,mode:'subscription',subscription_data:{trial_end:calculateTrialEndUnixTimestamp(price.trial_period_days)}};}elseif(price.type==='one_time'){params={...params,mode:'payment'};}// Create a checkout session in Stripeletsession;try{session=awaitstripe.checkout.sessions.create(params);}catch(err){console.error(err);thrownewError('Unable to create checkout session.');}// Instead of returning a Response, just return the data or error.if(session){return{sessionId:session.id};}else{thrownewError('Unable to create checkout session.');}}catch(error){if(errorinstanceofError){return{errorRedirect:getErrorRedirect(redirectPath,error.message,'Please try again later or contact a system administrator.')};}else{return{errorRedirect:getErrorRedirect(redirectPath,'An unknown error occurred.','Please try again later or contact a system administrator.')};}}}exportasyncfunctioncreateStripePortal(currentPath:string){try{constsupabase=createClient();const{error,data:{user}}=awaitsupabase.auth.getUser();if(!user){if(error){console.error(error);}thrownewError('Could not get user session.');}letcustomer;try{customer=awaitcreateOrRetrieveCustomer({uuid:user.id||'',email:user.email||''});}catch(err){console.error(err);thrownewError('Unable to access customer record.');}if(!customer){thrownewError('Could not get customer.');}try{const{url}=awaitstripe.billingPortal.sessions.create({customer,return_url:getURL('/account')});if(!url){thrownewError('Could not create billing portal');}returnurl;}catch(err){console.error(err);thrownewError('Could not create billing portal');}}catch(error){if(errorinstanceofError){console.error(error);returngetErrorRedirect(currentPath,error.message,'Please try again later or contact a system administrator.');}else{returngetErrorRedirect(currentPath,'An unknown error occurred.','Please try again later or contact a system administrator.');}}}
//helpers.tsimporttype{Tables}from'@/types_db';typePrice=Tables<'prices'>;exportconstgetURL=(path:string='')=>{// Check if NEXT_PUBLIC_SITE_URL is set and non-empty. Set this to your site URL in production env.leturl=process?.env?.NEXT_PUBLIC_SITE_URL&&process.env.NEXT_PUBLIC_SITE_URL.trim()!==''?process.env.NEXT_PUBLIC_SITE_URL:// If not set, check for NEXT_PUBLIC_VERCEL_URL, which is automatically set by Vercel.process?.env?.NEXT_PUBLIC_VERCEL_URL&&process.env.NEXT_PUBLIC_VERCEL_URL.trim()!==''?process.env.NEXT_PUBLIC_VERCEL_URL:// If neither is set, default to localhost for local development.'http://localhost:3000/';// Trim the URL and remove trailing slash if exists.url=url.replace(/\/+$/,'');// Make sure to include `https://` when not localhost.url=url.includes('http')?url:`https://${url}`;// Ensure path starts without a slash to avoid double slashes in the final URL.path=path.replace(/^\/+/,'');// Concatenate the URL and the path.returnpath?`${url}/${path}`:url;};exportconstpostData=async({url,data}:{url:string;data?:{price:Price};})=>{constres=awaitfetch(url,{method:'POST',headers:newHeaders({'Content-Type':'application/json'}),credentials:'same-origin',body:JSON.stringify(data)});returnres.json();};exportconsttoDateTime=(secs:number)=>{vart=newDate(+0);// Unix epoch start.t.setSeconds(secs);returnt;};exportconstcalculateTrialEndUnixTimestamp=(trialPeriodDays:number|null|undefined)=>{// Check if trialPeriodDays is null, undefined, or less than 2 daysif(trialPeriodDays===null||trialPeriodDays===undefined||trialPeriodDays<2){returnundefined;}constcurrentDate=newDate();// Current date and timeconsttrialEnd=newDate(currentDate.getTime()+(trialPeriodDays+1)*24*60*60*1000);// Add trial daysreturnMath.floor(trialEnd.getTime()/1000);// Convert to Unix timestamp in seconds};consttoastKeyMap:{[key:string]:string[]}={status:['status','status_description'],error:['error','error_description']};constgetToastRedirect=(path:string,toastType:string,toastName:string,toastDescription:string='',disableButton:boolean=false,arbitraryParams:string=''):string=>{const[nameKey,descriptionKey]=toastKeyMap[toastType];letredirectPath=`${path}?${nameKey}=${encodeURIComponent(toastName)}`;if(toastDescription){redirectPath+=`&${descriptionKey}=${encodeURIComponent(toastDescription)}`;}if(disableButton){redirectPath+=`&disable_button=true`;}if(arbitraryParams){redirectPath+=`&${arbitraryParams}`;}returnredirectPath;};exportconstgetStatusRedirect=(path:string,statusName:string,statusDescription:string='',disableButton:boolean=false,arbitraryParams:string='')=>getToastRedirect(path,'status',statusName,statusDescription,disableButton,arbitraryParams);exportconstgetErrorRedirect=(path:string,errorName:string,errorDescription:string='',disableButton:boolean=false,arbitraryParams:string='')=>getToastRedirect(path,'error',errorName,errorDescription,disableButton,arbitraryParams);
Create asupabase
folder and add the following files:
//admin.tsimport{toDateTime}from'@/utils/helpers';import{stripe}from'@/utils/stripe/config';import{createClient}from'@supabase/supabase-js';importStripefrom'stripe';importtype{Database,Tables,TablesInsert}from'types_db';typeProduct=Tables<'products'>;typePrice=Tables<'prices'>;// Change to control trial period lengthconstTRIAL_PERIOD_DAYS=0;// Note: supabaseAdmin uses the SERVICE_ROLE_KEY which you must only use in a secure server-side context// as it has admin privileges and overwrites RLS policies!constsupabaseAdmin=createClient<Database>(process.env.NEXT_PUBLIC_SUPABASE_URL||'',process.env.SUPABASE_SERVICE_ROLE_KEY||'');constupsertProductRecord=async(product:Stripe.Product)=>{constproductData:Product={id:product.id,active:product.active,name:product.name,description:product.description??null,image:product.images?.[0]??null,metadata:product.metadata};const{error:upsertError}=awaitsupabaseAdmin.from('products').upsert([productData]);if(upsertError)thrownewError(`Product insert/update failed:${upsertError.message}`);console.log(`Product inserted/updated:${product.id}`);};constupsertPriceRecord=async(price:Stripe.Price,retryCount=0,maxRetries=3)=>{constpriceData:Price={id:price.id,product_id:typeofprice.product==='string'?price.product:'',active:price.active,currency:price.currency,type:price.type,unit_amount:price.unit_amount??null,interval:price.recurring?.interval??null,interval_count:price.recurring?.interval_count??null,trial_period_days:price.recurring?.trial_period_days??TRIAL_PERIOD_DAYS};const{error:upsertError}=awaitsupabaseAdmin.from('prices').upsert([priceData]);if(upsertError?.message.includes('foreign key constraint')){if(retryCount<maxRetries){console.log(`Retry attempt${retryCount+1} for price ID:${price.id}`);awaitnewPromise((resolve)=>setTimeout(resolve,2000));awaitupsertPriceRecord(price,retryCount+1,maxRetries);}else{thrownewError(`Price insert/update failed after${maxRetries} retries:${upsertError.message}`);}}elseif(upsertError){thrownewError(`Price insert/update failed:${upsertError.message}`);}else{console.log(`Price inserted/updated:${price.id}`);}};constdeleteProductRecord=async(product:Stripe.Product)=>{const{error:deletionError}=awaitsupabaseAdmin.from('products').delete().eq('id',product.id);if(deletionError)thrownewError(`Product deletion failed:${deletionError.message}`);console.log(`Product deleted:${product.id}`);};constdeletePriceRecord=async(price:Stripe.Price)=>{const{error:deletionError}=awaitsupabaseAdmin.from('prices').delete().eq('id',price.id);if(deletionError)thrownewError(`Price deletion failed:${deletionError.message}`);console.log(`Price deleted:${price.id}`);};constupsertCustomerToSupabase=async(uuid:string,customerId:string)=>{const{error:upsertError}=awaitsupabaseAdmin.from('customers').upsert([{id:uuid,stripe_customer_id:customerId}]);if(upsertError)thrownewError(`Supabase customer record creation failed:${upsertError.message}`);returncustomerId;};constcreateCustomerInStripe=async(uuid:string,email:string)=>{constcustomerData={metadata:{supabaseUUID:uuid},email:email};constnewCustomer=awaitstripe.customers.create(customerData);if(!newCustomer)thrownewError('Stripe customer creation failed.');returnnewCustomer.id;};constcreateOrRetrieveCustomer=async({email,uuid}:{email:string;uuid:string;})=>{// Check if the customer already exists in Supabaseconst{data:existingSupabaseCustomer,error:queryError}=awaitsupabaseAdmin.from('customers').select('*').eq('id',uuid).maybeSingle();if(queryError){thrownewError(`Supabase customer lookup failed:${queryError.message}`);}// Retrieve the Stripe customer ID using the Supabase customer ID, with email fallbackletstripeCustomerId:string|undefined;if(existingSupabaseCustomer?.stripe_customer_id){constexistingStripeCustomer=awaitstripe.customers.retrieve(existingSupabaseCustomer.stripe_customer_id);stripeCustomerId=existingStripeCustomer.id;}else{// If Stripe ID is missing from Supabase, try to retrieve Stripe customer ID by emailconststripeCustomers=awaitstripe.customers.list({email:email});stripeCustomerId=stripeCustomers.data.length>0?stripeCustomers.data[0].id:undefined;}// If still no stripeCustomerId, create a new customer in StripeconststripeIdToInsert=stripeCustomerId?stripeCustomerId:awaitcreateCustomerInStripe(uuid,email);if(!stripeIdToInsert)thrownewError('Stripe customer creation failed.');if(existingSupabaseCustomer&&stripeCustomerId){// If Supabase has a record but doesn't match Stripe, update Supabase recordif(existingSupabaseCustomer.stripe_customer_id!==stripeCustomerId){const{error:updateError}=awaitsupabaseAdmin.from('customers').update({stripe_customer_id:stripeCustomerId}).eq('id',uuid);if(updateError)thrownewError(`Supabase customer record update failed:${updateError.message}`);console.warn(`Supabase customer record mismatched Stripe ID. Supabase record updated.`);}// If Supabase has a record and matches Stripe, return Stripe customer IDreturnstripeCustomerId;}else{console.warn(`Supabase customer record was missing. A new record was created.`);// If Supabase has no record, create a new record and return Stripe customer IDconstupsertedStripeCustomer=awaitupsertCustomerToSupabase(uuid,stripeIdToInsert);if(!upsertedStripeCustomer)thrownewError('Supabase customer record creation failed.');returnupsertedStripeCustomer;}};/** * Copies the billing details from the payment method to the customer object. */constcopyBillingDetailsToCustomer=async(uuid:string,payment_method:Stripe.PaymentMethod)=>{//Todo: check this assertionconstcustomer=payment_method.customerasstring;const{name,phone,address}=payment_method.billing_details;if(!name||!phone||!address)return;//@ts-ignoreawaitstripe.customers.update(customer,{name,phone,address});const{error:updateError}=awaitsupabaseAdmin.from('users').update({billing_address:{...address},payment_method:{...payment_method[payment_method.type]}}).eq('id',uuid);if(updateError)thrownewError(`Customer update failed:${updateError.message}`);};constmanageSubscriptionStatusChange=async(subscriptionId:string,customerId:string,createAction=false)=>{// Get customer's UUID from mapping table.const{data:customerData,error:noCustomerError}=awaitsupabaseAdmin.from('customers').select('id').eq('stripe_customer_id',customerId).single();if(noCustomerError)thrownewError(`Customer lookup failed:${noCustomerError.message}`);const{id:uuid}=customerData!;constsubscription=awaitstripe.subscriptions.retrieve(subscriptionId,{expand:['default_payment_method']});// Upsert the latest status of the subscription object.constsubscriptionData:TablesInsert<'subscriptions'>={id:subscription.id,user_id:uuid,metadata:subscription.metadata,status:subscription.status,price_id:subscription.items.data[0].price.id,//TODO check quantity on subscription// @ts-ignorequantity:subscription.quantity,cancel_at_period_end:subscription.cancel_at_period_end,cancel_at:subscription.cancel_at?toDateTime(subscription.cancel_at).toISOString():null,canceled_at:subscription.canceled_at?toDateTime(subscription.canceled_at).toISOString():null,current_period_start:toDateTime(subscription.current_period_start).toISOString(),current_period_end:toDateTime(subscription.current_period_end).toISOString(),created:toDateTime(subscription.created).toISOString(),ended_at:subscription.ended_at?toDateTime(subscription.ended_at).toISOString():null,trial_start:subscription.trial_start?toDateTime(subscription.trial_start).toISOString():null,trial_end:subscription.trial_end?toDateTime(subscription.trial_end).toISOString():null};const{error:upsertError}=awaitsupabaseAdmin.from('subscriptions').upsert([subscriptionData]);if(upsertError)thrownewError(`Subscription insert/update failed:${upsertError.message}`);console.log(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);// For a new subscription copy the billing details to the customer object.// NOTE: This is a costly operation and should happen at the very end.if(createAction&&subscription.default_payment_method&&uuid)//@ts-ignoreawaitcopyBillingDetailsToCustomer(uuid,subscription.default_payment_methodasStripe.PaymentMethod);};export{upsertProductRecord,upsertPriceRecord,deleteProductRecord,deletePriceRecord,createOrRetrieveCustomer,manageSubscriptionStatusChange};
//queries.tsimport{SupabaseClient}from'@supabase/supabase-js';import{cache}from'react';exportconstgetUser=cache(async(supabase:SupabaseClient)=>{const{data:{user}}=awaitsupabase.auth.getUser();returnuser;});exportconstgetSubscription=cache(async(supabase:SupabaseClient)=>{const{data:subscription,error}=awaitsupabase.from('subscriptions').select('*, prices(*, products(*))').in('status',['trialing','active']).maybeSingle();returnsubscription;});exportconstgetProducts=cache(async(supabase:SupabaseClient)=>{const{data:products,error}=awaitsupabase.from('products').select('*, prices(*)').eq('active',true).eq('prices.active',true).order('metadata->index').order('unit_amount',{referencedTable:'prices'});returnproducts;});exportconstgetUserDetails=cache(async(supabase:SupabaseClient)=>{const{data:userDetails}=awaitsupabase.from('users').select('*').single();returnuserDetails;});
5. Add Webhooks to Sync Your Database with Stripe
We haven't created our Stripe account yet, but be patient. Once we add our webhook and start listening to our app, our database will be synced with Stripe. All the products and prices added to our Stripe project will be reflected in our database.
This webhook ensures that Stripe can ping our app, and our app handles the necessary database synchronization. The code might seem intimidating at first, but all it does is listen for events and take action accordingly. For instance, when a product is created, we update our product table with the new product details.
Create aroute.ts
file underapi/webhook
or your preferred path. We will obtain theSTRIPE_WEBHOOK_SECRET
from Stripe in the next step.
// route.tsimportStripefrom'stripe';import{stripe}from'@/utils/stripe/config';import{upsertProductRecord,upsertPriceRecord,manageSubscriptionStatusChange,deleteProductRecord,deletePriceRecord}from'@/utils/supabase/admin';constrelevantEvents=newSet(['product.created','product.updated','product.deleted','price.created','price.updated','price.deleted','checkout.session.completed','customer.subscription.created','customer.subscription.updated','customer.subscription.deleted']);exportasyncfunctionPOST(req:Request){constbody=awaitreq.text();constsig=req.headers.get('stripe-signature')asstring;constwebhookSecret=process.env.STRIPE_WEBHOOK_SECRET;letevent:Stripe.Event;try{if(!sig||!webhookSecret)returnnewResponse('Webhook secret not found.',{status:400});event=stripe.webhooks.constructEvent(body,sig,webhookSecret);console.log(`🔔 Webhook received:${event.type}`);}catch(err:any){console.log(`❌ Error message:${err.message}`);returnnewResponse(`Webhook Error:${err.message}`,{status:400});}if(relevantEvents.has(event.type)){try{switch(event.type){case'product.created':case'product.updated':awaitupsertProductRecord(event.data.objectasStripe.Product);break;case'price.created':case'price.updated':awaitupsertPriceRecord(event.data.objectasStripe.Price);break;case'price.deleted':awaitdeletePriceRecord(event.data.objectasStripe.Price);break;case'product.deleted':awaitdeleteProductRecord(event.data.objectasStripe.Product);break;case'customer.subscription.created':case'customer.subscription.updated':case'customer.subscription.deleted':constsubscription=event.data.objectasStripe.Subscription;awaitmanageSubscriptionStatusChange(subscription.id,subscription.customerasstring,event.type==='customer.subscription.created');break;case'checkout.session.completed':constcheckoutSession=event.data.objectasStripe.Checkout.Session;if(checkoutSession.mode==='subscription'){constsubscriptionId=checkoutSession.subscription;awaitmanageSubscriptionStatusChange(subscriptionIdasstring,checkoutSession.customerasstring,true);}break;default:thrownewError('Unhandled relevant event!');}}catch(error){console.log(error);returnnewResponse('Webhook handler failed. View your Next.js function logs.',{status:400});}}else{returnnewResponse(`Unsupported event type:${event.type}`,{status:400});}returnnewResponse(JSON.stringify({received:true}));}
6. Create Your Stripe Account and Connect the Webhook
Next, we'll configureStripe to handle test payments. If you don’t have a Stripe account, create one now.
Once you're in the dashboard, ensure the"Test Mode" toggle is activated.
Go to the developers section and copy the Publishable Key and Secret Key into your.env
variables. We will also obtain the Webhook Secret soon.
# StripeNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=<>STRIPE_SECRET_KEY=<>STRIPE_WEBHOOK_SECRET=<>
- Download the Stripe CLI here:https://docs.stripe.com/stripe-cli
- Navigate to the Stripe Developers tab and Webhooks. Open the "Test in local environment" tab.
- Run
stripe login
in your terminal. - Copy the
endpointSecret
from the sample endpoint code and paste it into your.env
variables asSTRIPE_WEBHOOK_SECRET
.
- Run
- Run your app locally.
Start listening for events (adjust the link if necessary):
stripe listen--forward-to localhost:3000/api/webhook
Test the webhook by running a sample trigger:
stripe trigger payment_intent.succeeded
You should see output similar to the following in your console, indicating the webhook listener is working. Don’t worry about the 400 status code.
Stripe Console:--> charge.succeeded[]--> payment_intent.succeeded[]--> payment_intent.created[]<--[400] POST http://localhost:3000/api/webhooks[]
7. Create Product and Price
Now that we have set up our webhook, we're ready to create our Stripe product and price.Ensure the webhook and development server are still running!
- Go to the Stripe Dashboard.
- Navigate to "Product Catalogue."
- Click "Add Product."
- Create a sample product for testing. In this example, I created a Recurring Monthly product.
Once the product is created, you should see webhook events being received by our server in the console. Check the Supabase tables to confirm that the new product and price have been added.
Good news! The backend is complete, and now we will move on to the front-end setup! 🚀🎉
8. Add Front-end to Test
I understand each app has its specific design style, so I’ll share example front-end code for testing. You can customize it to suit your needs.
First, we need a pricing page to view product options:
// page.tsx under the desired route, example: /pricingimportPricingfrom'@/components/ui/Pricing/Pricing';import{createClient}from'@/utils/supabase/server';import{getProducts,getSubscription,getUser}from'@/utils/supabase/queries';exportdefaultasyncfunctionPricingPage(){constsupabase=createClient();const[user,products,subscription]=awaitPromise.all([getUser(supabase),getProducts(supabase),getSubscription(supabase)]);return(<Pricinguser={user}products={products??[]}subscription={subscription}/>);}
You can use the example Pricing component here, but all we need is to map products and display them with a clickable button that triggers the checkout session:Example Pricing Component
// Sample Pricing.tsx'use client';importButtonfrom'@/components/ui/Button';importtype{Tables}from'@/types_db';import{getStripe}from'@/utils/stripe/client';import{checkoutWithStripe}from'@/utils/stripe/server';import{getErrorRedirect}from'@/utils/helpers';import{User}from'@supabase/supabase-js';importcnfrom'classnames';import{useRouter,usePathname}from'next/navigation';import{useState}from'react';typeSubscription=Tables<'subscriptions'>;typeProduct=Tables<'products'>;typePrice=Tables<'prices'>;interfaceProductWithPricesextendsProduct{prices:Price[];}interfacePriceWithProductextendsPrice{products:Product|null;}interfaceSubscriptionWithProductextendsSubscription{prices:PriceWithProduct|null;}interfaceProps{user:User|null|undefined;products:ProductWithPrices[];subscription:SubscriptionWithProduct|null;}exportdefaultfunctionPricing({user,products,subscription}:Props){constrouter=useRouter();constcurrentPath=usePathname();consthandleStripeCheckout=async(price:Price)=>{if(subscription){returnrouter.push('/account');}if(!user){returnrouter.push('/signin/signup');}const{errorRedirect,sessionId}=awaitcheckoutWithStripe(price,currentPath);if(errorRedirect){returnrouter.push(errorRedirect);}if(!sessionId){returnrouter.push(getErrorRedirect(currentPath,'An unknown error occurred.','Please try again later or contact a system administrator.'));}conststripe=awaitgetStripe();stripe?.redirectToCheckout({sessionId});};if(!products.length){return(<sectionclassName="bg-black"><divclassName="max-w-6xl px-4 py-8 mx-auto sm:py-24 sm:px-6 lg:px-8"><divclassName="sm:flex sm:flex-col sm:align-center"></div><pclassName="text-4xl font-extrabold text-white sm:text-center sm:text-6xl"> No subscription pricing plans found. Create them in your{''}<aclassName="text-pink-500 underline"href="https://dashboard.stripe.com/products"rel="noopener noreferrer"target="_blank"> Stripe Dashboard</a> .</p></div></section>);}else{return(<sectionclassName="bg-black"><divclassName="mt-12 space-y-0 sm:mt-16 flex flex-wrap justify-center gap-6 lg:max-w-4xl lg:mx-auto xl:max-w-none xl:mx-0">{products.map((product)=>{constprice=product?.prices[0]);if(!price)returnnull;constpriceString=newIntl.NumberFormat('en-US',{style:'currency',currency:price.currency!,minimumFractionDigits:0}).format((price?.unit_amount||0)/100);return(<divkey={product.id}className={cn('flex flex-col rounded-lg shadow-sm divide-y divide-zinc-600 bg-zinc-900',{'border border-pink-500':subscription?product.name===subscription?.prices?.products?.name:product.name==='Freelancer'},'flex-1',// This makes the flex item grow to fill the space'basis-1/3',// Assuming you want each card to take up roughly a third of the container's width'max-w-xs'// Sets a maximum width to the cards to prevent them from getting too large)}><divclassName="p-6"><h2className="text-2xl font-semibold leading-6 text-white">{product.name}</h2><pclassName="mt-4 text-zinc-300">{product.description}</p><pclassName="mt-8"><spanclassName="text-5xl font-extrabold white">{priceString}</span><spanclassName="text-base font-medium text-zinc-100"> /{billingInterval}</span></p><buttononClick={()=>handleStripeCheckout(price)}className="block w-full py-2 mt-8 text-sm font-semibold text-center text-white rounded-md hover:bg-zinc-900">{subscription?'Manage':'Subscribe'}</Button></div></div>);})}</div></section>);}}
Finally, we will add the/account
page:
// page.tsximportCustomerPortalFormfrom'@/components/ui/AccountForms/CustomerPortalForm';import{redirect}from'next/navigation';import{createClient}from'@/utils/supabase/server';import{getSubscription,getUser}from'@/utils/supabase/queries';exportdefaultasyncfunctionAccount(){constsupabase=createClient();const[user,subscription]=awaitPromise.all([getUser(supabase),getSubscription(supabase)]);if(!user){returnredirect('/signin');}return(<sectionclassName="mb-32 bg-black"><divclassName="max-w-6xl px-4 py-8 mx-auto sm:px-6 sm:pt-24 lg:px-8"><divclassName="sm:align-center sm:flex sm:flex-col"><h1className="text-4xl font-extrabold text-white sm:text-center sm:text-6xl"> Account</h1><pclassName="max-w-2xl m-auto mt-5 text-xl text-zinc-200 sm:text-center sm:text-2xl"> We partnered with Stripe for a simplified billing.</p></div></div><divclassName="p-4"><CustomerPortalFormsubscription={subscription}/></div></section>);}
// CustomerPortalForm.tsx'use client';import{useRouter,usePathname}from'next/navigation';import{useState}from'react';import{createStripePortal}from'@/utils/stripe/server';importLinkfrom'next/link';import{Tables}from'@/types_db';typeSubscription=Tables<'subscriptions'>;typePrice=Tables<'prices'>;typeProduct=Tables<'products'>;typeSubscriptionWithPriceAndProduct=Subscription&{prices:|(Price&{products:Product|null;})|null;};interfaceProps{subscription:SubscriptionWithPriceAndProduct|null;}exportdefaultfunctionCustomerPortalForm({subscription}:Props){constrouter=useRouter();constcurrentPath=usePathname();const[isSubmitting,setIsSubmitting]=useState(false);constsubscriptionPrice=subscription&&newIntl.NumberFormat('en-US',{style:'currency',currency:subscription?.prices?.currency!,minimumFractionDigits:0}).format((subscription?.prices?.unit_amount||0)/100);consthandleStripePortalRequest=async()=>{setIsSubmitting(true);constredirectUrl=awaitcreateStripePortal(currentPath);setIsSubmitting(false);returnrouter.push(redirectUrl);};return(<divclassName="p m-auto my-8 w-full max-w-3xl rounded-md border border-zinc-700"><divclassName="px-5 py-4"><h3className="mb-1 text-2xl font-medium">Your Plan</h3><pclassName="text-zinc-300">{subscription?`You are currently on the${subscription?.prices?.products?.name} plan.`:'You are not currently subscribed to any plan.'}</p><divclassName="mb-4 mt-8 text-xl font-semibold">{subscription?(`${subscriptionPrice}/${subscription?.prices?.interval}`):(<Linkhref="/dev/stripe-example-page">Choose your plan</Link>)}</div></div><divclassName="rounded-b-md border-t border-zinc-700 bg-zinc-900 p-4 text-zinc-500"><divclassName="flex flex-col items-start justify-between sm:flex-row sm:items-center"><pclassName="pb-4 sm:pb-0">Manage your subscription on Stripe.</p><buttononClick={handleStripePortalRequest}disabled={isSubmitting}> Open customer portal</Button></div></div></div>);}
🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉
Congratulations, you've made it! 🚀🚀🚀🚀🚀
You can now go to the/pricing
page and purchase your plan using the dummy card below.
Your tables will be updated automatically,make sure the webhook event listener is still running!
Dummy Card Information:Card Number: 4242 4242 4242 4242Expiry Date: Any future dateCVC: Any 3-digit number
Let me know what you think and comment if there you have any suggestions to improve this as I am still working on improving this for our app,www.mindtrajour.com.
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse