Next.js & Supabase Stripe Subscriptions: SaaS Guide
SaaS billing with Stripe subscriptions in Next.js + Supabase: webhooks, user syncing, and gated content for production.
Introduction#
Building a subscription-based product involves more than just throwing a checkout button on a pricing page. The hardest part isn't taking the payment—it's maintaining a synchronized state between Stripe, your application database (Supabase), and your frontend UI (Next.js).
If your local database drops out of sync with Stripe's source of truth, users get locked out of content they paid for, or worse, get free access after canceling.
This guide walks through building a production-grade billing system using Next.js App Router, Supabase, and Stripe Checkout. We cover webhook processing, secure database schema design, and gating premium content natively on the server.
The architecture in one paragraph: Stripe is the source of truth, Supabase is a read-optimized cache of it. Checkout Sessions are created server-side; a webhook route verifies each event's signature against the raw body, resolves the Supabase user through a customers mapping table, and upserts subscription state with the service-role key; Server Components read that cached status column to gate content. Every design decision below follows from keeping that one-way sync honest — including the parts Stripe's docs warn about: unordered event delivery, duplicate events, and the 2025-03-31.basil API changes to billing-period fields.
1. Database Schema Design for SaaS Billing#
Your Supabase database must cache Stripe's state to prevent querying the Stripe API on every page load. We need two primary tables: customers and subscriptions.
Run this SQL in your Supabase SQL Editor:
-- Map Auth Users to Stripe Customers
CREATE TABLE public.customers (
id uuid references auth.users not null primary key,
stripe_customer_id text
);
-- Store Subscription State
CREATE TABLE public.subscriptions (
id text primary key,
user_id uuid references auth.users not null,
status text check (status in ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid', 'paused')),
price_id text,
quantity integer,
cancel_at_period_end boolean,
created timestamp with time zone default timezone('utc'::text, now()) not null,
current_period_start timestamp with time zone default timezone('utc'::text, now()) not null,
current_period_end timestamp with time zone default timezone('utc'::text, now()) not null,
ended_at timestamp with time zone default timezone('utc'::text, now()),
cancel_at timestamp with time zone default timezone('utc'::text, now()),
canceled_at timestamp with time zone default timezone('utc'::text, now()),
trial_start timestamp with time zone default timezone('utc'::text, now()),
trial_end timestamp with time zone default timezone('utc'::text, now())
);
-- Enable Row Level Security (RLS)
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
-- Users can only read their own data
CREATE POLICY "Can read own customer data" ON customers FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Can read own subscription data" ON subscriptions FOR SELECT USING (auth.uid() = user_id);Security Note: Notice that we only create SELECT policies for users. INSERT and UPDATE operations for subscriptions should only be performed securely on the server via Stripe webhooks using a Service Role key.
2. Setting Up the Stripe Checkout Session#
When a user clicks "Subscribe", we must generate a secure Stripe Checkout URL on the server. Never initiate checkouts entirely on the client side, as malicious actors can manipulate price IDs.
With Next.js Server Actions, this is straightforward:
// app/actions/stripe.ts
'use server'
import { headers } from 'next/headers'
import { createClient } from '@/lib/supabase/server'
import { stripe } from '@/lib/stripe'
import { redirect } from 'next/navigation'
export async function createCheckoutSession(priceId: string) {
const supabase = createClient()
// 1. Get authenticated user
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('You must be logged in to subscribe.')
// 2. Fetch or create Stripe Customer ID
let { data: customerData } = await supabase
.from('customers')
.select('stripe_customer_id')
.eq('id', user.id)
.single()
let customerId = customerData?.stripe_customer_id
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { supabaseUUID: user.id }
})
customerId = customer.id
// Save mapping in Supabase
await supabase.from('customers').insert({
id: user.id,
stripe_customer_id: customerId
})
}
// 3. Create Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${headers().get('origin')}/dashboard?success=true`,
cancel_url: `${headers().get('origin')}/pricing?canceled=true`,
})
redirect(checkoutSession.url as string)
}One gap this code leaves open:
stripe.checkout.sessions.createhas noidempotencyKey, so a double-click on "Subscribe" or a Vercel function timeout followed by an automatic retry can create two Checkout Sessions for the same intent — and two charges. Stripe'sidempotencyKeyparameter collapses retries into a single call; our step-by-step idempotency key setup for Next.js shows exactly where to wire it for a Supabase-backed checkout.
3. Which Stripe Events You Actually Need#
Stripe fires dozens of event types; subscribing to all of them "puts undue strain on your server" (Stripe's words). For a subscription SaaS, Stripe's subscription-webhooks guide boils down to a short list:
| Event | Why you care |
|---|---|
checkout.session.completed | Checkout finished. On API version 2025-03-31.basil and later, Stripe only creates the subscription after payment completes, so this is your earliest reliable signal. |
customer.subscription.created / updated / deleted | The subscription's lifecycle. updated fires on renewals, plan changes, coupons, cancellations-at-period-end — sync your subscriptions row on every one. |
invoice.paid | Stripe's documented signal to provision access: "You can provision access to your product when you receive this event and the subscription status is active." |
invoice.payment_failed | A renewal charge failed. Notify the customer; consider Stripe's Smart Retries. |
customer.subscription.trial_will_end | Sent 3 days before a trial ends — your window to confirm a payment method exists before the first real charge. |
Two delivery behaviors from the same doc that your handler must survive:
- Ordering is not guaranteed. Creating a subscription can emit
customer.subscription.created,invoice.created,invoice.paid, andcharge.created— and Stripe explicitly does not promise they arrive in that order. - Duplicates happen. Stripe recommends logging processed event IDs and skipping ones you've already seen.
The upsert pattern below survives both: instead of trusting the event payload's ordering, it treats every subscription event as "re-sync this subscription's current state."
4. The Webhook Handler: Syncing State#
This is the beating heart of your billing system. When Stripe charges a card, updates a subscription, or cancels an account, it sends a webhook back to your application.
Create an API Route in Next.js to listen to these events securely.
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe'
import { createAdminClient } from '@/lib/supabase/admin' // Uses Service Role Key
export async function POST(req: Request) {
const body = await req.text()
const signature = headers().get('Stripe-Signature') as string
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err: any) {
return new NextResponse(`Webhook Error: ${err.message}`, { status: 400 })
}
const supabaseAdmin = createAdminClient()
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
// Resolve the Supabase user from OUR customers table, not from
// subscription.metadata. We set metadata on the Stripe *Customer* in
// step 2 — the Subscription object never inherits it, so reading
// subscription.metadata.supabaseUUID here would silently return
// undefined and violate the NOT NULL constraint on user_id.
const { data: customer } = await supabaseAdmin
.from('customers')
.select('id')
.eq('stripe_customer_id', subscription.customer as string)
.single()
if (!customer) break // customer created outside this app — ignore
// Billing-period fields: API version 2025-03-31.basil removed
// current_period_start/end from the Subscription object and moved
// them to each subscription item. On older API versions they still
// live on the subscription itself.
const item = subscription.items.data[0]
await supabaseAdmin.from('subscriptions').upsert({
id: subscription.id,
user_id: customer.id,
status: subscription.status,
price_id: item.price.id,
cancel_at_period_end: subscription.cancel_at_period_end,
current_period_start: new Date(item.current_period_start * 1000).toISOString(),
current_period_end: new Date(item.current_period_end * 1000).toISOString(),
// map other fields accordingly...
})
break
}
default:
console.log(`Unhandled event type ${event.type}`)
}
return new NextResponse('Webhook processed', { status: 200 })
}You must use the Supabase Service Role Key inside your webhook handlers. The Standard anon key will fail due to Row Level Security, as the webhook operates outside the context of an authenticated user session. The service role key bypasses RLS entirely — never let it reach the browser.
Three production behaviors from Stripe's docs that this handler leans on:
- Return 200 fast. Stripe requires your endpoint to "quickly return a successful status code (2xx) prior to any complex logic that might cause a timeout." If your Supabase writes grow slow, move them behind a queue and acknowledge first.
- Failures are retried, not lost. In live mode Stripe retries failed deliveries for up to three days with exponential backoff; in a sandbox, only three times over a few hours. If the handler 500s during a deploy, the backlog replays itself.
- Verification failures have their own dedicated post. If you're seeing
No signatures found matching the expected signature, the raw-body walkthrough is here: Stripe Webhook Signature Verification Failed in Next.js.
Watch your Stripe API version#
The pinned API version on your Stripe account (or in the SDK constructor) changes the shape of webhook payloads. The one that bites subscription integrations: 2025-03-31.basil removed current_period_start / current_period_end from the Subscription object (they now live per subscription item, as in the code above) and changed Checkout so the subscription is only created after the customer completes payment. Code copied from pre-Basil tutorials type-checks against old typings and then crashes — or writes Invalid Date — in production. Stripe also notes that the event structure follows your account's API version at the time the event was created, so upgrading your version doesn't retroactively rewrite old events you replay.
5. Handling Failed Payments and Subscription States#
Your status column will hold more than active and canceled, and each state carries documented semantics you should encode in your access checks:
| Status | What Stripe says | What your app should do |
|---|---|---|
trialing | Trial period; converts to active on first payment | Grant access |
active | In good standing | Grant access |
incomplete | First payment failed or needs authentication; the customer has 23 hours to complete it | Don't grant access yet |
incomplete_expired | First payment never completed within 23 hours; terminal | Nothing to do — the sub never billed |
past_due | Latest renewal invoice failed or wasn't attempted; Smart Retries may still recover it | Grace period + "update your card" prompt |
unpaid | Retries exhausted, invoices keep generating but payments aren't attempted | Stripe's guidance: revoke access |
canceled | Terminal state, cannot be updated | Revoke access |
paused | Trial ended with no payment method (with trial_settings.end_behavior.missing_payment_method: 'pause') | Prompt for a payment method |
The .in('status', ['trialing', 'active']) filter in the gating code below is exactly this table collapsed into a query. Whether past_due also grants a grace period is a business decision — the point is to make it deliberately, not by accident of which statuses you remembered.
For dunning, Stripe's recommendation on invoice.payment_failed is to notify the customer and enable Smart Retries in Dashboard billing settings, which re-attempts the charge on a machine-learned schedule before the subscription falls to past_due's configured end state (canceled, unpaid, or staying past_due — you choose in the Dashboard).
6. Gating Content on the Server#
Because we have mirrored our Stripe state directly inside Supabase, validating access in Next.js Server Components takes single millisecond reads.
// app/premium-dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function PremiumDashboard() {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
// Check active subscription
const { data: subscription } = await supabase
.from('subscriptions')
.select('status')
.eq('user_id', user.id)
.in('status', ['trialing', 'active'])
.single()
if (!subscription) {
redirect('/pricing') // Upsell the user
}
return (
<main>
<h1>Welcome to the Pro Dashboard</h1>
{/* Premium features here */}
</main>
)
}Key Takeaways#
- Cache Stripe state locally: Always mirror Stripe subscription state in
public.subscriptionsto avoid rate limits and latency. - Service Role for Webhooks: Use the
service_rolekey in webhooks to bypass RLS and securely mutate user billing states. - Server-Side Checks: Check the
statuscolumn from your server components before rendering protected routes. - Resolve users through your
customerstable, not through subscription metadata — the Subscription object doesn't inherit Customer metadata. - Pin and read the right API version: from
2025-03-31.basil, billing periods live on subscription items, and Checkout creates the subscription only after payment. - Handle
past_due,unpaid,incomplete, andpauseddeliberately — each has documented semantics, and "forgot to handle it" defaults to either free access or a locked-out paying customer.
Next Steps#
Now that your billing infrastructure is bulletproof, focus on increasing conversions. Check out our guide on Optimizing Next.js Performance for Conversions to ensure your app speed doesn't cost you checkout clicks.
See Also#
- Next.js + Supabase architecture patterns hub
- Next.js Webhook Handling and Event-Driven Architecture
- Stripe Webhook Signature Verification Failed in Next.js (Production Fix + Retry Strategy 2026)
- Error Handling and Observability for Next.js + Supabase
- Complete Guide to Building SaaS with Next.js and Supabase
Related#
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Error Handling and Observability for Next.js + Supabase
Comprehensive guide to error handling, logging, monitoring, and observability for production Next.js and Supabase applications.
Next.js Server Actions with Supabase: Complete Guide
Complete guide to Next.js Server Actions with Supabase. Learn validation, error handling, optimistic updates, and production patterns for type-safe forms.
GraphQL Integration with Next.js and Supabase Guide
Integrate GraphQL with Next.js and Supabase: schema generation, resolvers, authentication, and real-time subscriptions for production apps.