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 PolarNEXT_PUBLIC_BILLING_PROVIDER=polarThe 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
| Identifier | Purpose | Examples | Details |
|---|---|---|---|
customerId | Provider's customer reference | Stripe: cus_… | Powers portal access, entitlements, and usage meters. Stripe maintains separate customer records for users and organizations. |
subscriptionId | ID used in cancel/restore/upgrade calls | Varies by provider | Note: The active provider determines this value. The kit UI pulls it from subscription.providerSubscriptionId. |
providerSubscriptionId | Raw subscription ID from provider | Stripe: 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
billingresource control access.
Standard permission mapping:
| Permission | Allows |
|---|---|
billing:read | Access billing page, view subscriptions |
billing:create | Initiate checkout, upgrade plans |
billing:update | Access customer portal, restore subscriptions |
billing:delete | Cancel active subscriptions |
Essential Environment Variables
NEXT_PUBLIC_BILLING_PROVIDER: Set tostripe(default) orpolarNEXT_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 objectawait 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:
- Checkout - Create Stripe Checkout sessions for new subscriptions
- Trials - Automatic trial management with conversion tracking
- Active Management - Monitor ongoing subscription status
- Plan Changes - Handle upgrades and downgrades with proration
- Cancellations - Support cancel-at-period-end with restore option
- Payment Recovery - Automatic retry logic and failure notifications
Multi-Tenant Architecture
The same billing code works for both account types:
// Individual user billingconst referenceId = user.id;// Team account billingconst 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=stripeSTRIPE_SECRET_KEY=sk_test_...STRIPE_WEBHOOK_SECRET=whsec_...# For PolarNEXT_PUBLIC_BILLING_PROVIDER=polarPOLAR_ACCESS_TOKEN=polar_at_...POLAR_WEBHOOK_SECRET=whsec_...POLAR_ENVIRONMENT=sandboxDetailed 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
- Start development:
pnpm dev - Open
/settings/billing - Choose a plan and start checkout
- Stripe: Complete with test card
4242 4242 4242 4242 - 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_SECRETorPOLAR_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:listenfor Stripe or use ngrok for Polar. Without webhooks, subscription state stalls. - Mixing up
referenceIdandcustomerId:referenceIdis your app's user/org ID;customerIdis 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?
How do I test billing without real payments?
Do subscriptions survive provider switches?
How do I handle failed payments?
Can I offer lifetime deals?
How do I upgrade/downgrade plans?
Next: Billing Configuration →