Back to Blog
·10 min

Stripe Subscription Templates: Build a Next.js Billing Boilerplate

Subscription billing is the backbone of SaaS businesses. Setting up Stripe subscriptions correctly — with webhooks, proration, trial periods, upgrade/downgrade handling, and customer portal integration — is one of the most complex parts of building a SaaS product. A single mistake in webhook handling can lead to billing errors, angry customers, and lost revenue.

A Stripe subscription template or Next.js billing boilerplate eliminates this complexity by providing production-ready billing infrastructure that handles every edge case.

Why Billing Is So Complex

Subscription billing seems simple at first: charge users monthly and let them upgrade or cancel. In practice, production billing requires handling:

  • Subscription creation with trial periods
  • Plan upgrades with prorated charges
  • Plan downgrades with credits
  • Cancellation at period end vs immediate
  • Failed payment recovery with dunning
  • Invoice generation and email delivery
  • Tax calculation and collection
  • Multi-currency support
  • Usage-based billing for metered products
  • Coupon and discount application

Each of these scenarios requires careful Stripe API handling, webhook processing with idempotency, and database synchronization.

What a Stripe Subscription Template Includes

A production-ready Next.js billing boilerplate should handle:

Checkout Flow

Stripe Checkout or custom Elements UI for collecting payment details. The template should support both one-time payments and recurring subscriptions with plan selection.

Webhook Processing

Stripe sends webhooks for dozens of events — subscription created, updated, deleted, payment succeeded, payment failed, invoice finalized. A proper webhook handler verifies signatures, processes events idempotently, and updates your database.

Customer Portal

Stripe's hosted customer portal lets users manage their subscription — upgrade, downgrade, cancel, update payment method, and download invoices. The template should generate the portal link and redirect users after authentication.

Setting Up Stripe Billing

Here is how to configure a Stripe subscription template:

git clone https://github.com/breafio/saas-starter-kit.git my-saas
cd my-saas
pnpm install
cp .env.example .env.local

Environment Configuration

# .env.local
STRIPE_SECRET_KEY=sk_live_your_stripe_secret
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_your_publishable_key
STRIPE_PRICE_STARTER=price_starter_monthly
STRIPE_PRICE_PRO=price_pro_monthly
STRIPE_PRICE_ENTERPRISE=price_enterprise_monthly

Subscription API Route

// app/api/create-subscription/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: Request) {
  const session = await auth()
  if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const { priceId } = await req.json()

  // Create or retrieve the Stripe customer
  let customerId: string
  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: { stripeCustomerId: true, email: true, name: true },
  })

  if (user?.stripeCustomerId) {
    customerId = user.stripeCustomerId
  } else {
    const customer = await stripe.customers.create({
      email: user!.email!,
      name: user!.name ?? undefined,
      metadata: { userId: session.user.id },
    })
    customerId = customer.id
    await prisma.user.update({
      where: { id: session.user.id },
      data: { stripeCustomerId: customer.id },
    })
  }

  // Create checkout session
  const checkout = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    subscription_data: {
      metadata: { userId: session.user.id },
    },
  })

  return NextResponse.json({ url: checkout.url })
}

Webhook Handler

Webhook processing is the most critical part of billing infrastructure:

// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { prisma } from '@/lib/prisma'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(req: Request) {
  const body = await req.text()
  const signature = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      const subscriptionId = session.subscription as string
      const customerId = session.customer as string
      const userId = session.metadata?.userId

      if (userId) {
        const subscription = await stripe.subscriptions.retrieve(subscriptionId)
        await prisma.user.update({
          where: { id: userId },
          data: {
            stripeCustomerId: customerId,
            stripeSubscriptionId: subscriptionId,
            stripeSubscriptionStatus: subscription.status,
            stripePriceId: subscription.items.data[0].price.id,
          },
        })
      }
      break
    }

    case 'invoice.payment_succeeded': {
      const invoice = event.data.object as Stripe.Invoice
      if (invoice.subscription) {
        const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string)
        const userId = subscription.metadata?.userId
        if (userId) {
          await prisma.user.update({
            where: { id: userId },
            data: { stripeSubscriptionStatus: subscription.status },
          })
        }
      }
      break
    }

    case 'customer.subscription.updated':
    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      const userId = subscription.metadata?.userId
      if (userId) {
        await prisma.user.update({
          where: { id: userId },
          data: {
            stripeSubscriptionStatus: subscription.status,
            stripePriceId: subscription.items.data[0]?.price.id ?? null,
            stripeSubscriptionId: subscription.id,
          },
        })
      }
      break
    }
  }

  return NextResponse.json({ received: true })
}

Customer Portal

Let users manage their billing without building UI:

// app/api/portal/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function GET() {
  const session = await auth()
  if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: { stripeCustomerId: true },
  })

  if (!user?.stripeCustomerId) {
    return NextResponse.json({ error: 'No customer found' }, { status: 400 })
  }

  const portal = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  })

  return NextResponse.json({ url: portal.url })
}

Subscription Status Check

Middleware to gate premium features:

// lib/stripe/access.ts
import { prisma } from '@/lib/prisma'

const ALLOWED_STATUSES = ['active', 'trialing', 'past_due']

export async function checkSubscriptionAccess(userId: string, requiredPlan?: string) {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: { stripeSubscriptionStatus: true, stripePriceId: true },
  })

  if (!user) return false
  if (!ALLOWED_STATUSES.includes(user.stripeSubscriptionStatus!)) return false

  if (requiredPlan && user.stripePriceId !== requiredPlan) return false
  return true
}

The Bottom Line

Subscription billing is the most critical infrastructure in your SaaS. A single bug can cause revenue loss, customer churn, and support headaches. A production-ready Stripe subscription template handles every edge case — webhook processing, proration, dunning, customer portal — so you never have to debug billing code while customers are being charged incorrectly.

Browse all 200+ templates and starter kits and find the perfect billing foundation for your SaaS.

Ready to Build?

Get started with our production-ready starter kits and ship your project faster.

Browse Starter Kits