Most Stripe integration write-ups stop at the Checkout button. Production billing is the entry-point below: a typed PLANS config that drives every surface, a Checkout Server Action that knows about currencies and trials, a Customer Portal helper that returns to the right place, and a webhook handler that writes the resulting subscription row when Checkout completes.
1. The pricing config
Plans live in code. The same file is the source of truth for Checkout, the Customer Portal, the admin dashboard and the marketing pricing page. Updating a price is a typed edit, not a Stripe dashboard click.
// src/lib/billing/plans.ts
import { z } from 'zod'
export const PLAN_IDS = ['starter', 'growth', 'enterprise'] as const
export type PlanId = (typeof PLAN_IDS)[number]
export const CURRENCY_IDS = ['eur', 'usd', 'gbp'] as const
export type CurrencyId = (typeof CURRENCY_IDS)[number]
export interface PlanPrice {
stripePriceId: string
monthlyCents: number
yearlyDiscount: number
}
interface PlanDefinition {
trialDays: number
features: string[]
prices: Record<CurrencyId, PlanPrice>
}
export const PLANS: Record<PlanId, PlanDefinition> = {
starter: {
trialDays: 14,
features: ['5 seats', 'core integrations', 'email support'],
prices: {
eur: {
stripePriceId: process.env.STRIPE_PRICE_STARTER_EUR!,
monthlyCents: 2900,
yearlyDiscount: 0.15,
},
usd: {
stripePriceId: process.env.STRIPE_PRICE_STARTER_USD!,
monthlyCents: 3200,
yearlyDiscount: 0.15,
},
gbp: {
stripePriceId: process.env.STRIPE_PRICE_STARTER_GBP!,
monthlyCents: 2500,
yearlyDiscount: 0.15,
},
},
},
growth: {
trialDays: 14,
features: ['20 seats', 'all integrations', 'priority support', 'SSO'],
prices: {
eur: {
stripePriceId: process.env.STRIPE_PRICE_GROWTH_EUR!,
monthlyCents: 9900,
yearlyDiscount: 0.20,
},
usd: {
stripePriceId: process.env.STRIPE_PRICE_GROWTH_USD!,
monthlyCents: 10900,
yearlyDiscount: 0.20,
},
gbp: {
stripePriceId: process.env.STRIPE_PRICE_GROWTH_GBP!,
monthlyCents: 8500,
yearlyDiscount: 0.20,
},
},
},
enterprise: {
trialDays: 0,
features: ['unlimited seats', 'custom integrations', 'dedicated CSM', 'SLA'],
prices: {
eur: {
stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_EUR!,
monthlyCents: 49900,
yearlyDiscount: 0.25,
},
usd: {
stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_USD!,
monthlyCents: 54900,
yearlyDiscount: 0.25,
},
gbp: {
stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_GBP!,
monthlyCents: 42900,
yearlyDiscount: 0.25,
},
},
},
}
export const PlanIdSchema = z.enum(PLAN_IDS)
export const CurrencyIdSchema = z.enum(CURRENCY_IDS)
2. The Checkout Server Action
A typed Server Action creates a Checkout Session. The action reads the PLANS config, picks the price by plan and currency, applies the trial, and returns the URL Stripe expects the browser to navigate to.
// app/[lang]/(public)/pricing/actions.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
import { stripe } from '@/lib/stripe/server'
import { PLANS, PlanIdSchema, CurrencyIdSchema } from '@/lib/billing/plans'
import { createDraftTenant } from '@/lib/tenants/server'
const Input = z.object({
email: z.string().email(),
planId: PlanIdSchema,
currency: CurrencyIdSchema,
interval: z.enum(['monthly', 'yearly']),
})
export async function startCheckout(raw: unknown): Promise<void> {
const parsed = Input.parse(raw)
const plan = PLANS[parsed.planId]
const price = plan.prices[parsed.currency]
const tenant = await createDraftTenant(parsed.email)
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer_email: parsed.email,
client_reference_id: tenant.id,
line_items: [
{
price: price.stripePriceId,
quantity: 1,
},
],
subscription_data:
plan.trialDays > 0
? { trial_period_days: plan.trialDays }
: undefined,
automatic_tax: { enabled: true },
billing_address_collection: 'required',
success_url: `${process.env.SITE_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/pricing`,
metadata: {
tenant_id: tenant.id,
plan_id: parsed.planId,
currency: parsed.currency,
interval: parsed.interval,
},
})
redirect(session.url!)
}
3. The post-Checkout webhook handler
When Checkout completes, Stripe fires checkout.session.completed. The webhook handler is the one that writes the canonical subscription row, not the success page. The browser may never reach /welcome (network error, navigation away); the webhook always does.
// src/lib/stripe/handlers/checkout-completed.ts
import type Stripe from 'stripe'
import { adminClient } from '@/lib/supabase/admin'
export async function handleCheckoutCompleted(
session: Stripe.Checkout.Session,
): Promise<void> {
if (session.mode !== 'subscription') return
if (!session.subscription || !session.client_reference_id) {
throw new Error('checkout session missing required fields')
}
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id
const customerId =
typeof session.customer === 'string'
? session.customer
: session.customer?.id
if (!customerId) throw new Error('checkout session missing customer')
// Promote the draft tenant: link the user, store the Stripe IDs.
const { error: tenantError } = await adminClient
.from('tenants')
.update({
stripe_customer_id: customerId,
status: 'active',
})
.eq('id', session.client_reference_id)
if (tenantError) throw tenantError
// Write the canonical subscription row. The full subscription state will
// arrive in customer.subscription.updated; this seeds the row.
const { error: subError } = await adminClient.from('subscriptions').insert({
id: subscriptionId,
tenant_id: session.client_reference_id,
stripe_customer_id: customerId,
plan_id: session.metadata?.plan_id ?? 'unknown',
status: 'trialing',
created_at: new Date().toISOString(),
})
if (subError) throw subError
}
4. The Customer Portal helper
When the customer wants to manage their subscription, the application opens a Stripe-hosted portal. We never recreate Stripe's UI; we open a signed session and redirect.
// app/[lang]/(app)/account/billing/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { stripe } from '@/lib/stripe/server'
import { createServerClient } from '@/lib/supabase/server'
import { getServerSession } from '@/lib/auth/server'
export async function openBillingPortal(): Promise<void> {
const session = await getServerSession()
if (!session) throw new Error('unauthorised')
const supabase = await createServerClient()
const { data: tenant, error } = await supabase
.from('tenants')
.select('stripe_customer_id')
.eq('id', session.tenantId)
.single()
if (error || !tenant?.stripe_customer_id) {
throw new Error('no billing customer found')
}
const portal = await stripe.billingPortal.sessions.create({
customer: tenant.stripe_customer_id,
return_url: `${process.env.SITE_URL}/account`,
})
redirect(portal.url)
}
5. What this composes
The PLANS config drives every billing surface. Checkout reads it and creates a session. The webhook writes the resulting subscription row. The Customer Portal opens for self-service. Each piece is one focused file; the application code does not need to know about Stripe's API surface beyond these four entry-points.
Production billing is not a Checkout button. It is the composition of these four files, plus the webhook router from the Stripe stack page, plus the nightly reconciliation job. Three thousand lines of work, two to four weeks if you let the SDK do its job, an unfixable mess if you treat Stripe as something to wrap.