Billing & Subscriptions
How Next Starter handles Stripe subscriptions, webhooks, and per-plan access control via the Better Auth Stripe plugin.
Overview
Billing is handled through Stripe, integrated via the Better Auth Stripe plugin. Subscription state stays in sync with the session, so you can check a user's plan server-side without an extra database query.
Stripe customers are created automatically on sign-up. Subscriptions are stored in the Subscription table and kept in sync via webhooks.
Pricing Tiers
All plan definitions live in lib/pricing.ts. This is the single source of truth. When prices change, update this file and the corresponding Stripe price IDs in lib/auth.ts.
// lib/pricing.ts
export const PRICING_TIERS: PricingTier[] = [
{
name: "Starter",
id: "free",
href: "/auth/register",
price: { monthly: 0, annually: 0 },
description: "Perfect for getting started and exploring basic features at your own pace.",
features: ["3 projects", "1 GB storage", "Basic analytics", "Community support"],
featured: false,
},
{
name: "Plus",
id: "plus",
href: "/auth/register?plan=plus",
price: { monthly: 15, annually: 10 },
description: "Enhanced features for growing individuals and small teams.",
features: ["10 projects", "25 GB storage", "Advanced analytics", "24-hour support response time", "Priority email support"],
featured: true,
},
{
name: "Pro",
id: "pro",
href: "/auth/register?plan=pro",
price: { monthly: 30, annually: 20 },
description: "Advanced features for professionals and growing businesses.",
features: ["Unlimited projects", "Unlimited storage", "Advanced analytics", "1-hour support response time", "Premium support", "Custom integrations"],
featured: false,
},
];Stripe Plugin Configuration
The Stripe plugin is registered in lib/auth.ts alongside your plan definitions:
// lib/auth.ts (excerpt)
import { stripe } from "@better-auth/stripe";
import Stripe from "stripe";
const stripeClient = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: "2025-11-17.clover",
});
export const auth = betterAuth({
// ...
plugins: [
stripe({
stripeClient,
stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET,
createCustomerOnSignUp: true,
subscription: {
enabled: true,
plans: [
{
name: "plus",
priceId: "price_xxx", // Stripe monthly price ID
annualDiscountPriceId: "price_xxx", // Stripe annual price ID
limits: { projects: 10, storage: 25 },
},
{
name: "pro",
priceId: "price_xxx",
annualDiscountPriceId: "price_xxx",
limits: { projects: -1, storage: -1 }, // -1 = unlimited
},
],
getCheckoutSessionParams: async () => ({
params: {
tax_id_collection: { enabled: true },
billing_address_collection: "required",
customer_update: { name: "auto", address: "auto" },
},
}),
onSubscriptionComplete: async ({ subscription, stripeSubscription, plan }) => {
// Fires after checkout.session.completed. Send a confirmation
// email with the invoice link and next billing date here.
},
},
}),
nextCookies(), // Must be last plugin
],
});Checkout Session Params
The getCheckoutSessionParams callback configures every checkout session the plugin creates. Next Starter sets three options:
tax_id_collection: customers can enter a VAT or other tax ID at checkout, which Stripe records on the invoice.billing_address_collection: "required": Stripe requires a billing address before the customer can complete payment.customer_update: the address and name collected at checkout are saved back to the Stripe customer record automatically.
Updating Price IDs
When you create new prices in your Stripe dashboard:
- Copy the price ID from Stripe (format:
price_...) - Update
priceIdorannualDiscountPriceIdinlib/auth.ts - Update the prices in
lib/pricing.tsto match
Subscription Flow
The full subscription lifecycle:
- User clicks an upgrade button, triggering
subscription.upgrade()from the client - Better Auth creates a Stripe Checkout session and redirects the user
- User completes payment on Stripe's hosted checkout page
- Stripe sends a
checkout.session.completedwebhook - The plugin updates the
Subscriptionrecord in the database - The
onSubscriptionCompletecallback fires, used here to send a confirmation email
Starting a Checkout Session
From a client component, use the subscription object exported from lib/auth-client.ts:
"use client";
import { authClient } from "@/lib/auth-client";
async function handleUpgrade(planName: string, annual: boolean) {
await authClient.subscription.upgrade({
plan: planName,
annual,
successUrl: "/dashboard?success=subscription",
cancelUrl: "/dashboard/billing",
returnUrl: "/dashboard/billing?updated=true",
});
}Cancelling a Subscription
When a user selects the free (Starter) tier, the billing page calls subscription.cancel() rather than opening a checkout session. The subscription remains active until the end of the current billing period (cancelAtPeriodEnd).
await authClient.subscription.cancel({
subscriptionId: activeSubscription.stripeSubscriptionId,
returnUrl: "/dashboard/billing?updated=true",
});Restoring a Pending Cancellation
If a subscription is pending cancellation (cancelAtPeriodEnd is true or cancelAt is set), the user can undo this before the period ends:
await authClient.subscription.restore({
subscriptionId: activeSubscription.stripeSubscriptionId,
});The CurrentPlanCard component (app/dashboard/billing/current-plan-card.tsx) shows a "Restore Subscription" button whenever a pending cancellation is detected.
Checking Subscription Status
Client Components
Use the SubscriptionProvider context, which wraps the dashboard layout and exposes the current plan:
"use client";
import { useSubscription } from "@/components/subscription-provider";
export function PlanBadge() {
const { planName, isLoading } = useSubscription();
if (isLoading) return <Skeleton />;
return <span>{planName} Plan</span>;
}The full context shape is:
interface SubscriptionContextType {
subscriptions: Subscription[]; // All subscriptions for the user
activeSubscription: Subscription | undefined; // active, trialing, or past_due
planName: string; // Capitalised plan name, e.g. "Plus"
isLoading: boolean;
error: string | null;
refetch: () => Promise<void>; // Re-fetch subscriptions on demand
}The provider is mounted in app/dashboard/layout.tsx and available to all dashboard pages and components.
Customer Portal
The Stripe Customer Portal lets users manage their subscription, update payment methods, view invoices, and cancel. The portal URL is generated client-side:
"use client";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth-client";
const router = useRouter();
async function openCustomerPortal() {
const { data, error } = await authClient.subscription.billingPortal({
returnUrl: "/dashboard/billing?updated=true",
disableRedirect: true,
});
if (data?.url) {
router.push(data.url);
}
}The disableRedirect: true option returns the portal URL so you can handle navigation yourself. Users can manage their billing history, update cards, and upgrade or downgrade plans directly from the portal.
Webhook Handling
Webhooks are received at /api/auth/stripe/webhook, set up automatically by the Better Auth Stripe plugin. You do not need to create this route manually.
The plugin verifies the Stripe signature and handles these events internally:
checkout.session.completed: activates a new subscriptioncustomer.subscription.created: handles subscriptions created outside the checkout flowcustomer.subscription.updated: syncs plan changescustomer.subscription.deleted: marks the subscription as canceled
Custom Event Handling
The onEvent callback in the plugin config lets you handle any Stripe event. In Next Starter, it sends renewal and payment failure emails:
async onEvent(event) {
if (!event.type.startsWith("invoice.")) return;
const invoice = event.data.object as Stripe.Invoice;
if (event.type === "invoice.payment_succeeded" &&
invoice.billing_reason === "subscription_cycle") {
// Send renewal email
}
if (event.type === "invoice.payment_failed") {
// Send payment failure email with billing portal link
}
}Testing Locally with Stripe CLI
To receive webhooks during local development, forward them to your local server:
stripe listen --forward-to localhost:3000/api/auth/stripe/webhookThe CLI prints a webhook signing secret starting with whsec_. Copy it into your .env.local:
STRIPE_WEBHOOK_SECRET=whsec_...To trigger a test checkout flow:
stripe trigger checkout.session.completedRequired Environment Variables
STRIPE_SECRET_KEY=sk_live_... # or sk_test_... for development
STRIPE_PUBLISHABLE_KEY=pk_live_... # or pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # from Stripe CLI or dashboardSet STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY to test keys during development so no real charges occur.
Authentication
How Next Starter implements authentication using Better Auth, covering email/password sign-up, Google OAuth, session management, role-based access control, and email verification wired to Prisma and your SMTP provider.
How Next Starter sends transactional emails with SMTP2Go, including configuration, the sendEmail function, built-in HTML templates, disposable email blocking, and adding custom templates.