Billing & Subscriptions

Implement subscription billing with Stripe or Polar in your Next.js Prisma application

Add subscription billing to your Next.js Prisma application with built-in support for Stripe and Polar. Define your pricing tiers once, swap providers through an environment variable, and manage the complete subscription lifecycle without writing provider-specific code.

MakerKit's billing layer sits on top of Better Auth and provides a unified BillingClient API. This abstraction handles checkout sessions, customer portals, plan limits, feature entitlements, and metered usage across both Stripe and Polar.

Access raw SDKs when needed: For features beyond the unified API, call billing.getProviderClient() to work directly with Stripe or Polar.

What You Get

The billing system includes:

  • Provider Flexibility - Toggle between Stripe and Polar with a single env var
  • Full Subscription Lifecycle - Handle creation, upgrades, downgrades, cancellations, and reactivations
  • Multi-Tenant Ready - Bill both individual users and team accounts
  • Configurable Pricing - Define products with multiple plans and billing intervals
  • Seat-Based Pricing - Charge based on team size with quantity billing
  • Trial Support - Offer free trials that convert automatically
  • Self-Service Portal - Let customers manage billing through provider-hosted portals
  • Event Hooks - Run custom code when subscription events occur
  • Role-Based Access - Control who can manage billing with permissions

Provider Selection

Switch billing providers via environment variable:

# Use Stripe (default)
NEXT_PUBLIC_BILLING_PROVIDER=stripe
# Use Polar
NEXT_PUBLIC_BILLING_PROVIDER=polar

The same application code works with either provider - only configuration changes.

Core Concepts

Billing operations are context-aware: identical code paths serve both personal accounts and team organizations. Grasping these identifiers will help you debug issues and extend the system confidently.

Billing Context: referenceId + customerType

  • referenceId: Identifies the subscription owner:
    • For personal billing: the user ID
    • For team billing: the organization ID
  • customerType: Specifies the billing entity type:
    • 'user' for individual accounts
    • 'organization' for team accounts (note: not all providers support true org customers)

The billing page automatically determines context from the active session:

  • Individual accounts: referenceId = session.user.id, customerType = 'user'
  • Team accounts: referenceId = session.session.activeOrganizationId, customerType = 'organization'

Key Identifiers

IdentifierPurposeExamplesDetails
customerIdProvider's customer referenceStripe: cus_…Powers portal access, entitlements, and usage meters. Stripe maintains separate customer records for users and organizations.
subscriptionIdID used in cancel/restore/upgrade callsVaries by providerNote: The active provider determines this value. The kit UI pulls it from subscription.providerSubscriptionId.
providerSubscriptionIdRaw subscription ID from providerStripe: sub_…Available on the Subscription object returned by billing.listSubscriptions.

Permission Model

  • Personal accounts: Users manage their own billing directly.
  • Team accounts: Better Auth org permissions on the billing resource control access.

Standard permission mapping:

PermissionAllows
billing:readAccess billing page, view subscriptions
billing:createInitiate checkout, upgrade plans
billing:updateAccess customer portal, restore subscriptions
billing:deleteCancel active subscriptions

Essential Environment Variables

  • NEXT_PUBLIC_BILLING_PROVIDER: Set to stripe (default) or polar
  • NEXT_PUBLIC_SITE_URL: Absolute URL for server-side redirects (e.g., https://yourdomain.com)

Provider-specific configuration is covered in Stripe Setup and Polar Setup.

Package Structure

The billing system spans multiple packages in the monorepo:

packages/
├── billing/
│ ├── api/ # @kit/billing-api - Unified BillingClient
│ ├── core/ # @kit/billing - Core types and registry
│ ├── config/ # @kit/web-billing-config - App config
│ ├── stripe/ # @kit/billing-stripe - Stripe provider
│ ├── polar/ # @kit/billing-polar - Polar provider
│ └── ui/ # @kit/billing-ui - UI components
├── better-auth/
│ └── src/plugins/
│ └── billing.ts # Better Auth billing plugin (provider factory)

Architecture Diagram

┌─────────────────────────────────────────────────────────────────────────────┐
│ UI LAYER │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ pricing-table │ │ checkout-button │ │ subscription- │ │
│ │ │ │ │ │ card │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ ┌────────┴────────┐ ┌────────┴────────┐ ┌────────┴────────┐ │
│ │ plan-picker │ │ billing-portal- │ │ cancel-dialog │ │
│ │ │ │ button │ │ │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ @kit/billing-ui │
└───────────────────────────────────┬─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ SERVER ACTIONS │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ billing-server-actions.ts │ │
│ │ • createCheckoutSessionAction │ │
│ │ • createBillingPortalSessionAction │ │
│ │ • cancelSubscriptionAction │ │
│ │ • restoreSubscriptionAction │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────┬─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ BILLING CLIENT │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ getBilling(auth) → BillingClient │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Methods: │ Utilities: │ │ │
│ │ │ • checkout() │ • checkPlanLimit() │ │ │
│ │ │ • portal() │ • checkEntitlement() │ │ │
│ │ │ • listSubscriptions() │ • recordUsage() │ │ │
│ │ │ • cancelSubscription() │ • capabilities │ │ │
│ │ │ • restoreSubscription() │ • getProvider() │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ @kit/billing-api │
└───────────────────────────────────┬─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ PROVIDER REGISTRY │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ NEXT_PUBLIC_BILLING_PROVIDER env var → Provider Factory (lazy) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────┬─────────────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ STRIPE PROVIDER │ │ POLAR PROVIDER │
│ ┌────────────────────────────┐ │ │ ┌────────────────────────────┐ │
│ │ Capabilities: │ │ │ │ Capabilities: │ │
│ │ ✓ checkout │ │ │ │ ✓ checkout │ │
│ │ ✓ portal │ │ │ │ ✓ portal │ │
│ │ ✓ cancel │ │ │ │ ✗ cancel (via portal) │ │
│ │ ✓ restore │ │ │ │ ✗ restore (via portal) │ │
│ │ ✓ entitlements │ │ │ │ ✗ entitlements │ │
│ │ ✓ usage meters │ │ │ │ ✓ usage meters │ │
│ └────────────────────────────┘ │ │ └────────────────────────────┘ │
│ @kit/billing-stripe │ │ @kit/billing-polar │
└──────────────────────────────────┘ └──────────────────────────────────┘

Checkout Flow

User UI Server Action Provider
│ │ │ │
│ Click Checkout │ │ │
│────────────────────>│ │ │
│ │ createCheckoutSession │ │
│ │────────────────────────>│ │
│ │ │ billing.checkout() │
│ │ │───────────────────────>│
│ │ │ │
│ │ │ Checkout URL │
│ │ │<───────────────────────│
│ │ redirect(url) │ │
│ │<────────────────────────│ │
│ Redirect to │ │ │
│ Provider Checkout │ │ │
│<────────────────────│ │ │
│ │ │ │
│ Complete Payment │ │ │
│─────────────────────────────────────────────────────────────────────── >│
│ │ │ Webhook: subscription │
│ │ │<───────────────────────│
│ │ │ Update DB via │
│ │ │ Better Auth Plugin │
│ │ │ Execute lifecycle │
│ │ │ hooks │
│ Redirect to │ │ │
│ Success Page │ │ │
│<──────────────────────────────────────────────────────────────────────-│

The BillingClient API

Import BillingClient from @kit/billing-api to access:

  • Subscription operations: checkout, portal, list, cancel, restore
  • Quota enforcement: validate limits on seats, projects, storage
  • Feature gating: check boolean entitlements (graceful fallback when unsupported)
  • Usage tracking: record and query metered usage (provider-dependent)
import { getBilling } from '@kit/billing-api';
const billing = await getBilling(auth);
// All methods available on single object
await billing.checkout({ ... });
await billing.checkPlanLimit({ referenceId, limitKey, currentUsage });
await billing.checkEntitlement(customerId, 'feature-key');

Features

Complete Subscription Lifecycle

The system manages subscriptions from start to finish:

  1. Checkout - Create Stripe Checkout sessions for new subscriptions
  2. Trials - Automatic trial management with conversion tracking
  3. Active Management - Monitor ongoing subscription status
  4. Plan Changes - Handle upgrades and downgrades with proration
  5. Cancellations - Support cancel-at-period-end with restore option
  6. Payment Recovery - Automatic retry logic and failure notifications

Multi-Tenant Architecture

The same billing code works for both account types:

// Individual user billing
const referenceId = user.id;
// Team account billing
const referenceId = organizationId;

Better Auth's polymorphic subscription model enables this flexibility without conditional logic.

Getting Started

1. Select a Provider

Configure your billing provider in apps/web/.env.development:

# For Stripe (default)
NEXT_PUBLIC_BILLING_PROVIDER=stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# For Polar
NEXT_PUBLIC_BILLING_PROVIDER=polar
POLAR_ACCESS_TOKEN=polar_at_...
POLAR_WEBHOOK_SECRET=whsec_...
POLAR_ENVIRONMENT=sandbox

Detailed setup instructions are in Stripe Setup and Polar Setup.

2. Define Your Plans

After creating products in your provider dashboard, map them in the config:

import { BillingConfig } from '@kit/billing';
export const billingConfig: BillingConfig = {
products: [
{
id: 'starter',
name: 'Starter',
description: 'For individuals and small teams',
currency: 'USD',
features: ['Core features', 'Email support'],
plans: [
{
name: 'starter-monthly',
// Configure provider-specific IDs
planId: process.env.STRIPE_PRICE_STARTER_MONTHLY!,
displayName: 'Starter Monthly',
interval: 'month',
cost: 9.99,
},
],
},
],
};

Store IDs in environment variables to simplify switching between test and production. At runtime, only the active provider's configuration applies.

3. Verify the Integration

  1. Start development: pnpm dev
  2. Open /settings/billing
  3. Choose a plan and start checkout
  4. Stripe: Complete with test card 4242 4242 4242 4242
  5. Polar: Test in sandbox mode

Watch Out For

  • Price ID vs Product ID confusion: Stripe expects Price IDs (price_...), Polar expects Product IDs (prod_...). Using the wrong type breaks checkout.
  • Missing NEXT_PUBLIC_SITE_URL: Server actions rely on this for redirect URLs. Omitting it causes silent redirect failures.
  • No webhook secret in production: Signature verification fails without STRIPE_WEBHOOK_SECRET or POLAR_WEBHOOK_SECRET, preventing subscription sync.
  • Assuming Polar has org customers: Polar is user-centric with no separate organization customer records. Choose Stripe for strict B2B billing needs.
  • Skipping local webhook testing: Run pnpm --filter web run stripe:listen for Stripe or use ngrok for Polar. Without webhooks, subscription state stalls.
  • Mixing up referenceId and customerId: referenceId is your app's user/org ID; customerId is the provider's ID (e.g., cus_... for Stripe).
  • Expecting automatic limit enforcement: Plan limits are advisory. Call billing.checkPlanLimit() explicitly to enforce restrictions.

Frequently Asked Questions

Can I use both Stripe and Polar simultaneously?
No. Only one provider is active at a time, controlled by NEXT_PUBLIC_BILLING_PROVIDER. You can switch providers, but not run both for different customers.
How do I test billing without real payments?
Use Stripe test mode (sk_test_ keys) or Polar sandbox environment (POLAR_ENVIRONMENT=sandbox). Both provide test cards and sandboxed transactions.
Do subscriptions survive provider switches?
No. Subscription data is provider-specific and stored via Better Auth. Switching providers means existing subscriptions won't transfer.
How do I handle failed payments?
Implement the onPaymentFailed lifecycle hook in packages/billing/stripe/src/hooks/. Stripe automatically retries, but you can send notifications or restrict access.
Can I offer lifetime deals?
Yes, but configure them as one-time payments in the provider dashboard. The billing config supports any plan structure the provider allows.
How do I upgrade/downgrade plans?
Use the customer portal (billing.portal()) which handles proration automatically. Or call the provider SDK directly for programmatic changes.

Next: Billing Configuration →