Developer Guide

Next.js & Supabase Stripe Subscriptions: The Complete SaaS Guide

Master SaaS billing by integrating Stripe subscriptions with Next.js and Supabase. Learn webhooks, user syncing, and gated content strategies for production.

2026-03-24

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.

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:

sql
-- 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);
Tip

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:

typescript
// 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)
}

3. 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.

typescript
// 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
      
      // Upsert subscription data into Supabase
      await supabaseAdmin.from('subscriptions').upsert({
        id: subscription.id,
        user_id: subscription.metadata.supabaseUUID,
        status: subscription.status,
        price_id: subscription.items.data[0].price.id,
        cancel_at_period_end: subscription.cancel_at_period_end,
        current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
        current_period_end: new Date(subscription.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 })
}
Warning

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.

4. 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.

tsx
// 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.subscriptions to avoid rate limits and latency.
  • Service Role for Webhooks: Use the service_role key in webhooks to bypass RLS and securely mutate user billing states.
  • Server-Side Checks: Check the status column from your server components before rendering protected routes.

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.