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);
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
}
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 })
}
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 })
}
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 })
}
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
Register these webhook events in Stripe Dashboard:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deleted
10 Rules I Follow Every Time
- Create Stripe Customer at signup, not at payment time
- Always use
req.text()in webhook handler — neverreq.json() - Use
idempotencyKeyfor Checkout sessions (user ID + price ID) - Enable Customer Portal in both test and live Stripe Dashboard
- Use separate
STRIPE_WEBHOOK_SECRETper environment - Store
stripe_customer_idin DB immediately after creation — don't hold it in memory - Access control =
subscription_status+current_period_end(not just status) - Use
stripe.webhooks.constructEvent— never skip signature verification - Handle
subscription.updatedwithstatus: canceledfor end-of-period cancellations - 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)
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_itemsrow 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 likeinvoice.paid, so it hides in normal logs.Wrote up the full incident if useful: is.gd/s6OvYM