Billing & Subscriptions
How Next Starter runs Stripe billing and subscriptions via the Better Auth Stripe plugin: plans, checkout, the customer portal, webhooks, and lifecycle emails.
How it works
Billing runs on Stripe, wired in through @better-auth/stripe. lib/auth.ts registers the plugin, and the plugin does most of the work. It creates a Stripe customer when a user signs up, opens Checkout pages, mounts the webhook handler, and exposes the customer portal. When a subscription webhook arrives, it saves the new subscription state into the Subscription table. Every page the user actually types a card into is hosted by Stripe, so there's no card data and no payment UI anywhere in the codebase.
Because the plan lives in your database, you can read it without calling Stripe on every request. On the client, SubscriptionProvider (components/subscription-provider.tsx) calls authClient.subscription.list() once the session resolves and hands the result to the useSubscription() hook, along with a refetch() for after a change. It wraps the dashboard from app/dashboard/layout.tsx, so the sidebar, the dashboard home and the billing page share one fetch.
lib/auth.ts also builds the Stripe client itself and pins an API version: new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: "2026-06-24.dahlia" }). That string is the default for version 22 of the stripe SDK. Bump it when you upgrade the SDK, not before.
There is no STRIPE_PUBLISHABLE_KEY in this template. All Stripe interaction is server-side or through Stripe's hosted Checkout and portal pages. You need only the secret key, the webhook secret, and the four price IDs.
Plans
Two paid plans, Plus and Pro, and a free Starter tier. Only the paid two appear in the plans array in lib/auth.ts, where each carries a priceId and an annualDiscountPriceId read from env vars, plus a limits object of projects and storage. Starter is implicit. A user with no subscription is on it.
Display prices, descriptions and feature lists live in PRICING_TIERS in lib/pricing.ts, which is where you'll find the actual numbers for all three tiers. So each paid plan lives in two files. Add or rename one and both need the matching change.
Code identifies plans by tier id, free, plus or pro. That's what activeSubscription.plan holds, what a plan button sends back when a user picks a tier, and what getUserPlan() returns.
lib/validations/env.ts checks the four price env vars. Each must start with price_.
Checkout, cancel & portal
The billing page (app/dashboard/billing/) drives the whole flow through the auth client. Its methods hang off authClient.subscription, and lib/auth-client.ts exports authClient. Each method below talks to Stripe for you:
- Upgrade / switch plan:
authClient.subscription.upgrade({ plan, annual, successUrl, cancelUrl, returnUrl }). When the user has no active or trialing subscription this opens Stripe Checkout. For someone who already pays, the plugin skips Checkout and returns a Stripe portal confirmation screen for the plan change instead, sosuccessUrlandcancelUrlonly come into play on the Checkout path. PasssubscriptionIdto say which subscription to switch. - Cancel: selecting Starter calls
authClient.subscription.cancel({ subscriptionId, returnUrl }), which opens a Stripe portal session in cancel mode so the user confirms there. With the portal's cancellation mode set to end of period, the subscription stays active until the paid period ends (cancelAtPeriodEnd), so the user keeps access until then. While that's pending, the Starter button in the pricing table is disabled and reads "Cancellation pending". - Restore: if a cancellation is pending,
authClient.subscription.restore({ subscriptionId })undoes it before the period ends.current-plan-card.tsxshows a "Restore Subscription" button when it seescancelAtPeriodEndorcancelAt. - Customer portal: "Manage Billing & Invoices" calls
authClient.subscription.billingPortal({ returnUrl, disableRedirect: true }). WithdisableRedirect: truethe method returns the portal URL instead of redirecting, so the page canrouter.push()it itself. What the portal offers there (card updates, invoices, plan changes) depends on your customer portal configuration in the Stripe dashboard.
Cancel and restore always pass subscriptionId: activeSubscription.stripeSubscriptionId, upgrade passes it only when there is an active subscription, and the portal call takes none.
The billing page isn't the only way into checkout. The onboarding plan step (app/onboarding/plan-step.tsx) calls the same authClient.subscription.upgrade, just with successUrl: "/onboarding/complete" and cancelUrl: "/onboarding". See Onboarding for how a plan picked on the marketing site survives sign-up.
getCheckoutSessionParams in lib/auth.ts configures Checkout pages once: promotion codes enabled, tax-ID collection on, billing address required, and customer name and address synced back to Stripe. Stripe Tax (automatic_tax) is off. Turn it on in that callback once you activate Stripe Tax in your dashboard.
Account deletion depends on this too. The beforeDelete hook in lib/auth.ts refuses to delete an account while any subscription is active, trialing or past_due, so the user has to cancel and let the period run out first. A pending cancellation doesn't unblock it. The admin delete path (/admin/remove-user) skips that hook, so a hooks.before middleware in the same file runs the same check there. A past_due user can't use the in-app cancel, since that endpoint only accepts active and trialing, but the billing page sends them to the Stripe customer portal instead, and a cancellation made there clears the block once the webhook records it.
Reading the current plan
Inside the dashboard, use the hook. Don't refetch from Stripe:
const { activeSubscription, planName, isLoading } = useSubscription();activeSubscription is the first subscription the provider got back with status active, trialing, or past_due. planName is the capitalized plan name, and defaults to "Starter" when there is no subscription.
On the server there's a separate helper, getUserPlan() in lib/server/auth-helpers.ts. It queries Prisma directly and returns a PlanId, which is "free" | "plus" | "pro", falling back to "free" for anyone without a subscription. The dashboard home page uses it.
A past-due subscription reads as Starter on the client. The plugin's /subscription/list endpoint filters to active and trialing, so a past_due row never reaches the browser, and getUserPlan() filters the same way on the server. The server catches it instead: hasPastDueSubscription() in lib/server/auth-helpers.ts queries Prisma directly for a past_due row. It's wrapped in React's cache(), so the dashboard layout and the billing page share one query per request. The layout uses it to mount past-due-banner.tsx. app/dashboard/billing/page.tsx passes the result down as isPastDue, and current-plan-card.tsx renders a "Payment past due" card with a button into the customer portal in place of the Starter copy, while billing-content.tsx hides the plans table until the payment is sorted out. To keep past-due customers on their plan, widen the filter in getUserPlan() and read the row from your own query rather than the hook.
The plugin owns the Subscription table's shape and the Prisma model mirrors it, so read prisma/schema.prisma for the columns. Two of the names give nothing away. billingInterval copies Stripe's recurring interval for the price the user is on, and the plugin reads it back when listing subscriptions: "year" picks the plan's annualDiscountPriceId (falling back to priceId if the plan has none), anything else picks priceId. stripeScheduleId holds a Stripe subscription schedule. Passing scheduleAtPeriodEnd: true to upgrade creates one, deferring a plan change to the end of the period instead of prorating it now. This template never passes that flag, so deferred downgrades start there.
Webhooks & lifecycle emails
The plugin mounts its webhook handler inside the Better Auth catch-all route (app/api/auth/[...all]/route.ts) at:
/api/auth/stripe/webhookYou don't write this route. The plugin verifies each request's signature against STRIPE_WEBHOOK_SECRET so only Stripe can call it, then handles the event. Four types get their own handler, which creates or updates the Subscription row: checkout.session.completed, customer.subscription.created, customer.subscription.updated and customer.subscription.deleted. Your onEvent callback then fires for every event the endpoint receives, handled or not, and that's how the invoice emails below work.
On top of that, lib/auth.ts adds two callbacks that send emails when subscription events happen. Templates live in lib/email/, and lib/email/index.tsx exports their helper functions:
| Trigger | Callback | |
|---|---|---|
Checkout completed (checkout.session.completed) | onSubscriptionComplete | getSubscriptionStartedEmail: welcome, next billing date, and an invoice link when Stripe has one |
Renewal paid (invoice.payment_succeeded, subscription_cycle) | onEvent | getSubscriptionRenewedEmail |
Payment failed (invoice.payment_failed) | onEvent | getPaymentFailedEmail: includes a fresh billing-portal link to fix the card |
When a renewal charge fails, Stripe emits invoice.payment_failed and onEvent sends the recovery email with a fresh portal link. The warning bar in components/dashboard/past-due-banner.tsx is the other half: app/dashboard/layout.tsx renders it whenever hasPastDueSubscription() finds a past_due row. What stays on Starter is the plan itself, for the reason in the callout above.
Local webhook testing
Stripe can't reach localhost, so use the Stripe CLI to forward events to your dev server. Run stripe listen, then paste the whsec_... it prints into STRIPE_WEBHOOK_SECRET in .env:
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook
stripe trigger checkout.session.completedWhat you can change
Change a plan's price: create the new price in Stripe, copy its price_... ID into the matching STRIPE_PRICE_* env var, and update the display amount in lib/pricing.ts. No code change in lib/auth.ts needed; it reads the env vars.
Rename or add a plan: add a plan object (with name, priceId, annualDiscountPriceId, limits) to the plans array in lib/auth.ts, add a matching tier in lib/pricing.ts, and add the new price env vars (define them in lib/validations/env.ts too). The plan name must match the id used by the pricing table and the plan value returned in subscriptions. Four more places hard-code the ids: the PlanId union and the plan check in getUserPlan() (lib/server/auth-helpers.ts), the plan prop type in app/dashboard/dashboard-content.tsx, PLAN_HIERARCHY in components/pricing/pricing-table.tsx, which decides whether a button says Upgrade or Downgrade, and the TIERS map in components/dashboard/plan-card.tsx, the paid-tier card on the dashboard home, which only knows the two paid ids. Skip the first and third and a new plan reads as free on the server and gets the wrong button label.
Add a feature gate: extend the limits object on the plan in lib/auth.ts. The plugin copies it onto every subscription that /subscription/list returns, so in the dashboard it's on activeSubscription.limits. There are no gating helpers in lib/pricing.ts, and a user on Starter has no subscription the hook can see, so supply that tier's limits yourself:
const { activeSubscription } = useSubscription();
const projects = (activeSubscription?.limits?.projects as number | undefined) ?? 3; // Starter
if (projects !== -1 && currentProjectCount >= projects) {
// block or prompt to upgrade; limit of -1 means unlimited
}Change checkout behavior (tax, promo codes, collected fields): edit getCheckoutSessionParams in lib/auth.ts.
Change a lifecycle email: edit the template in lib/email/ (subscription-started.tsx, subscription-renewed.tsx, payment-failed.tsx) or its trigger logic in the onSubscriptionComplete / onEvent callbacks in lib/auth.ts.
Where it lives
| Path | Responsibility |
|---|---|
lib/auth.ts | Stripe plugin config, plans, checkout params, lifecycle email callbacks |
lib/pricing.ts | Display prices, features, formatPrice / formatCurrency |
lib/validations/env.ts | Validates Stripe secret, webhook secret, four price IDs |
lib/auth-client.ts | Exports authClient; authClient.subscription has upgrade / cancel / restore / billingPortal / list |
components/subscription-provider.tsx | useSubscription() hook + provider (mounted in app/dashboard/layout.tsx) |
app/dashboard/billing/ | Billing page (billing-content.tsx) and the current-plan card |
components/pricing/pricing-table.tsx | The plan cards, monthly/yearly toggle, and button states. Shared by the billing page, the marketing pricing page, and onboarding |
components/dashboard/past-due-banner.tsx | Dashboard-wide warning bar for past_due subscriptions, shown by app/dashboard/layout.tsx via hasPastDueSubscription() |
app/api/auth/[...all]/route.ts | Hosts the plugin's /api/auth/stripe/webhook handler |
lib/email/ | Subscription email templates |
Required environment variables
STRIPE_SECRET_KEY=sk_test_... # use a test key in development
STRIPE_WEBHOOK_SECRET=whsec_... # from Stripe CLI or dashboard
STRIPE_PRICE_PLUS_MONTHLY=price_...
STRIPE_PRICE_PLUS_ANNUAL=price_...
STRIPE_PRICE_PRO_MONTHLY=price_...
STRIPE_PRICE_PRO_ANNUAL=price_...Onboarding
How the post-signup onboarding wizard works in Next Starter: capture a profile and plan, then gate the dashboard behind the User.onboardingComplete flag.
How Next Starter sends transactional email: React Email templates render to HTML and go out through SMTP2Go via one sendEmail function you can preview and swap.