nextjsboilerplate Docs
Guides

Billing & subscriptions

Stripe setup, the three recurring tiers, checkout flow, customer portal, webhook contract, and how the audit chain captures every lifecycle event.

Billing & subscriptions

The boilerplate ships a complete subscription billing stack on top of Stripe : three recurring tiers (free, pro, scale), one-shot credit packs, checkout, the Stripe customer portal, an idempotent webhook handler, and audit-chain emission on every lifecycle event.

Every feature below has a migration, a server action, a UI surface, and a rollback path documented in CLAUDE.md.

The three tiers

Tier definitions live in src/config/billing.ts (subscriptionTiers). The ordering is free < pro < scale (TIER_ORDER in src/libs/Tier.ts).

TierIntended audienceDefault checkout target
freeSelf-serve trial, indie usersn/a (no Stripe price)
proSingle team, Stripe-billed monthly / yearlySTRIPE_PRICE_PRO_*
scaleMulti-org, advanced features (LLM admin)STRIPE_PRICE_SCALE_*

Subscription state is persisted in the subscriptions table (migration 0008-subscriptions). The active org's tier is resolved at request time via getEffectiveTier(orgId).

Required env vars

Paid tiers no-op without these — checkout buttons are visible but won't dispatch :

# Stripe API
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

# Webhook signature verification
STRIPE_WEBHOOK_SECRET=whsec_...

# Per-tier prices (create them in the Stripe dashboard, paste the price IDs)
STRIPE_PRICE_PRO_MONTHLY=price_...
STRIPE_PRICE_PRO_YEARLY=price_...
STRIPE_PRICE_SCALE_MONTHLY=price_...
STRIPE_PRICE_SCALE_YEARLY=price_...

The pk_ key is intentionally NEXT_PUBLIC_ — the publishable key is safe to ship in client bundles. Never put STRIPE_SECRET_KEY behind NEXT_PUBLIC_*.

Configure prices in Stripe

  1. Sign in to the Stripe dashboard.
  2. Create a Product for each tier (Pro, Scale).
  3. Add two recurring Price rows per product : one monthly, one yearly. The boilerplate computes the yearly discount from the price difference, so use whatever discount your business model wants — the UI labels are purely informational.
  4. Copy the four price_xxx IDs into .env.local against the four STRIPE_PRICE_* vars above.

Checkout flow

The /pricing page renders three plan cards from subscriptionTiers. The "Upgrade to Pro" CTA posts to POST /api/stripe/checkout :

curl -X POST https://your-domain.example/api/stripe/checkout \
  -H 'Content-Type: application/json' \
  -b 'authjs.session-token=...' \
  -d '{"tier":"pro","period":"monthly"}'

The handler at src/app/api/stripe/checkout/route.ts :

  1. Resolves the user from the NextAuth session (Bearer not yet supported on this route).
  2. Reads STRIPE_PRICE_<TIER>_<PERIOD> from the env.
  3. Calls stripe.checkout.sessions.create({ mode: 'subscription', … }) with the user's email pre-filled and the active org id in metadata.
  4. Returns { url }. The client redirects with window.location.assign(url).

The success URL is /dashboard?checkout=success; cancel URL is /pricing.

Webhook contract

Stripe POSTs lifecycle events to /api/stripe/webhook. The handler at src/app/api/stripe/webhook/route.ts :

  1. Verifies the signature against STRIPE_WEBHOOK_SECRET.
  2. Wraps the body in Idempotency.handle() keyed by Stripe-Event-Id — replays of the same event return the cached response instead of re-firing audit-chain rows.
  3. Switches on event.type and upserts the subscriptions row.
  4. Emits the matching audit_chain event :
    • customer.subscription.created / customer.subscription.updatedsubscription.upgraded or subscription.downgraded (compared against the prior row).
    • customer.subscription.deletedsubscription.canceled.
    • invoice.payment_succeededinvoice.paid.
    • invoice.payment_failedinvoice.failed.

The handler 200s gracefully when STRIPE_WEBHOOK_SECRET is unset (dev safety) so a misconfigured local dev never blocks Stripe's retry queue.

Verify the webhook locally

Use the Stripe CLI to pipe events into your local dev server :

# Forward events to localhost
stripe listen --forward-to localhost:3000/api/stripe/webhook

# Trigger a synthetic subscription lifecycle
stripe trigger customer.subscription.created
stripe trigger invoice.payment_succeeded
stripe trigger customer.subscription.deleted

Watch the dev server logs and /dashboard/audit-chain — every webhook should produce an audit_chain row that the chain verifier walks in order.

Customer portal

Authenticated users with an active subscription can manage their billing (update card, cancel, switch plan) via the Stripe customer portal at /api/stripe/portal. The handler creates a Stripe billing portal session scoped to the active org's customer_id and 302-redirects to the returned URL.

The portal is configurable from the Stripe dashboard under Settings → Billing → Customer portal. The minimum we recommend enabling :

  • Update payment method.
  • Cancel subscription (at period end).
  • View invoices.
  • Switch plan (Pro ↔ Scale).

Tier gates in code

Gate a paid feature behind a minimum tier with the <TierGate> server component or the requireTier / assertTier helpers in src/libs/Tier.ts.

<TierGate> server component

import { TierGate } from '@/components/dashboard/TierGate';

export default async function PromptsAdminPage({ params }) {
  return (
    <TierGate min="pro" locale={params.locale}>
      <PromptsAdminPanel />
    </TierGate>
  );
}

When the active org is below pro, the component renders an upsell card with a CTA to /pricing. Copy is i18n-driven via the Tier namespace in src/locales/{en,fr}.json — forks override the wording without forking the component.

requireTier (for server actions that prefer Result types)

import { requireTier } from '@/libs/Tier';

export async function createPrompt(input: CreatePromptInput) {
  const gate = await requireTier(input.orgId, 'pro');
  if (!gate.ok) {
    return {
      ok: false as const,
      error: 'tier_required',
      currentTier: gate.currentTier,
      requiredTier: gate.requiredTier,
      upgradeUrl: gate.upgradeUrl,
    };
  }
  // … create the prompt …
}

assertTier (for server actions that prefer exception flow)

import { assertTier, TierRequiredError } from '@/libs/Tier';

export async function exportPacket(orgId: string, packet: PacketInput) {
  await assertTier(orgId, 'scale'); // throws TierRequiredError if below
  // … export …
}

One-shot credit packs

Coexist with subscriptions — see src/config/credits.ts. Pricing for credit packs is denominated in integer cents (Money = Int). Float math is banned in the codebase per the core rules.

Add a new tier

  1. Append an entry to subscriptionTiers in src/config/billing.ts with the copy + features.
  2. Add the new id to SubscriptionTierId and subscriptionTierEnum in src/models/Schema.ts.
  3. Append the new tier id to TIER_ORDER in src/libs/Tier.ts so the ordering stays consistent with <TierGate>.
  4. Run npm run db:generate — Drizzle emits an ALTER TYPE … ADD VALUE migration.
  5. Wire the matching Stripe Price IDs via STRIPE_PRICE_<TIER>_<PERIOD> env vars.
  6. Add i18n strings (feature labels, explainer) under Pricing and DashboardSubscription in src/locales/{en,fr}.json.

Anti-patterns we ban

  • Float math on money — every cents column is INTEGER and every computation is integer arithmetic.
  • Webhook handlers that don't verify the signature — Stripe events are signature-checked against STRIPE_WEBHOOK_SECRET before any side-effect.
  • Webhook handlers that aren't idempotent — replays of the same Stripe-Event-Id MUST be no-ops. Idempotency is enforced through the shared Idempotency library, not bespoke if (already_processed) checks.
  • Client-side checkout assembly — the price ID is resolved server-side from STRIPE_PRICE_*. The client only sends tier + period. This prevents tampering in the network tab.

What's next

On this page