Next Starter Logo

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 in lib/auth.ts.
  • Client: SubscriptionProvider (components/subscription-provider.tsx) loads the user's subscriptions once with client.subscription.list() and shares them through the useSubscription() hook. It's mounted in app/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.

PlanDefined inMonthly price IDAnnual price IDLimits
Starterimplicit (no subscription)3 projects, 1 GB
Pluslib/auth.tsSTRIPE_PRICE_PLUS_MONTHLYSTRIPE_PRICE_PLUS_ANNUAL10 projects, 25 GB
Prolib/auth.tsSTRIPE_PRICE_PRO_MONTHLYSTRIPE_PRICE_PRO_ANNUALunlimited (-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 varPlan & cycle
STRIPE_PRICE_PLUS_MONTHLYPlus, monthly
STRIPE_PRICE_PLUS_ANNUALPlus, annual
STRIPE_PRICE_PRO_MONTHLYPro, monthly
STRIPE_PRICE_PRO_ANNUALPro, 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. Pass subscriptionId to 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.tsx shows a "Restore Subscription" button when it sees cancelAtPeriodEnd or cancelAt.
  • Customer portal: "Manage Billing & Invoices" calls client.subscription.billingPortal({ returnUrl, disableRedirect: true }). With disableRedirect: true the method returns the portal URL instead of redirecting, so the page can router.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/webhook

You 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:

TriggerCallbackEmail
New subscription activeonSubscriptionCompletegetSubscriptionStartedEmail: welcome, invoice link, and next billing date
Renewal paid (invoice.payment_succeeded, subscription_cycle)onEventgetSubscriptionRenewedEmail
Payment failed (invoice.payment_failed)onEventgetPaymentFailedEmail: 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.completed

What 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

PathResponsibility
lib/auth.tsStripe plugin config, plans, checkout params, lifecycle email callbacks
lib/pricing.tsDisplay prices, features, limits, gating helpers
lib/validations/env.tsValidates Stripe secret, webhook secret, four price IDs
lib/auth-client.tsExports subscription (upgrade / cancel / restore / billingPortal / list)
components/subscription-provider.tsxuseSubscription() hook + provider (mounted in app/dashboard/layout.tsx)
app/dashboard/billing/Billing page, current-plan card, pricing table wiring
app/api/auth/[...all]/route.tsHosts 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_...

On this page