DEV Community

Cover image for #33 Stripe Integration Guide for Next.js 15 with Supabase
Florian Zeba
Florian Zeba

Posted on

#33 Stripe Integration Guide for Next.js 15 with Supabase

This guide provides a step-by-step process to integrate Stripe payments into your Next.js 15 application with Supabase authentication.

Prerequisites

Before starting, ensure you have:

  • A Next.js 15 application set up
  • Supabase integration for authentication and storage
  • Node.js v18.17.0 or later
  • npm or yarn package manager

Setting Up Stripe Account

  1. Create a Stripe account at stripe.com
  2. Navigate to the Stripe Dashboard
  3. Get your API keys from Developers > API keys
  4. Note both your Publishable Key and Secret Key
  5. Enable test mode for development

Installing Required Packages

Install the necessary packages:

npm install stripe @stripe/stripe-js @stripe/react-stripe-js # or yarn add stripe @stripe/stripe-js @stripe/react-stripe-js 
Enter fullscreen mode Exit fullscreen mode

Environment Configuration

Create or update your .env.local file with Stripe configuration:

# Stripe API Keys NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key STRIPE_SECRET_KEY=sk_test_your_secret_key # Stripe Webhook Secret (you'll get this later) STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret # Your domain for Stripe redirects NEXT_PUBLIC_SITE_URL=http://localhost:3000 
Enter fullscreen mode Exit fullscreen mode

Stripe Client Integration

1. Create a Stripe context provider

Create a file at lib/stripe/stripe-client.js:

import { loadStripe } from '@stripe/stripe-js'; let stripePromise; export const getStripe = () => { if (!stripePromise) { stripePromise = loadStripe( process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ); } return stripePromise; }; 
Enter fullscreen mode Exit fullscreen mode

2. Create a Stripe Elements provider component

Create a file at components/StripeElementsProvider.jsx:

'use client'; import { Elements } from '@stripe/react-stripe-js'; import { getStripe } from '@/lib/stripe/stripe-client'; export default function StripeElementsProvider({ children, options }) { const stripePromise = getStripe(); return ( <Elements stripe={stripePromise} options={options}> {children} </Elements> ); } 
Enter fullscreen mode Exit fullscreen mode

Stripe API Routes

1. Set up Stripe server-side instance

Create a file at lib/stripe/stripe-server.js:

import Stripe from 'stripe'; let stripe; export const getStripe = () => { if (!stripe) { stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2023-10-16', // Use the latest API version }); } return stripe; }; 
Enter fullscreen mode Exit fullscreen mode

2. Create API route for creating payment intents

Create a file at app/api/stripe/payment-intents/route.js:

import { NextResponse } from 'next/server'; import { getStripe } from '@/lib/stripe/stripe-server'; import { createClient } from '@supabase/supabase-js'; export async function POST(request) { try { const { amount, currency = 'usd', paymentMethodType = 'card', metadata = {}, } = await request.json(); // Validate amount if (!amount || isNaN(amount) || amount <= 0) { return NextResponse.json( { error: 'Invalid amount' }, { status: 400 } ); } // Initialize Supabase client const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const supabase = createClient(supabaseUrl, supabaseServiceKey); // Get user from cookie (this assumes you're using Supabase Auth) const cookieStore = request.cookies; const supabaseAuthToken = cookieStore.get('sb-access-token')?.value; if (!supabaseAuthToken) { return NextResponse.json( { error: 'User not authenticated' }, { status: 401 } ); } // Get user from Supabase const { data: { user }, error, } = await supabase.auth.getUser(supabaseAuthToken); if (error || !user) { return NextResponse.json( { error: 'User not found' }, { status: 401 } ); } // Add user ID to metadata const enhancedMetadata = { ...metadata, userId: user.id, }; // Create a PaymentIntent with the order amount and currency const stripe = getStripe(); const paymentIntent = await stripe.paymentIntents.create({ amount: Math.round(amount * 100), // Stripe expects amount in cents currency, payment_method_types: [paymentMethodType], metadata: enhancedMetadata, }); return NextResponse.json({ clientSecret: paymentIntent.client_secret, }); } catch (error) { console.error('Error creating payment intent:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } 
Enter fullscreen mode Exit fullscreen mode

3. Create API route for creating checkout sessions

Create a file at app/api/stripe/checkout-sessions/route.js:

import { NextResponse } from 'next/server'; import { getStripe } from '@/lib/stripe/stripe-server'; import { createClient } from '@supabase/supabase-js'; export async function POST(request) { try { const { priceId, mode = 'payment', successUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/success`, cancelUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/cancel`, metadata = {}, } = await request.json(); if (!priceId) { return NextResponse.json( { error: 'Price ID is required' }, { status: 400 } ); } // Initialize Supabase client const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const supabase = createClient(supabaseUrl, supabaseServiceKey); // Get user from cookie const cookieStore = request.cookies; const supabaseAuthToken = cookieStore.get('sb-access-token')?.value; if (!supabaseAuthToken) { return NextResponse.json( { error: 'User not authenticated' }, { status: 401 } ); } // Get user from Supabase const { data: { user }, error, } = await supabase.auth.getUser(supabaseAuthToken); if (error || !user) { return NextResponse.json( { error: 'User not found' }, { status: 401 } ); } // Add user ID to metadata const enhancedMetadata = { ...metadata, userId: user.id, }; // Create Checkout Session const stripe = getStripe(); const session = await stripe.checkout.sessions.create({ mode, payment_method_types: ['card'], line_items: [ { price: priceId, quantity: 1, }, ], success_url: `${successUrl}?session_id={CHECKOUT_SESSION_ID}`, cancel_url: cancelUrl, metadata: enhancedMetadata, customer_email: user.email, // Pre-fill customer email }); return NextResponse.json({ sessionId: session.id, url: session.url, }); } catch (error) { console.error('Error creating checkout session:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } 
Enter fullscreen mode Exit fullscreen mode

4. Create API route for subscriptions

Create a file at app/api/stripe/subscriptions/route.js:

import { NextResponse } from 'next/server'; import { getStripe } from '@/lib/stripe/stripe-server'; import { createClient } from '@supabase/supabase-js'; export async function POST(request) { try { const { priceId, successUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/subscription/success`, cancelUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/subscription/cancel`, metadata = {}, } = await request.json(); if (!priceId) { return NextResponse.json( { error: 'Price ID is required' }, { status: 400 } ); } // Initialize Supabase client const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const supabase = createClient(supabaseUrl, supabaseServiceKey); // Get user from cookie const cookieStore = request.cookies; const supabaseAuthToken = cookieStore.get('sb-access-token')?.value; if (!supabaseAuthToken) { return NextResponse.json( { error: 'User not authenticated' }, { status: 401 } ); } // Get user from Supabase const { data: { user }, error, } = await supabase.auth.getUser(supabaseAuthToken); if (error || !user) { return NextResponse.json( { error: 'User not found' }, { status: 401 } ); } // Check if user already has a Stripe customer ID const { data: customerData } = await supabase .from('customers') .select('stripe_customer_id') .eq('user_id', user.id) .single(); const stripe = getStripe(); let customerId; // If no customer ID exists, create one if (!customerData?.stripe_customer_id) { const customer = await stripe.customers.create({ email: user.email, metadata: { userId: user.id, }, }); customerId = customer.id; // Save Stripe customer ID to Supabase await supabase.from('customers').insert({ user_id: user.id, stripe_customer_id: customerId, }); } else { customerId = customerData.stripe_customer_id; } // Add user ID to metadata const enhancedMetadata = { ...metadata, userId: user.id, }; // Create subscription checkout session const session = await stripe.checkout.sessions.create({ customer: customerId, mode: 'subscription', payment_method_types: ['card'], line_items: [ { price: priceId, quantity: 1, }, ], success_url: `${successUrl}?session_id={CHECKOUT_SESSION_ID}`, cancel_url: cancelUrl, metadata: enhancedMetadata, }); return NextResponse.json({ sessionId: session.id, url: session.url, }); } catch (error) { console.error('Error creating subscription:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } // GET route to fetch user subscriptions export async function GET(request) { try { // Initialize Supabase client const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const supabase = createClient(supabaseUrl, supabaseServiceKey); // Get user from cookie const cookieStore = request.cookies; const supabaseAuthToken = cookieStore.get('sb-access-token')?.value; if (!supabaseAuthToken) { return NextResponse.json( { error: 'User not authenticated' }, { status: 401 } ); } // Get user from Supabase const { data: { user }, error, } = await supabase.auth.getUser(supabaseAuthToken); if (error || !user) { return NextResponse.json( { error: 'User not found' }, { status: 401 } ); } // Get customer ID from Supabase const { data: customerData } = await supabase .from('customers') .select('stripe_customer_id') .eq('user_id', user.id) .single(); if (!customerData?.stripe_customer_id) { return NextResponse.json({ subscriptions: [], }); } // Get subscriptions from Stripe const stripe = getStripe(); const subscriptions = await stripe.subscriptions.list({ customer: customerData.stripe_customer_id, status: 'active', expand: [ 'data.default_payment_method', 'data.items.data.price.product', ], }); return NextResponse.json({ subscriptions: subscriptions.data, }); } catch (error) { console.error('Error fetching subscriptions:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } 
Enter fullscreen mode Exit fullscreen mode

Payment Flows

One-time Payments

1. Create a payment component using Elements

Create a file at components/CheckoutForm.jsx:

'use client'; import { useState } from 'react'; import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; import StripeElementsProvider from './StripeElementsProvider'; const CheckoutFormInner = ({ amount, onSuccess, onError }) => { const stripe = useStripe(); const elements = useElements(); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const handleSubmit = async (e) => { e.preventDefault(); if (!stripe || !elements) { return; } setIsLoading(true); setErrorMessage(null); try { // Create a payment intent on the server const response = await fetch('/api/stripe/payment-intents', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ amount, }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Something went wrong'); } // Confirm the payment on the client const { error, paymentIntent } = await stripe.confirmCardPayment( data.clientSecret, { payment_method: { card: elements.getElement(CardElement), }, } ); if (error) { throw new Error(error.message); } if (paymentIntent.status === 'succeeded') { if (onSuccess) { onSuccess(paymentIntent); } } } catch (error) { setErrorMessage(error.message); if (onError) { onError(error); } } finally { setIsLoading(false); } }; return ( <form onSubmit={handleSubmit} className='space-y-4'> <div className='p-4 border rounded-md'> <CardElement options={{ style: { base: { fontSize: '16px', color: '#424770', '::placeholder': { color: '#aab7c4', }, }, invalid: { color: '#9e2146', }, }, }} /> </div> {errorMessage && ( <div className='text-red-500 text-sm'>{errorMessage}</div> )} <button type='submit' disabled={!stripe || isLoading} className='px-4 py-2 bg-blue-600 text-white rounded-md disabled:opacity-50' > {isLoading ? 'Processing...' : `Pay $${amount.toFixed(2)}`} </button> </form> ); }; export default function CheckoutForm({ amount, onSuccess, onError }) { return ( <StripeElementsProvider> <CheckoutFormInner amount={amount} onSuccess={onSuccess} onError={onError} /> </StripeElementsProvider> ); } 
Enter fullscreen mode Exit fullscreen mode

2. Create a checkout page

Create a file at app/checkout/page.jsx:

'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import CheckoutForm from '@/components/CheckoutForm'; export default function CheckoutPage() { const router = useRouter(); const [isSuccess, setIsSuccess] = useState(false); // Sample product const product = { name: 'Sample Product', price: 19.99, description: 'This is a sample product for testing Stripe integration', }; const handleSuccess = (paymentIntent) => { setIsSuccess(true); // Navigate to success page after a short delay setTimeout(() => { router.push(`/success?payment_intent=${paymentIntent.id}`); }, 1500); }; const handleError = (error) => { console.error('Payment error:', error); }; return ( <div className='max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md'> <h1 className='text-2xl font-bold mb-4'>Checkout</h1> {isSuccess ? ( <div className='text-green-600 font-semibold mb-4'> Payment successful! Redirecting... </div> ) : ( <> <div className='mb-6'> <h2 className='text-xl font-semibold'> {product.name} </h2> <p className='text-gray-600'>{product.description}</p> <div className='text-xl font-bold mt-2'> ${product.price.toFixed(2)} </div> </div> <CheckoutForm amount={product.price} onSuccess={handleSuccess} onError={handleError} /> </> )} </div> ); } 
Enter fullscreen mode Exit fullscreen mode

3. Create a success page

Create a file at app/success/page.jsx:

'use client'; import { useEffect, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; export default function SuccessPage() { const searchParams = useSearchParams(); const sessionId = searchParams.get('session_id'); const paymentIntentId = searchParams.get('payment_intent'); const [paymentDetails, setPaymentDetails] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const getPaymentDetails = async () => { try { if (sessionId) { // If we have a checkout session ID, fetch session details const response = await fetch( `/api/stripe/checkout-sessions/${sessionId}` ); if (response.ok) { const data = await response.json(); setPaymentDetails(data.session); } } else if (paymentIntentId) { // If we have a payment intent ID, fetch payment intent details const response = await fetch( `/api/stripe/payment-intents/${paymentIntentId}` ); if (response.ok) { const data = await response.json(); setPaymentDetails(data.paymentIntent); } } } catch (error) { console.error('Error fetching payment details:', error); } finally { setLoading(false); } }; getPaymentDetails(); }, [sessionId, paymentIntentId]); return ( <div className='max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md'> <div className='text-center mb-6'> <div className='inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4'> <svg xmlns='http://www.w3.org/2000/svg' className='h-8 w-8 text-green-600' fill='none' viewBox='0 0 24 24' stroke='currentColor' > <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' /> </svg> </div> <h1 className='text-2xl font-bold text-green-600'> Payment Successful! </h1> <p className='text-gray-600 mt-2'> Thank you for your purchase. Your payment has been processed successfully. </p> </div> {loading ? ( <p className='text-center text-gray-500'> Loading payment details... </p> ) : paymentDetails ? ( <div className='border-t border-gray-200 pt-4'> <h2 className='text-lg font-semibold mb-2'> Payment Details </h2> <p className='text-gray-700'> Amount: ${(paymentDetails.amount / 100).toFixed(2)} </p> <p className='text-gray-700'> Date:{' '} {new Date( paymentDetails.created * 1000 ).toLocaleDateString()} </p> <p className='text-gray-700'> Payment ID: {paymentDetails.id} </p> </div> ) : null} <div className='mt-6 text-center'> <Link href='/' className='text-blue-600 hover:text-blue-800'> Return to Home </Link> </div> </div> ); } 
Enter fullscreen mode Exit fullscreen mode

Subscriptions

1. Create a subscription checkout button component

Create a file at components/SubscribeButton.jsx:

'use client'; import { useState } from 'react'; import { getStripe } from '@/lib/stripe/stripe-client'; export default function SubscribeButton({ priceId, buttonText = 'Subscribe' }) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const handleSubscribe = async () => { setIsLoading(true); setError(null); try { const response = await fetch('/api/stripe/subscriptions', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ priceId, }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Something went wrong'); } // Redirect to Stripe Checkout if (data.url) { window.location.href = data.url; } else { // If no URL is provided, redirect using the session ID const stripe = await getStripe(); const { error } = await stripe.redirectToCheckout({ sessionId: data.sessionId, }); if (error) throw error; } } catch (error) { setError(error.message); console.error('Error subscribing:', error); } finally { setIsLoading(false); } }; return ( <div> <button onClick={handleSubscribe} disabled={isLoading} className='px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50' > {isLoading ? 'Processing...' : buttonText} </button> {error && <div className='text-red-500 text-sm mt-2'>{error}</div>} </div> ); } 
Enter fullscreen mode Exit fullscreen mode

2. Create a pricing page with subscription options

Create a file at app/pricing/page.jsx:

'use client'; import { useState, useEffect } from 'react'; import SubscribeButton from '@/components/SubscribeButton'; import { useRouter } from 'next/navigation'; import { useSupabase } from '@/lib/supabase/client'; // Assuming you have this hook export default function PricingPage() { const router = useRouter(); const { supabase, user } = useSupabase(); const [subscription, setSubscription] = useState(null); const [loading, setLoading] = useState(true); // Pricing plans - in a real app, you would fetch these from Stripe const pricingPlans = [ { name: 'Basic', description: 'For individuals and small projects', price: '$9.99', interval: 'month', features: ['Feature 1', 'Feature 2', 'Feature 3'], priceId: 'price_1NxYzABCDEFGHIJK', // Your actual Stripe Price ID }, { name: 'Pro', description: 'For professionals and teams', price: '$19.99', interval: 'month', features: [ 'All Basic features', 'Feature 4', 'Feature 5', 'Feature 6', ], priceId: 'price_2OyZaBCDEFGHIJK', // Your actual Stripe Price ID popular: true, }, { name: 'Enterprise', description: 'For large organizations', price: '$49.99', interval: 'month', features: [ 'All Pro features', 'Feature 7', 'Feature 8', 'Feature 9', 'Priority support', ], priceId: 'price_3PzZbBCDEFGHIJK', // Your actual Stripe Price ID }, ]; useEffect(() => { const checkUser = async () => { if (!user) { router.push('/login?redirect=/pricing'); return; } try { // Fetch current subscription const response = await fetch('/api/stripe/subscriptions'); const data = await response.json(); if (response.ok && data.subscriptions.length > 0) { setSubscription(data.subscriptions[0]); } } catch (error) { console.error('Error fetching subscription:', error); } finally { setLoading(false); } }; checkUser(); }, [user, router]); // Helper function to check if user has the current plan const hasCurrentPlan = (priceId) => { if (!subscription) return false; return subscription.items.data.some( (item) => item.price.id === priceId ); }; // Handle subscription cancellation const handleCancelSubscription = async () => { if (!subscription) return; try { setLoading(true); const response = await fetch( `/api/stripe/subscriptions/${subscription.id}`, { method: 'DELETE', } ); if (response.ok) { setSubscription(null); alert('Subscription cancelled successfully'); } else { const data = await response.json(); throw new Error(data.error || 'Failed to cancel subscription'); } } catch (error) { console.error('Error cancelling subscription:', error); alert(error.message); } finally { setLoading(false); } }; if (!user) { return ( <div className='flex justify-center items-center h-64'> <p>Please login to view pricing...</p> </div> ); } if (loading) { return ( <div className='flex justify-center items-center h-64'> <p>Loading pricing options...</p> </div> ); } return ( <div className='max-w-6xl mx-auto py-12 px-4 sm:px-6 lg:px-8'> <div className='text-center mb-12'> <h1 className='text-3xl font-extrabold text-gray-900 sm:text-4xl'> Pricing Plans </h1> <p className='mt-4 text-xl text-gray-600'> Choose the perfect plan for your needs </p> </div> {subscription && ( <div className='mb-12 max-w-md mx-auto bg-green-50 rounded-lg p-6 border border-green-200'> <h2 className='text-xl font-semibold text-gray-900'> Your Current Subscription </h2> <p className='text-gray-600 mt-2'> You are currently subscribed to the{' '} {subscription.items.data[0].price.product.name} plan. </p> <p className='text-gray-600 mt-2'> Next billing date:{' '} {new Date( subscription.current_period_end * 1000 ).toLocaleDateString()} </p> <button onClick={handleCancelSubscription} className='mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700' > Cancel Subscription </button> </div> )} <div className='grid gap-6 lg:grid-cols-3 lg:gap-8'> {pricingPlans.map((plan) => ( <div key={plan.name} className={`bg-white rounded-lg shadow-lg divide-y divide-gray-200 ${ plan.popular ? 'border-2 border-blue-500 relative' : '' }`} > {plan.popular && ( <div className='absolute top-0 right-0 transform translate-x-2 -translate-y-2'> <span className='bg-blue-500 text-white text-xs font-semibold px-3 py-1 rounded-full'> Popular </span> </div> )} <div className='p-6'> <h2 className='text-xl font-semibold text-gray-900'> {plan.name} </h2> <p className='mt-2 text-gray-600'> {plan.description} </p> <p className='mt-4'> <span className='text-3xl font-extrabold text-gray-900'> {plan.price} </span> <span className='text-base font-medium text-gray-500'> /{plan.interval} </span> </p> </div> <div className='px-6 pt-6 pb-4'> <h3 className='text-sm font-semibold text-gray-900 uppercase tracking-wide'> What's included </h3> <ul className='mt-4 space-y-3'> {plan.features.map((feature) => ( <li key={feature} className='flex'> <svg className='h-5 w-5 text-green-500' fill='none' viewBox='0 0 24 24' stroke='currentColor' > <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' /> </svg> <span className='ml-3 text-gray-700'> {feature} </span> </li> ))} </ul> </div> <div className='px-6 py-4'> {hasCurrentPlan(plan.priceId) ? ( <div className='inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600'> Current Plan </div> ) : ( <SubscribeButton priceId={plan.priceId} buttonText={ subscription ? 'Change Plan' : 'Subscribe' } /> )} </div> </div> ))} </div> </div> ); } 
Enter fullscreen mode Exit fullscreen mode

Webhook Handler

Create a file at app/api/stripe/webhook/route.js:

import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; import { getStripe } from '@/lib/stripe/stripe-server'; import { createClient } from '@supabase/supabase-js'; // Buffer to string for webhook signature verification const buffer = async (readable) => { const chunks = []; for await (const chunk of readable) { chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); } return Buffer.concat(chunks); }; export async function POST(request) { try { const body = await request.text(); const signature = headers().get('stripe-signature'); if (!signature) { return NextResponse.json( { error: 'Missing Stripe signature' }, { status: 401 } ); } // Initialize Stripe const stripe = getStripe(); // Verify webhook signature let event; try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { console.error( `Webhook signature verification failed: ${err.message}` ); return NextResponse.json( { error: `Webhook signature verification failed: ${err.message}`, }, { status: 400 } ); } // Initialize Supabase const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const supabase = createClient(supabaseUrl, supabaseServiceKey); // Handle specific Stripe events switch (event.type) { case 'checkout.session.completed': const session = event.data.object; // Extract user ID from metadata const userId = session.metadata?.userId; if (userId) { if (session.mode === 'subscription') { // Handle subscription payment await handleSuccessfulSubscription( session, userId, supabase ); } else { // Handle one-time payment await handleSuccessfulPayment( session, userId, supabase ); } } break; case 'invoice.paid': // Handle successful invoice payment await handleSuccessfulInvoice(event.data.object, supabase); break; case 'invoice.payment_failed': // Handle failed invoice payment await handleFailedInvoice(event.data.object, supabase); break; case 'customer.subscription.deleted': // Handle subscription cancellation await handleSubscriptionCancelled(event.data.object, supabase); break; // Add more event handlers as needed } return NextResponse.json({ received: true }); } catch (error) { console.error('Webhook error:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } // Helper functions for handling webhook events async function handleSuccessfulPayment(session, userId, supabase) { // Record the payment in your database await supabase.from('payments').insert({ user_id: userId, stripe_checkout_id: session.id, amount: session.amount_total, currency: session.currency, status: 'completed', payment_intent: session.payment_intent, payment_method: session.payment_method_types?.[0] || 'unknown', created_at: new Date().toISOString(), }); // Update user's access level or entitlements if needed // This depends on your specific business logic } async function handleSuccessfulSubscription(session, userId, supabase) { // Get the customer and subscription IDs const subscriptionId = session.subscription; const customerId = session.customer; // Verify subscription details const stripe = getStripe(); const subscription = await stripe.subscriptions.retrieve(subscriptionId); const priceId = subscription.items.data[0].price.id; // Record the subscription in your database await supabase.from('subscriptions').insert({ user_id: userId, stripe_customer_id: customerId, stripe_subscription_id: subscriptionId, stripe_price_id: priceId, status: subscription.status, current_period_start: new Date( subscription.current_period_start * 1000 ).toISOString(), current_period_end: new Date( subscription.current_period_end * 1000 ).toISOString(), created_at: new Date().toISOString(), }); // Update user's access level based on the subscription await supabase .from('profiles') .update({ subscription_status: subscription.status, subscription_plan: priceId, }) .eq('user_id', userId); } async function handleSuccessfulInvoice(invoice, supabase) { const customerId = invoice.customer; const subscriptionId = invoice.subscription; if (!subscriptionId) { // This is not a subscription invoice return; } // Find the customer in your database const { data: customerData } = await supabase .from('customers') .select('user_id') .eq('stripe_customer_id', customerId) .single(); if (!customerData?.user_id) { console.error('Customer not found:', customerId); return; } // Update subscription status await supabase .from('subscriptions') .update({ status: 'active', current_period_end: new Date( invoice.lines.data[0].period.end * 1000 ).toISOString(), }) .eq('stripe_subscription_id', subscriptionId); // Update user's subscription status await supabase .from('profiles') .update({ subscription_status: 'active', }) .eq('user_id', customerData.user_id); } async function handleFailedInvoice(invoice, supabase) { const customerId = invoice.customer; const subscriptionId = invoice.subscription; if (!subscriptionId) { // This is not a subscription invoice return; } // Find the customer in your database const { data: customerData } = await supabase .from('customers') .select('user_id') .eq('stripe_customer_id', customerId) .single(); if (!customerData?.user_id) { console.error('Customer not found:', customerId); return; } // Update subscription status await supabase .from('subscriptions') .update({ status: 'past_due', }) .eq('stripe_subscription_id', subscriptionId); // Update user's subscription status await supabase .from('profiles') .update({ subscription_status: 'past_due', }) .eq('user_id', customerData.user_id); } async function handleSubscriptionCancelled(subscription, supabase) { const subscriptionId = subscription.id; // Update subscription in your database await supabase .from('subscriptions') .update({ status: 'cancelled', cancelled_at: new Date().toISOString(), }) .eq('stripe_subscription_id', subscriptionId); // Find the user associated with this subscription const { data: subscriptionData } = await supabase .from('subscriptions') .select('user_id') .eq('stripe_subscription_id', subscriptionId) .single(); if (subscriptionData?.user_id) { // Update user's subscription status await supabase .from('profiles') .update({ subscription_status: 'cancelled', subscription_plan: null, }) .eq('user_id', subscriptionData.user_id); } } // This is needed to parse the body as a stream for the webhook signature verification export const config = { api: { bodyParser: false, }, }; 
Enter fullscreen mode Exit fullscreen mode

Supabase Integration

1. Add Stripe Customer ID to Supabase User Table

Create a Supabase migration to add a stripe_customer_id column to your users table:

-- Create customers table to store Stripe customer IDs CREATE TABLE IF NOT EXISTS customers ( id SERIAL PRIMARY KEY, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, stripe_customer_id TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc', NOW()), UNIQUE(user_id), UNIQUE(stripe_customer_id) ); -- Create payments table to track one-time payments CREATE TABLE IF NOT EXISTS payments ( id SERIAL PRIMARY KEY, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, stripe_checkout_id TEXT, stripe_payment_intent_id TEXT, amount INTEGER NOT NULL, currency TEXT NOT NULL, status TEXT NOT NULL, payment_method TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc', NOW()) ); -- Create subscriptions table to track user subscriptions CREATE TABLE IF NOT EXISTS subscriptions ( id SERIAL PRIMARY KEY, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, stripe_customer_id TEXT NOT NULL, stripe_subscription_id TEXT NOT NULL, stripe_price_id TEXT NOT NULL, status TEXT NOT NULL, current_period_start TIMESTAMP WITH TIME ZONE NOT NULL, current_period_end TIMESTAMP WITH TIME ZONE NOT NULL, cancelled_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc', NOW()), UNIQUE(stripe_subscription_id) ); -- Add subscription fields to profiles table if it exists -- If you don't have a profiles table, you should create one ALTER TABLE IF EXISTS profiles ADD COLUMN IF NOT EXISTS subscription_status TEXT, ADD COLUMN IF NOT EXISTS subscription_plan TEXT; 
Enter fullscreen mode Exit fullscreen mode

2. Create a Supabase client hook (if you haven't already)

Create a file at lib/supabase/client.js:

'use client'; import { createContext, useContext, useEffect, useState } from 'react'; import { createClient } from '@supabase/supabase-js'; // Create a Supabase client const createBrowserClient = () => { return createClient( process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ); }; // Create a context for the Supabase client const SupabaseContext = createContext(null); // Provider component to wrap your app export function SupabaseProvider({ children }) { const [supabase] = useState(() => createBrowserClient()); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const { data: { subscription }, } = supabase.auth.onAuthStateChange(async (event, session) => { setUser(session?.user || null); setLoading(false); }); // Initial check const checkUser = async () => { const { data: { session }, } = await supabase.auth.getSession(); setUser(session?.user || null); setLoading(false); }; checkUser(); return () => { subscription?.unsubscribe(); }; }, [supabase]); return ( <SupabaseContext.Provider value={{ supabase, user, loading }}> {children} </SupabaseContext.Provider>  ); } // Hook to use the Supabase client export function useSupabase() { const context = useContext(SupabaseContext); if (!context) { throw new Error('useSupabase must be used within a SupabaseProvider'); } return context; } 
Enter fullscreen mode Exit fullscreen mode

3. Update your app layout to include the Supabase provider

Update file at app/layout.jsx:

import { SupabaseProvider } from '@/lib/supabase/client'; import './globals.css'; export const metadata = { title: 'My Next.js App with Stripe and Supabase', description: 'A Next.js app with Stripe payments and Supabase authentication', }; export default function RootLayout({ children }) { return ( <html lang='en'> <body> <SupabaseProvider>{children}</SupabaseProvider> </body> </html> ); } 
Enter fullscreen mode Exit fullscreen mode

Testing

1. Set up Stripe CLI for local testing

  1. Download and install the Stripe CLI
  2. Login to your Stripe account:

    stripe login 
  3. Start the webhook forwarding:

    stripe listen --forward-to http://localhost:3000/api/stripe/webhook 
  4. The CLI will output a webhook signing secret. Add this to your .env.local file:

    STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_from_cli 

2. Use Stripe test cards for testing

For testing payments, use Stripe's test card numbers:

  • Successful payment: 4242 4242 4242 4242
  • Payment requires authentication: 4000 0025 0000 3155
  • Payment declined: 4000 0000 0000 0002

3. Testing checklist

  • Ensure the Stripe dashboard is set to test mode
  • Test one-time payments
  • Test subscription creation
  • Test subscription cancellation
  • Test webhook handling
  • Verify Supabase data is updated correctly

Going to Production

1. Update environment variables

For production, update your environment variables with production API keys:

# Production Stripe API Keys NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_your_publishable_key STRIPE_SECRET_KEY=sk_live_your_secret_key # Production Webhook Secret (from Stripe Dashboard) STRIPE_WEBHOOK_SECRET=whsec_your_live_webhook_secret # Production URL NEXT_PUBLIC_SITE_URL=https://your-production-domain.com 
Enter fullscreen mode Exit fullscreen mode

2. Set up production webhooks

  1. Go to the Stripe Dashboard > Developers > Webhooks
  2. Add an endpoint with your production URL (e.g., https://your-production-domain.com/api/stripe/webhook)
  3. Select the events you want to listen for (at minimum: checkout.session.completed, invoice.paid, invoice.payment_failed, customer.subscription.deleted)
  4. Copy the webhook signing secret and add it to your environment variables

3. Final checklist before going live

  • Ensure your Stripe account is fully verified for production
  • Test the integration in production mode with a small real payment
  • Verify that webhooks are being received correctly
  • Implement proper error handling and monitoring
  • Set up Stripe email receipts
  • Configure tax settings if applicable
  • Ensure compliance with local payment regulations

Conclusion

You now have a complete Stripe integration for your Next.js 15 application with Supabase authentication. This implementation supports:

  • One-time payments via Elements and Checkout Sessions
  • Subscription management
  • Webhook handling
  • Integration with user accounts

Remember to keep your API keys secure and never expose your Stripe secret key to the client-side code. All sensitive operations should be handled on the server-side through API routes.

Read this article and more on fzeba.com.

Top comments (0)