Next Starter Logo

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:

  1. Copy the price ID from Stripe (format: price_...)
  2. Update priceId or annualDiscountPriceId in lib/auth.ts
  3. Update the prices in lib/pricing.ts to match

Subscription Flow

The full subscription lifecycle:

  1. User clicks an upgrade button, triggering subscription.upgrade() from the client
  2. Better Auth creates a Stripe Checkout session and redirects the user
  3. User completes payment on Stripe's hosted checkout page
  4. Stripe sends a checkout.session.completed webhook
  5. The plugin updates the Subscription record in the database
  6. The onSubscriptionComplete callback 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 subscription
  • customer.subscription.created: handles subscriptions created outside the checkout flow
  • customer.subscription.updated: syncs plan changes
  • customer.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/webhook

The 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.completed

Required 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 dashboard

Set STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY to test keys during development so no real charges occur.

On this page