Stripe billing in 200 lines
Pricing tiers, customer portal, recurring billing, webhooks, SCA. The smallest amount of Next.js code that buys a real, live, production billing flow. The version I actually ship.
Why Stripe still wins
Every six months somebody publishes a thread claiming Stripe has been dethroned by Lemon Squeezy, Paddle, Polar, or whichever Merchant of Record is fashionable that month. I have built on most of them. For a UK solo founder selling globally, Stripe still wins on the things that actually matter: the docs are the best in the industry, the dashboard is the only one that does not lie to you, the test mode is a perfect mirror of live, and SCA is handled invisibly. The only honest reason to pick a Merchant of Record over Stripe is if you genuinely do not want to deal with VAT MOSS, and even then Stripe Tax now covers most of that for a small percentage on top.
The pitch of this playbook is simple. People look at Stripe and see a wall of products: Billing, Checkout, Invoicing, Connect, Issuing, Terminal, Sigma, Atlas. You do not need any of them except Checkout, Billing, and the Customer Portal. Those three, wired together correctly, give you a production grade subscription business in about 200 lines of Next.js code. Everything else is either a footgun or a feature you do not need yet.
The Stripe API is small. The Stripe surface area is huge. Almost every billing horror story is somebody using the wrong product for the job.
Pick a pricing model
Before you write a line of code, lock in your pricing model. Stripe supports three primitives, and the right answer is almost always one of them, not a mix.
Flat recurring. A fixed amount per month or per year for the whole product. This is the right default for a B2B SaaS under £50k MRR. It is the easiest to model, the easiest to forecast, and the easiest to refund. Most of my products ship on flat recurring.
Per seat. Quantity times unit price, billed monthly. Use this only when the user genuinely buys seats and is happy to count them. The downside is the proration maths gets noisy on mid cycle seat changes, and customers will complain about a £3.42 line on next month's invoice.
Per usage (metered). You report usage events; Stripe rolls them up at period end. The right choice for anything that looks like an API gateway or a token bucket. The wrong choice for a typical SaaS, because the customer cannot predict their bill and finance teams refuse to sign POs against unpredictable numbers.
My rule of thumb: ship flat recurring with two tiers, Starter and Pro, and an annual option that gives 2 months free. That covers 90 percent of customers and you can add a metered top up later without rewriting anything.
Products, prices, env
In the Stripe dashboard, create one Product per tier (Starter, Pro), and one Price per billing cadence (monthly, annual). You end up with four Price IDs. Copy them into your env, alongside your secret key and webhook signing secret.
bash# .env.local STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # Price IDs from the dashboard STRIPE_PRICE_STARTER_MONTHLY=price_1Q... STRIPE_PRICE_STARTER_ANNUAL=price_1Q... STRIPE_PRICE_PRO_MONTHLY=price_1Q... STRIPE_PRICE_PRO_ANNUAL=price_1Q... # Public app URL, for redirect URLs NEXT_PUBLIC_APP_URL=http://localhost:3000
Install the SDK and the CLI:
bashpnpm add stripe # Mac brew install stripe/stripe-cli/stripe stripe login
One shared Stripe client, used everywhere on the server. Never on the client.
ts// lib/stripe.ts import Stripe from 'stripe' export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-09-30.acacia', typescript: true, appInfo: { name: 'sarmalinux', version: '1.0.0' }, })
The Checkout Session server action
Checkout is Stripe's hosted payment page. It handles cards, Apple Pay, Google Pay, Link, SEPA, and SCA without you writing a single line of front end. You hand it a price ID and a redirect URL; it hands the customer back to you with a session ID.
One server action, called from a pricing page button. It looks up or creates a Stripe customer for the signed in user, opens a session, and returns the URL.
ts// app/actions/checkout.ts 'use server' import { redirect } from 'next/navigation' import { stripe } from '@/lib/stripe' import { getCurrentUser } from '@/lib/auth' import { db } from '@/lib/db' export async function startCheckout(priceId: string) { const user = await getCurrentUser() if (!user) redirect('/login') // Reuse the Stripe customer if we have one let customerId = user.stripeCustomerId if (!customerId) { const customer = await stripe.customers.create({ email: user.email, metadata: { userId: user.id }, }) customerId = customer.id await db.user.update({ where: { id: user.id }, data: { stripeCustomerId: customerId }, }) } const session = await stripe.checkout.sessions.create({ mode: 'subscription', customer: customerId, line_items: [{ price: priceId, quantity: 1 }], success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`, allow_promotion_codes: true, automatic_tax: { enabled: true }, subscription_data: { metadata: { userId: user.id }, }, }) if (!session.url) throw new Error('Stripe did not return a session URL') redirect(session.url) }
Two details that matter. First, always pass the customer ID, not just the email. If you pass only the email, Stripe creates a fresh anonymous customer every time and your dashboard fills with duplicates. Second, always stamp the userId into metadata on both the customer and the subscription. You will thank yourself when you are debugging a webhook six months later.
The webhook handler
Webhooks are the only honest source of truth in a billing system. The browser redirect after Checkout is a UX nicety, not a source of truth. Treat the success page as a "thanks, your account will activate in a moment" message, and let the webhook do the actual work.
Three events do 95 percent of what you need: checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted. Verify the signature, switch on the type, write to your DB.
ts// app/api/stripe/webhook/route.ts import { NextRequest, NextResponse } from 'next/server' import { stripe } from '@/lib/stripe' import { db } from '@/lib/db' import type Stripe from 'stripe' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const SECRET = process.env.STRIPE_WEBHOOK_SECRET! export async function POST(req: NextRequest) { const body = await req.text() // raw body, not JSON const sig = req.headers.get('stripe-signature') if (!sig) return new NextResponse('Missing signature', { status: 400 }) let event: Stripe.Event try { event = stripe.webhooks.constructEvent(body, sig, SECRET) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' return new NextResponse(`Webhook signature failed: ${message}`, { status: 400 }) } // Idempotency: skip if we have already processed this event.id const seen = await db.webhookEvent.findUnique({ where: { id: event.id } }) if (seen) return NextResponse.json({ received: true, duplicate: true }) await db.webhookEvent.create({ data: { id: event.id, type: event.type } }) switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session const customerId = session.customer as string const subscriptionId = session.subscription as string const sub = await stripe.subscriptions.retrieve(subscriptionId) await syncSubscription(customerId, sub) break } case 'customer.subscription.updated': case 'customer.subscription.created': { const sub = event.data.object as Stripe.Subscription await syncSubscription(sub.customer as string, sub) break } case 'customer.subscription.deleted': { const sub = event.data.object as Stripe.Subscription await db.user.update({ where: { stripeCustomerId: sub.customer as string }, data: { plan: 'free', subscriptionStatus: 'canceled' }, }) break } } return NextResponse.json({ received: true }) } async function syncSubscription(customerId: string, sub: Stripe.Subscription) { const priceId = sub.items.data[0]?.price.id const plan = priceToPlan(priceId) await db.user.update({ where: { stripeCustomerId: customerId }, data: { plan, subscriptionStatus: sub.status, currentPeriodEnd: new Date(sub.current_period_end * 1000), stripePriceId: priceId, }, }) } function priceToPlan(priceId: string | undefined): 'free' | 'starter' | 'pro' { if (!priceId) return 'free' if (priceId === process.env.STRIPE_PRICE_PRO_MONTHLY) return 'pro' if (priceId === process.env.STRIPE_PRICE_PRO_ANNUAL) return 'pro' if (priceId === process.env.STRIPE_PRICE_STARTER_MONTHLY) return 'starter' if (priceId === process.env.STRIPE_PRICE_STARTER_ANNUAL) return 'starter' return 'free' }
Two non negotiables in that handler. Read the body as text, not JSON, because constructEvent needs the raw bytes to verify the signature. And store every processed event ID, so a Stripe retry never double credits an account.
Mount the Customer Portal
The Customer Portal is the single biggest reason to ship on Stripe instead of rolling your own. It is a Stripe hosted page that handles upgrades, downgrades, cancellations, invoice history, payment method changes, and address updates. It is free. You do not write any of it.
Enable it once in the dashboard under Settings, Billing, Customer Portal. Tick the features you want customers to self serve. Then write one server action.
ts// app/actions/portal.ts 'use server' import { redirect } from 'next/navigation' import { stripe } from '@/lib/stripe' import { getCurrentUser } from '@/lib/auth' export async function openPortal() { const user = await getCurrentUser() if (!user?.stripeCustomerId) redirect('/pricing') const session = await stripe.billingPortal.sessions.create({ customer: user.stripeCustomerId, return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`, }) redirect(session.url) }
That is the whole feature. A "Manage billing" button on the account page calls openPortal, the user lands on Stripe, does whatever they want, and comes back. Any changes they make fire the same subscription webhooks above, so your DB stays in sync without extra code.
The entitlements pattern
Never gate features off the Stripe API directly. Never call stripe.subscriptions.retrieve in a hot path. Sync the plan to your own DB on every webhook, and gate features off the DB column. That is the entitlements pattern.
On every user row, store: plan (free, starter, pro), subscriptionStatus (active, trialing, past_due, canceled), and currentPeriodEnd. Every feature check is then a one liner against your own DB, with no network round trip to Stripe.
ts// lib/entitlements.ts import { db } from '@/lib/db' const LIMITS = { free: { projects: 1, seats: 1, apiCallsPerMonth: 1_000 }, starter: { projects: 5, seats: 3, apiCallsPerMonth: 50_000 }, pro: { projects: 50, seats: 25, apiCallsPerMonth: 1_000_000 }, } as const export type Plan = keyof typeof LIMITS export async function getEntitlements(userId: string) { const user = await db.user.findUniqueOrThrow({ where: { id: userId } }) const isActive = user.subscriptionStatus === 'active' || user.subscriptionStatus === 'trialing' const plan: Plan = isActive ? (user.plan as Plan) : 'free' return { plan, ...LIMITS[plan] } } export async function assertCanCreateProject(userId: string) { const ent = await getEntitlements(userId) const count = await db.project.count({ where: { ownerId: userId } }) if (count >= ent.projects) { throw new Error(`Plan limit reached, ${ent.plan} allows ${ent.projects} projects`) } }
The clean separation is the whole point. Stripe owns money. Your DB owns features. The webhook is the only bridge.
Testing locally
The Stripe CLI forwards live webhook events to your local dev server. One command, leave it running in a second terminal.
bashstripe listen --forward-to localhost:3000/api/stripe/webhook # It prints a webhook signing secret for the session. # Paste that into .env.local as STRIPE_WEBHOOK_SECRET, restart pnpm dev. # In a third terminal you can fire test events on demand: stripe trigger checkout.session.completed stripe trigger customer.subscription.updated stripe trigger customer.subscription.deleted
Use the magic card numbers from the Stripe docs. 4242 4242 4242 4242 always succeeds. 4000 0025 0000 3155 forces a 3D Secure challenge so you can verify SCA flows. 4000 0000 0000 9995 fails with insufficient funds, so you can test the past_due path.
One workflow tip. Keep a fixed test customer in the dashboard, top of the list, called "Smoke test". Use their email when you sign up new test users locally. That way your test data is grouped and you can wipe it in one go.
Going live
A checklist I run through before flipping a project from test mode to live.
Swap keys. Replace the sk_test and pk_test values with sk_live and pk_live in production env only. Never put live keys in .env.local.
Register the production webhook. In the dashboard, Developers, Webhooks, add an endpoint pointing at https://yourdomain.com/api/stripe/webhook. Subscribe to the three events above and copy the new signing secret to production env. The signing secret from stripe listen is different from the live one; do not mix them up.
Enable Stripe Tax. Settings, Tax. Register the jurisdictions you ship to. For a UK solo founder, the UK is automatic; add the EU OSS scheme if you sell to consumers in the EU. Stripe Tax adds 0.5 percent on top of the standard fee, and saves you a tax accountant.
Turn on Radar. Settings, Radar. The default rules block obvious card testing and CVC mismatches. For a SaaS, the default ruleset is enough; do not over tune it on day one.
Customise the receipt and invoice email. Brand settings, upload a logo, set the colour. Customers get receipt emails directly from Stripe, with your brand on them, and you do not have to wire up Resend for it.
Test a real card. Make a £1 product, charge your own card, refund it. Verify the webhook hit production, verify the DB updated, verify the refund flowed back. Costs 30p in fees and buys you confidence.
Pitfalls
Next.js route handlers do not auto parse, so this only bites people who try to be clever. Call req.text() and feed the raw string to constructEvent. If you call req.json() first, the signature check fails with no useful error.
Stripe retries webhooks aggressively on a non 2xx response. Without a webhook_events table keyed by event.id, a slow handler will double credit accounts, double email customers, and double bill your own usage tracking.
When a user downgrades in the Customer Portal, the change can either take effect immediately with proration or at period end with no proration. Pick one in Portal settings and stick to it. Mid cycle downgrades that refund money will be the most expensive support ticket of your week.
If your product price is in GBP but the customer is in the US and Stripe Tax is on, you get an immediate failure. Decide your billing currency once. For a UK solo founder selling globally, GBP is the cleanest answer; let Stripe present the local equivalent at checkout.
Without a userId stamped onto the customer and the subscription, your webhook has to do a lookup by customer ID to find the user. That works until somebody deletes a user record and you can no longer resolve the back reference. Stamp it, always.
The success_url fires before the webhook in some flows, especially with async payment methods like SEPA. If the success page reads the DB and shows the new plan, the customer sees stale state. Show a "we are activating your account" message and refresh from the server when the webhook lands.
Wrap up
That is the whole thing. One Checkout server action, one webhook handler, one Portal server action, one entitlements helper. Roughly 200 lines of code, plus whatever pricing UI you decide to build. It runs on the free tier of Stripe forever, no monthly platform fee, just the standard per transaction percentage. The dashboard handles refunds, disputes, invoices, tax, fraud, receipt emails, and SCA without you writing any of it.
Resist the urge to build the billing UI yourself. The Customer Portal is genuinely the best self serve billing UI on the market, and it is free. Every hour you spend building your own upgrade flow is an hour not spent on the actual product. Ship the 200 lines, plug the webhook into your DB, and get back to the thing customers are paying you for.
Want this done for you?
If you would rather skip the YAK shave and have someone who has done this fifty times set it up properly, that is what I do for a living.
Start a project