DEV Community

ミント
ミント

Posted on • Originally published at note.com

The Complete Stripe Billing Template for Next.js: 5 Production Landmines

The Complete Stripe Billing Template for Next.js: 5 Production Landmines and How to Avoid Them

I spent 8 hours debugging Stripe on my first SaaS. Now I can wire it up in 30 minutes. Here's the full template with every gotcha documented.

This is what I actually use in production — not a toy demo. Copy-paste it and adapt.


The Stack

  • Next.js 14+ (App Router)
  • Supabase (auth + DB)
  • Stripe (subscriptions + Customer Portal)

DB Schema First

Before any code, add these columns to your profiles table:

alter table profiles
  add column if not exists stripe_customer_id text,
  add column if not exists subscription_status text default 'free',
  add column if not exists price_id text,
  add column if not exists current_period_end timestamptz;

create index if not exists idx_profiles_stripe_customer_id
  on profiles(stripe_customer_id);
Enter fullscreen mode Exit fullscreen mode

Step 1: Create Customer on Signup

Landmine #1: Don't create the Customer lazily.

The temptation is to create a Stripe Customer only when the user tries to pay. Don't. Create it at signup. If you create it lazily, you'll end up with race conditions and orphaned sessions.

lib/stripe.ts

import Stripe from 'stripe'

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-04-10',
})

export async function getOrCreateStripeCustomer(
  userId: string,
  email: string
): Promise<string> {
  const { createClient } = await import('@/lib/supabase/server')
  const supabase = createClient()

  const { data: profile } = await supabase
    .from('profiles')
    .select('stripe_customer_id')
    .eq('id', userId)
    .single()

  if (profile?.stripe_customer_id) {
    return profile.stripe_customer_id
  }

  // Create and immediately persist — never leave this in memory only
  const customer = await stripe.customers.create({
    email,
    metadata: { supabase_user_id: userId },
  })

  await supabase
    .from('profiles')
    .update({ stripe_customer_id: customer.id })
    .eq('id', userId)

  return customer.id
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Checkout Session (Initial Payment)

Landmine #2: Pass idempotencyKey on Checkout sessions.

Without idempotency keys, a user double-clicking "Subscribe" can create two subscriptions. Always generate a key tied to the user + price combination.

app/api/stripe/checkout/route.ts

import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { stripe, getOrCreateStripeCustomer } from '@/lib/stripe'

export async function POST(req: NextRequest) {
  const supabase = createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const { priceId } = await req.json()
  const customerId = await getOrCreateStripeCustomer(user.id, user.email!)

  const session = await stripe.checkout.sessions.create(
    {
      customer: customerId,
      mode: 'subscription',
      payment_method_types: ['card'],
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?checkout=success`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    },
    {
      idempotencyKey: `checkout-${user.id}-${priceId}`,
    }
  )

  return NextResponse.json({ url: session.url })
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Webhook — The Most Critical Part

Landmine #3: You MUST use req.text() for the raw body.

This is the #1 cause of webhook failures. If you use req.json(), the body gets parsed and Stripe's signature verification fails. Always read the raw text first.

app/api/stripe/webhook/route.ts

import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import Stripe from 'stripe'
import { stripe } from '@/lib/stripe'
import { createAdminClient } from '@/lib/supabase/admin'

export async function POST(req: NextRequest) {
  // Critical: use req.text(), NOT req.json()
  const body = await req.text()
  const signature = headers().get('stripe-signature')!

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

  const supabase = createAdminClient()

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

      const subscription = await stripe.subscriptions.retrieve(subscriptionId)
      const { data: profile } = await supabase
        .from('profiles')
        .select('id')
        .eq('stripe_customer_id', customerId)
        .single()

      if (profile) {
        await supabase.from('profiles').update({
          subscription_status: subscription.status,
          price_id: subscription.items.data[0]?.price.id ?? null,
          current_period_end: new Date(
            subscription.current_period_end * 1000
          ).toISOString(),
        }).eq('id', profile.id)
      }
      break
    }

    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription
      const { data: profile } = await supabase
        .from('profiles')
        .select('id')
        .eq('stripe_customer_id', subscription.customer as string)
        .single()

      if (profile) {
        await supabase.from('profiles').update({
          subscription_status: subscription.status,
          price_id: subscription.items.data[0]?.price.id ?? null,
          current_period_end: new Date(
            subscription.current_period_end * 1000
          ).toISOString(),
        }).eq('id', profile.id)
      }
      break
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      const { data: profile } = await supabase
        .from('profiles')
        .select('id')
        .eq('stripe_customer_id', subscription.customer as string)
        .single()

      if (profile) {
        await supabase.from('profiles').update({
          subscription_status: 'canceled',
          price_id: null,
        }).eq('id', profile.id)
      }
      break
    }
  }

  return NextResponse.json({ received: true })
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Customer Portal (Subscription Management)

Landmine #4: Configure the Portal in Stripe Dashboard BEFORE writing code.

The Portal needs to be explicitly enabled in Stripe Dashboard → Settings → Billing → Customer portal. Test mode and live mode are configured separately — I've been burned by this more than once.

app/api/stripe/portal/route.ts

import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { stripe, getOrCreateStripeCustomer } from '@/lib/stripe'

export async function POST(req: NextRequest) {
  const supabase = createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const customerId = await getOrCreateStripeCustomer(user.id, user.email!)

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

  return NextResponse.json({ url: session.url })
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Env Switching (Test vs Live)

Landmine #5: Different webhook secrets for test and live mode.

STRIPE_WEBHOOK_SECRET from stripe listen (local dev) is different from the one in Stripe Dashboard (production). I once deployed with the test secret to production and spent an hour wondering why all webhooks were failing.

# .env.local (development)
STRIPE_SECRET_KEY=sk_test_xxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxx   # from: stripe listen --forward-to localhost:3000/api/stripe/webhook
NEXT_PUBLIC_APP_URL=http://localhost:3000

# .env.production (Vercel)
STRIPE_SECRET_KEY=sk_live_xxxx
STRIPE_WEBHOOK_SECRET=whsec_yyyy   # from: Stripe Dashboard → Developers → Webhooks
NEXT_PUBLIC_APP_URL=https://yourdomain.com
Enter fullscreen mode Exit fullscreen mode

Register these webhook events in Stripe Dashboard:

  • checkout.session.completed
  • customer.subscription.updated
  • customer.subscription.deleted

10 Rules I Follow Every Time

  1. Create Stripe Customer at signup, not at payment time
  2. Always use req.text() in webhook handler — never req.json()
  3. Use idempotencyKey for Checkout sessions (user ID + price ID)
  4. Enable Customer Portal in both test and live Stripe Dashboard
  5. Use separate STRIPE_WEBHOOK_SECRET per environment
  6. Store stripe_customer_id in DB immediately after creation — don't hold it in memory
  7. Access control = subscription_status + current_period_end (not just status)
  8. Use stripe.webhooks.constructEvent — never skip signature verification
  9. Handle subscription.updated with status: canceled for end-of-period cancellations
  10. Index profiles(stripe_customer_id) — you'll query by it on every webhook

What Takes 8 Hours vs 30 Minutes

The first time: figuring out raw body, Customer creation order, idempotency, environment mismatches.

With this template: copy, replace env vars, enable Portal in Dashboard. Done.

Next up: "Supabase + Vercel + Next.js: 15 Landmines" (→ #7)


Originally posted on note (Japanese): https://note.com/mintototo1/n/nc7cf608b62e4

Top comments (1)

Collapse
 
edhiblemeer profile image
edhiblemeer

The "don't create the Customer lazily" landmine is solid — we hit a sibling version of this where a Customer existed but the corresponding subscription_items row was missing for one tenant, and the resulting webhook 4xx silently retried for 5 days until Stripe almost auto-disabled our endpoint.

Worth adding as a 6th landmine: even when your write path looks correct, run a periodic referential-integrity check across customer ↔ subscription ↔ subscription_items. The drift only shows up on month-aligned events like invoice.paid, so it hides in normal logs.

Wrote up the full incident if useful: is.gd/s6OvYM