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.
Stripe in plain English
New to Stripe? Four ideas cover everything on this page:
- Subscription: a recurring charge. The user pays every month or year until they cancel. Stripe runs the charges; you store the result.
- Checkout: a payment page hosted by Stripe. You send the user there to enter their card. You never touch card details.
- Customer portal: another Stripe-hosted page where a user manages an existing subscription: update card, view invoices, cancel.
- Webhook: a message Stripe sends to your server when something happens, like a payment succeeding or a subscription being canceled. This is how your database learns about changes.
You only run hosted pages and listen for webhooks, so there is no card data or payment UI in your code.
How it works
Billing runs on Stripe, wired in through @better-auth/stripe. The plugin is registered in lib/auth.ts and does most of the work for you. It creates a Stripe customer when a user signs up, opens Checkout pages, mounts the webhook handler, and exposes the customer portal. When a webhook arrives, it saves the new subscription state into the Subscription table.
That stored state matters. Because the plan lives in your database, you can read a user's plan without calling Stripe on every request. Two layers make it available:
- Server / config: plans and their Stripe price IDs are defined in the
stripe()plugin inlib/auth.ts. - Client:
SubscriptionProvider(components/subscription-provider.tsx) loads the user's subscriptions once withclient.subscription.list()and shares them through theuseSubscription()hook. It's mounted inapp/dashboard/layout.tsx, so every dashboard page can read the current plan.
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
There are two paid plans, Plus and Pro, plus a free Starter tier. Starter is implicit: a user with no subscription is on Starter. The paid plans are defined in the stripe() plugin in lib/auth.ts, and their price IDs come from env vars. The marketing copy, prices, and feature lists live separately in lib/pricing.ts.
| Plan | Defined in | Monthly price ID | Annual price ID | Limits |
|---|---|---|---|---|
| Starter | implicit (no subscription) | — | — | 3 projects, 1 GB |
| Plus | lib/auth.ts | STRIPE_PRICE_PLUS_MONTHLY | STRIPE_PRICE_PLUS_ANNUAL | 10 projects, 25 GB |
| Pro | lib/auth.ts | STRIPE_PRICE_PRO_MONTHLY | STRIPE_PRICE_PRO_ANNUAL | unlimited (-1) |
Each plan in lib/auth.ts carries a limits object (projects, storage) used for feature gating. lib/pricing.ts carries a richer limits shape plus features, price, and featured for the pricing table. These are two separate definitions, so keep them in sync when you change a plan.
The four price env vars
Validated in lib/validations/env.ts (each must start with price_):
| Env var | Plan & cycle |
|---|---|
STRIPE_PRICE_PLUS_MONTHLY | Plus, monthly |
STRIPE_PRICE_PLUS_ANNUAL | Plus, annual |
STRIPE_PRICE_PRO_MONTHLY | Pro, monthly |
STRIPE_PRICE_PRO_ANNUAL | Pro, annual |
Checkout, cancel & portal
The billing page (app/dashboard/billing/) drives the whole flow through the auth client. The subscription object is exported from lib/auth-client.ts (also available as client.subscription). Each method below talks to Stripe for you:
- Upgrade / switch plan:
client.subscription.upgrade({ plan, annual, successUrl, cancelUrl, returnUrl })sends the user to Stripe Checkout. PasssubscriptionIdto switch an existing subscription instead of starting a new one. - Cancel: selecting Starter calls
client.subscription.cancel({ subscriptionId }), which sends the user to the Stripe portal to confirm. The subscription then stays active until the end of the paid period (cancelAtPeriodEnd), so the user keeps access until then. - Restore: if a cancellation is pending,
client.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
client.subscription.billingPortal({ returnUrl, disableRedirect: true }). WithdisableRedirect: truethe method returns the portal URL instead of redirecting, so the page canrouter.push()it itself. In the portal the user updates their card, views invoices, and changes plans.
The billing page passes activeSubscription.stripeSubscriptionId as the subscriptionId in these calls.
Checkout pages are configured once in getCheckoutSessionParams in lib/auth.ts: 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.
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 with status active, trialing, or past_due. A past-due subscription still counts as the active plan, so the user keeps access while Stripe retries the payment. planName is the capitalized plan name, and defaults to "Starter" when there is no subscription.
Webhooks & lifecycle emails
A webhook is how Stripe tells your server about events. 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 checks that each request really came from Stripe (it verifies the signature using STRIPE_WEBHOOK_SECRET), then saves checkout.session.completed, customer.subscription.*, and related events into the Subscription table for you.
On top of that, lib/auth.ts adds two callbacks that send emails when subscription events happen. Templates live in lib/email/, and their helper functions are exported from lib/email/index.tsx:
| Trigger | Callback | |
|---|---|---|
| New subscription active | onSubscriptionComplete | getSubscriptionStartedEmail: welcome, invoice link, and next billing date |
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 |
Past-due handling has two parts. When a renewal charge fails, Stripe retries it and emits invoice.payment_failed, which sends the recovery email. Meanwhile the subscription's past_due status keeps the user on their plan until Stripe gives up and cancels it.
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.local:
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.
Add a feature gate: extend the limits object on the plan in lib/auth.ts and the PlanLimits type in lib/pricing.ts, then gate access with the helpers in lib/pricing.ts:
import { getPlanLimits, isUnderLimit } from "@/lib/pricing";
const { projects } = getPlanLimits(activeSubscription?.plan ?? "free");
if (!isUnderLimit(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, limits, gating helpers |
lib/validations/env.ts | Validates Stripe secret, webhook secret, four price IDs |
lib/auth-client.ts | Exports subscription (upgrade / cancel / restore / billingPortal / list) |
components/subscription-provider.tsx | useSubscription() hook + provider (mounted in app/dashboard/layout.tsx) |
app/dashboard/billing/ | Billing page, current-plan card, pricing table wiring |
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.