Stripe Integration Guide for Next.js/Supabase Applications
This guide walks through setting up Stripe with your Supabase + Next.js application to handle subscriptions for your SaaS app.
1. Create Stripe Products & Prices
- Log into your Stripe Dashboard
- Go to Products > Add Product
- Create one product with two pricing options:
- Monthly Subscription: Set your monthly price
- Annual Subscription: Set your yearly price (consider offering a discount)
- For each pricing option, make sure to:
- Add clear descriptions
- Set the recurring price (monthly or yearly)
- Add metadata with key
tierand valuepro- this is critical for the subscription tier to be recognized correctly in your database - Note the Product ID and Price IDs for later use in your app
Important: Your database functions rely on the
tier: prometadata to determine subscription levels. Without this metadata, the subscription tier will default to 'free' even for paid subscriptions.
2. Test Mode vs Live Mode in Stripe
Stripe provides two separate environments:
- Test Mode: For development and testing without real transactions
- Live Mode: For production use with real payments
Managing Test vs Live Mode:
-
Toggle Environment: Use the "Test Mode" toggle in the Stripe Dashboard to switch between environments
-
Separate API Keys: Stripe provides different API keys for each environment:
- Test keys start with
pk_test_andsk_test_ - Live keys start with
pk_live_andsk_live_
- Test keys start with
-
Environment Variables:
# Test environment
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_abc123...
STRIPE_SECRET_KEY=sk_test_abc123...
STRIPE_PRODUCT_ID=prod_S0gzgGF3BlKxPU
NEXT_PUBLIC_STRIPE_PRICE_MONTHLY=price_1R6fb3BADT6BPZHEOFIxgAYF
NEXT_PUBLIC_STRIPE_PRICE_YEARLY=price_1R6fb3BADT6BPZHEw03SfTPn
STRIPE_WEBHOOK_SECRET=whsec_test_abc123... # You'll get this in section 3
# Production environment
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_abc123...
STRIPE_SECRET_KEY=sk_live_abc123...
STRIPE_PRODUCT_ID=prod_S0gzgGF3BlKxPU
NEXT_PUBLIC_STRIPE_PRICE_MONTHLY=price_1R6fb3BADT6BPZHEOFIxgAYF
NEXT_PUBLIC_STRIPE_PRICE_YEARLY=price_1R6fb3BADT6BPZHEw03SfTPn
STRIPE_WEBHOOK_SECRET=whsec_live_abc123... # You'll get this in section 3
Note: The
STRIPE_WEBHOOK_SECRETwill be obtained when you set up webhooks in section 3.
- Deployment-Specific Configuration:
- Development/Preview: Use test keys for
https://{randomid}.lite.vusercontent.net - Production: Use live keys for
https://{appname}.vercel.app
- Development/Preview: Use test keys for
3. Stripe Webhook Setup
Create Webhook Endpoints
-
Go to Developers > Webhooks in Stripe Dashboard
-
Create webhook endpoints for each environment:
- Local development:
http://localhost:3000/api/webhooks/stripe - Preview deployments:
https://{randomid}.lite.vusercontent.net/api/webhooks/stripe - Production:
https://{appname}.vercel.app/api/webhooks/stripe
- Local development:
-
For each endpoint, select these events:
customer.createdcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedcustomer.updatedinvoice.paidinvoice.payment_failedproduct.createdproduct.updatedprice.createdprice.updated
-
Copy the Webhook Signing Secret for each environment
NextJS Webhook Implementation
Create a webhook handler at app/api/webhooks/stripe/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createAdminClient } from '@/lib/supabase/admin';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
// Relevant events to process
const relevantEvents = new Set([
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'customer.updated',
'invoice.paid',
'invoice.payment_failed',
'product.created',
'product.updated',
'price.created',
'price.updated',
]);
// Your app's product IDs (to filter out irrelevant product events)
const appProductIds = [process.env.STRIPE_PRODUCT_ID!];
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature') as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
webhookSecret
);
} catch (error: any) {
console.log(`Webhook signature verification failed: ${error.message}`);
return NextResponse.json(
{ error: `Webhook signature verification failed` },
{ status: 400 }
);
}
// Filter to only handle events for your app's products
if (
(event.type === 'product.created' ||
event.type === 'product.updated') &&
!appProductIds.includes((event.data.object as Stripe.Product).id)
) {
return NextResponse.json({ received: true, relevant: false });
}
if (
(event.type === 'price.created' ||
event.type === 'price.updated') &&
!appProductIds.includes((event.data.object as Stripe.Price).product as string)
) {
return NextResponse.json({ received: true, relevant: false });
}
// Check if we need to process this event
if (relevantEvents.has(event.type)) {
try {
const supabase = createAdminClient();
// Store the event in Supabase for processing
const { error } = await supabase
.from('stripe.webhook_events')
.insert({
stripe_event_id: event.id,
event_type: event.type,
event_data: event.data,
processed: false
});
if (error) throw error;
console.log(`✅ Webhook event ${event.id} stored for processing`);
} catch (error: any) {
console.log(`❌ Error storing webhook event: ${error.message}`);
return NextResponse.json(
{ error: 'Error storing webhook event' },
{ status: 500 }
);
}
}
// Return a 200 response to acknowledge receipt of the event
return NextResponse.json({ received: true });
}
4. Customer Portal Integration
The Stripe Customer Portal allows users to manage their subscriptions without you needing to build custom UI:
-
Configure Customer Portal in Stripe Dashboard:
- Go to Settings > Customer Portal
- Set branding, allowed actions, and return URL
-
Create an API endpoint to generate portal sessions:
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { cookies } from 'next/headers';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const cookieStore = cookies();
const supabase = createServerClient(cookieStore);
// Check user authentication
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
try {
// Get the user's Stripe customer ID
const { data: userSubscription } = await supabase
.from('user_subscription_details')
.select('stripe_customer_id')
.eq('user_id', session.user.id)
.single();
if (!userSubscription?.stripe_customer_id) {
return NextResponse.json(
{ error: 'No subscription found' },
{ status: 404 }
);
}
// Create a portal session
const portalSession = await stripe.billingPortal.sessions.create({
customer: userSubscription.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/account`,
});
// Return the URL to the client
return NextResponse.json({ url: portalSession.url });
} catch (error: any) {
console.error('Error creating portal session:', error);
return NextResponse.json(
{ error: 'Failed to create portal session' },
{ status: 500 }
);
}
}
- Create a component to handle subscription management:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
export default function ManageSubscriptionButton() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const handleManageSubscription = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/stripe/portal', {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to create portal session');
}
const { url } = await response.json();
router.push(url);
} catch (error) {
console.error('Error opening Stripe portal:', error);
alert('Failed to open subscription management portal');
} finally {
setIsLoading(false);
}
};
return (
<Button
onClick={handleManageSubscription}
disabled={isLoading}
variant="outline"
>
{isLoading ? 'Loading...' : 'Manage Subscription'}
</Button>
);
}
5. Checkout Sessions for New Subscriptions
Create an API endpoint to initiate the checkout process:
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { cookies } from 'next/headers';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const cookieStore = cookies();
const supabase = createServerClient(cookieStore);
// Check user authentication
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Get request body
const { priceId, planType } = await req.json();
try {
// Check if user already has a Stripe customer ID
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('user_id', session.user.id)
.single();
// Get or create customer in Stripe
let customerId;
const { data: customer } = await supabase
.from('stripe_customers')
.select('stripe_customer_id')
.eq('user_id', session.user.id)
.single();
if (customer?.stripe_customer_id) {
customerId = customer.stripe_customer_id;
} else {
// Create a new customer in Stripe
const stripeCustomer = await stripe.customers.create({
email: session.user.email,
name: profile?.full_name || session.user.email,
metadata: {
user_id: session.user.id
}
});
customerId = stripeCustomer.id;
// Store the customer ID in Supabase
await supabase
.from('stripe_customers')
.insert({
user_id: session.user.id,
stripe_customer_id: customerId
});
}
// Get app URL based on environment
const appUrl = process.env.NEXT_PUBLIC_APP_URL ||
(process.env.VERCEL_ENV === 'production'
? `https://${process.env.VERCEL_URL}`
: `https://${process.env.VERCEL_URL}.lite.vusercontent.net`);
// Create checkout session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'subscription',
success_url: `${appUrl}/dashboard/account?success=true&plan=${planType}`,
cancel_url: `${appUrl}/pricing?canceled=true`,
allow_promotion_codes: true,
metadata: {
user_id: session.user.id,
plan_type: planType
}
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error: any) {
console.error('Error creating checkout session:', error);
return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ status: 500 }
);
}
}
6. Supabase Database Functions
Create a function to sync Stripe subscription data to your app:
CREATE OR REPLACE FUNCTION stripe.sync_subscription_to_app()
RETURNS TRIGGER AS $$
BEGIN
-- Insert or update the user_subscriptions record
INSERT INTO api.user_subscriptions(
user_id,
subscription_tier,
status,
payment_provider,
subscription_id,
stripe_subscription_id,
stripe_price_id,
current_period_start,
current_period_end,
cancel_at_period_end,
cancelled_at
)
SELECT
sc.id, -- user_id from stripe.customers
CASE
WHEN sp.metadata->>'tier' = 'pro' THEN 'pro'::reference.subscription_tier
ELSE 'free'::reference.subscription_tier
END,
NEW.status,
'stripe',
NEW.id,
NEW.id,
NEW.price_id,
NEW.current_period_start,
NEW.current_period_end,
NEW.cancel_at_period_end,
NEW.canceled_at
FROM stripe.customers sc
LEFT JOIN stripe.prices sp ON sp.id = NEW.price_id
WHERE sc.stripe_customer_id = (NEW.metadata->>'stripe_customer_id')
ON CONFLICT (user_id) DO UPDATE SET
subscription_tier = CASE
WHEN EXCLUDED.stripe_price_id IN (SELECT id FROM stripe.prices WHERE metadata->>'tier' = 'pro')
THEN 'pro'::reference.subscription_tier
ELSE 'free'::reference.subscription_tier
END,
status = EXCLUDED.status,
stripe_subscription_id = EXCLUDED.stripe_subscription_id,
stripe_price_id = EXCLUDED.stripe_price_id,
current_period_start = EXCLUDED.current_period_start,
current_period_end = EXCLUDED.current_period_end,
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
cancelled_at = EXCLUDED.cancelled_at,
metadata = EXCLUDED.metadata,
updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create trigger for subscription changes
CREATE TRIGGER on_stripe_subscription_change
AFTER INSERT OR UPDATE ON stripe.subscriptions
FOR EACH ROW EXECUTE FUNCTION stripe.sync_subscription_to_app();
7. Managing Subscriptions on Front End
Create a component to handle subscription checkout:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
type SubscribeButtonProps = {
priceId: string;
planType: 'monthly' | 'yearly';
variant?: 'default' | 'outline' | 'secondary';
text?: string;
};
export default function SubscribeButton({
priceId,
planType,
variant = 'default',
text = 'Subscribe'
}: SubscribeButtonProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const handleSubscribe = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
priceId,
planType,
}),
});
if (!response.ok) {
throw new Error('Failed to create checkout session');
}
const { url } = await response.json();
router.push(url);
} catch (error) {
console.error('Error creating checkout session:', error);
alert('Failed to start subscription process');
} finally {
setIsLoading(false);
}
};
return (
<Button
onClick={handleSubscribe}
disabled={isLoading}
variant={variant}
>
{isLoading ? 'Loading...' : text}
</Button>
);
}
Using the Subscription Button in Your App
Create a pricing page that uses the subscription button with your stored price IDs:
import SubscribeButton from '@/components/SubscribeButton';
export default function PricingPage() {
return (
<div className="pricing-container">
<div className="pricing-plan">
<h2>Monthly Plan</h2>
<p className="price">$9.99/month</p>
<SubscribeButton
priceId={process.env.NEXT_PUBLIC_STRIPE_PRICE_MONTHLY!}
planType="monthly"
text="Subscribe Monthly"
/>
</div>
<div className="pricing-plan featured">
<h2>Annual Plan</h2>
<p className="price">$99.99/year</p>
<p className="savings">Save 16%</p>
<SubscribeButton
priceId={process.env.NEXT_PUBLIC_STRIPE_PRICE_YEARLY!}
planType="yearly"
text="Subscribe Yearly"
/>
</div>
</div>
);
}
Note that we prefix the environment variables with NEXT_PUBLIC_ so they're accessible in the client-side code.
8. Tracking Specific Products in Webhooks
To ensure your webhook only processes events for your specific product:
- Store Your Product ID in Environment Variables:
STRIPE_PRODUCT_ID=prod_abc123
- Update the Webhook Handler to Filter by Product ID: See the webhook implementation above, which includes product filtering.
9. Handling Development vs Production Environment
To manage different webhook URLs between environments:
-
For Local Development:
- Use Stripe CLI to forward webhooks:
stripe listen --forward-to localhost:3000/api/webhooks/stripe -
For Preview/Test Deployments:
- Create a separate webhook endpoint in Stripe dashboard pointing to:
https://{randomid}.lite.vusercontent.net/api/webhooks/stripe -
For Production:
- Create a production webhook endpoint in Stripe pointing to:
https://{appname}.vercel.app/api/webhooks/stripe
10. Additional Considerations and Best Practices
-
Error Handling and Retry Logic:
- Implement retry logic for failed webhook processing
- Set up monitoring and alerts for critical subscription events
-
Security:
- Always verify webhook signatures
- Store API keys securely in environment variables
- Use strong CORS policies on API endpoints
- Implement rate limiting on API endpoints
-
Database Structure:
- Keep Stripe data in a separate schema (e.g.,
stripe) - Maintain proper relations between app users and Stripe customers
- Use views to simplify access to subscription details
- Keep Stripe data in a separate schema (e.g.,
-
Testing:
- Test all subscription flows in Stripe Test Mode before going live
- Create test customers and subscriptions in Test Mode
- Validate webhook handling for all important events
-
Monitoring and Troubleshooting:
- Store all webhook events for auditing purposes
- Implement logging for key subscription operations
- Set up alerts for subscription failures
-
User Experience:
- Provide clear feedback during the subscription process
- Handle subscription failures gracefully
- Automatically redirect users to appropriate areas after subscription changes
-
Compliance:
- Ensure proper storage and handling of payment information
- Maintain records of subscription events for accounting purposes
- Provide clear terms of service and privacy policy
By following this guide, you'll have a robust Stripe integration for your Next.js and Supabase application, with proper handling of environments, product filtering, and user subscription management.