Billing Configuration
Configure your pricing plans, products, and billing settings
Define your pricing tiers in a single TypeScript file at packages/billing/config/src/config.ts. Products map to your provider's dashboard, plans reference Price IDs (Stripe) or Product IDs (Polar), and limits like seats or storage are enforced by your application code.
This guide is part of the Billing & Subscriptions documentation.
The billing configuration (BillingConfig) is a TypeScript object that declares your products, plans, pricing intervals, feature limits, and trial settings - used by the UI for plan display and by the billing client for limit enforcement.
You can read more about how to configure the billing provider in
- the Stripe Setup page
- and the Polar Setup page
Configuration File
The main billing configuration is located at packages/billing/config/src/config.ts.
By default, the billing configuration has some placeholder products and plans (just to give you an idea of how to configure it) - you will want to replace them with your own.
Have you already set up the billing provider and configured the environment variables? Good! We can now start configuring the billing configuration.
At a very basic level, the billing configuration is an object with a products array. Each product has a name, description, currency, features, and plans array:
packages/billing/config/src/config.ts
import { BillingConfig } from '@kit/billing';export const billingConfig: BillingConfig = { products: [ { id: 'starter', name: 'Starter', description: 'Perfect for individuals and small teams', currency: 'USD', badge: 'Value', features: [ 'Up to 3 team members', 'Core features', 'Email support', ], plans: [ { name: 'starter-monthly', planId: process.env.STRIPE_PRICE_STARTER_MONTHLY!, displayName: 'Starter Monthly', interval: 'month', cost: 9.99, limits: { seats: 3 }, }, ], }, ],};Product Configuration
Each product represents a pricing tier with multiple billing intervals.
Product Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique product identifier |
name | string | Yes | Display name |
description | string | Yes | Short description |
currency | string | Yes | Currency code (USD, EUR, etc.) |
features | string[] | Yes | List of features included |
badge | string | No | Badge text (e.g., "Popular") |
highlighted | boolean | No | Highlight this product in UI |
Example Product
Below is an example of a product configuration:
{ id: 'pro', name: 'Pro', badge: 'Popular', highlighted: true, description: 'Best for growing teams and professionals', currency: 'USD', features: [ 'Up to 10 team members', 'All Starter features', 'Priority support', 'Advanced analytics', ], plans: [ // ... plan configuration ],}Plan Configuration
Each product can have multiple plans with different billing intervals.
Important: Plan Naming Flexibility
The name field is used as the Better Auth plan identifier and can be any string you choose. The UI uses displayName and interval to determine how the plan is displayed - it does not parse the plan name.
This means you have complete freedom to name your plans:
{ name: 'founder-yearly', // Better Auth identifier (can be anything) displayName: 'Founder', // UI displays "Founder" interval: 'year', // UI shows "(Annual)" cost: 99.99, // Result: UI displays "Founder (Annual)"}// Other valid naming examples:name: 'vip-annual' // Works perfectlyname: 'custom-plan-1' // Works perfectlyname: 'lifetime-deal' // Works perfectlyKey Point: You can rename plans freely without breaking the UI. The system trusts your metadata (displayName and interval), not naming conventions.
Plan Fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique plan identifier (Better Auth ID - can be any string) |
planId | string | Yes | Provider plan identifier (Stripe Price ID like price_…, or Polar Product ID like prod_…) |
displayName | string | Recommended | Display name in UI (if omitted, falls back to capitalized name) |
interval | string | Yes | month or year - controls UI display |
cost | number | Yes | Price amount for display purposes |
limits | object | No | Plan limits (seats, storage, etc.) - enforced in your app |
freeTrial | object | No | Free trial configuration (e.g. { days: 14 }) |
Example Plans
plans: [ { name: 'pro-monthly', planId: process.env.STRIPE_PRICE_PRO_MONTHLY!, displayName: 'Pro Monthly', interval: 'month', cost: 19.99, limits: { seats: 10 }, }, { name: 'pro-yearly', planId: process.env.STRIPE_PRICE_PRO_YEARLY!, displayName: 'Pro Yearly', interval: 'year', cost: 199.99, limits: { seats: 10 }, },]Important notes:
- Only the active provider's configuration is used at runtime (set via
NEXT_PUBLIC_BILLING_PROVIDERenv var) - Use environment variables for IDs to easily switch between test/production environments
Plan IDs (Provider-Specific)
The planId field means different things depending on the provider:
- Stripe: a Price ID (
price_…) - Polar: a Product ID (
prod_…)
You can name the environment variables however you want. The key is that planId must be the correct identifier for the active provider.
Stripe example (Price IDs)
You can use environment variables to store the plan IDs so that you can easily switch between test/production environments:
STRIPE_PRICE_STARTER_MONTHLY=price_...STRIPE_PRICE_STARTER_YEARLY=price_...STRIPE_PRICE_PRO_MONTHLY=price_...STRIPE_PRICE_PRO_YEARLY=price_...Then reference them in your config:
planId: process.env.STRIPE_PRICE_STARTER_MONTHLY!,Polar example (Product IDs)
Similarly, you can use environment variables to store the product IDs when using Polar:
POLAR_PRODUCT_STARTER_MONTHLY=prod_...POLAR_PRODUCT_STARTER_YEARLY=prod_...Then reference them in your config:
planId: process.env.POLAR_PRODUCT_STARTER_MONTHLY!,Free Trials
Add a free trial to any plan by setting freeTrial:
{ name: 'pro-monthly', planId: process.env.STRIPE_PRICE_PRO_MONTHLY!, interval: 'month', cost: 19.99, freeTrial: { days: 14 },}Notes:
- Trials are handled by Better Auth (including multi-trial prevention).
- The subscription status will typically be
trialingduring the trial. - In Polar, please specify the free trial during the product setup
Plan Limits
Define usage limits for each plan:
limits: { seats: 10, // Maximum team members projects: 50, // Maximum projects storage: 100, // Storage in GB apiCalls: 10000, // API calls per month}Complete Example
Here's a complete three-tier pricing configuration:
import { BillingConfig } from '@kit/billing';export const billingConfig: BillingConfig = { products: [ { id: 'starter', name: 'Starter', description: 'Perfect for individuals and small teams', currency: 'USD', badge: 'Value', features: [ 'Up to 3 team members', 'Core features', 'Email support', '14-day free trial', ], plans: [ { name: 'starter-monthly', planId: process.env.STRIPE_PRICE_STARTER_MONTHLY!, displayName: 'Starter Monthly', interval: 'month', cost: 9.99, limits: { seats: 3 }, }, { name: 'starter-yearly', planId: process.env.STRIPE_PRICE_STARTER_YEARLY!, displayName: 'Starter Yearly', interval: 'year', cost: 99.99, limits: { seats: 3 }, }, ], }, { id: 'pro', name: 'Pro', badge: 'Popular', highlighted: true, description: 'Best for growing teams', currency: 'USD', features: [ 'Up to 10 team members', 'All Starter features', 'Priority support', 'Advanced analytics', ], plans: [ { name: 'pro-monthly', planId: process.env.STRIPE_PRICE_PRO_MONTHLY!, displayName: 'Pro Monthly', interval: 'month', cost: 19.99, limits: { seats: 10 }, }, { name: 'pro-yearly', planId: process.env.STRIPE_PRICE_PRO_YEARLY!, displayName: 'Pro Yearly', interval: 'year', cost: 199.99, limits: { seats: 10 }, }, ], }, { id: 'enterprise', name: 'Enterprise', description: 'For large organizations', currency: 'USD', features: [ 'Unlimited team members', 'All Pro features', 'Dedicated support', 'Custom integrations', ], plans: [ { name: 'enterprise-monthly', planId: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY!, displayName: 'Enterprise Monthly', interval: 'month', cost: 49.99, limits: { seats: null }, // Unlimited }, ], }, ],};Supported vs. Reserved Fields
The billing configuration includes several fields in the TypeScript types. Here's what's currently supported:
Supported Fields (Ready to Use)
name- Plan identifierplanId- Provider plan identifier (Stripe price ID or Polar product ID)displayName- UI display nameinterval- Billing interval (monthoryear)cost- Price for displaylimits- Feature limits (seats, storage, etc.)hidden- Hide product from UI (for grandfathered plans)
Hidden Products (Grandfathered Plans)
You can hide products from the plan picker UI while keeping them active for existing subscribers:
{ id: 'legacy-pro', name: 'Legacy Pro', description: 'Original Pro plan for early customers', currency: 'USD', hidden: true, // Hides from plan picker UI features: [ 'All Pro features', 'Grandfathered pricing', ], plans: [ { name: 'legacy-pro-monthly', planId: process.env.STRIPE_PRICE_LEGACY_PRO_MONTHLY!, displayName: 'Legacy Pro', interval: 'month', cost: 9.99, // Old pricing }, ],}Use Cases:
- Grandfathered pricing for early customers
- Deprecated plans still in use
- Custom enterprise agreements
- Limited-time promotional pricing
Hidden products remain fully functional for existing subscriptions but won't appear when users browse available plans.
Common Pitfalls
- Using Product ID instead of Price ID for Stripe: Stripe
planIdmust be a Price ID (price_...), not a Product ID (prod_...). Check your Stripe dashboard for the correct ID. - Hardcoding Price IDs: Always use environment variables (
process.env.STRIPE_PRICE_...) so you can switch between test/production environments without code changes. - Forgetting the
!assertion: TypeScript requiresprocess.env.VAR!with the non-null assertion since env vars arestring | undefined. Missing this causes type errors. - Mismatched limits and provider config: If you set
limits: { seats: 10 }but your Stripe price doesn't support quantity billing, the UI will show limits but checkout won't enforce them. - Setting
freeTrialfor Polar: Polar trials are configured in the product dashboard, not in the billing config. The config'sfreeTrialonly applies to Stripe. - Duplicate plan names: Each plan
namemust be unique across all products. Better Auth uses this as the plan identifier; duplicates cause lookup failures.
Frequently Asked Questions
Where do I find Stripe Price IDs?
How do limits actually get enforced?
Can I have different limits for monthly vs yearly plans?
What happens if I change a plan's name?
How do I offer unlimited seats?
Advanced Pricing
The examples above use planId for simple, single-price subscriptions. For complex scenarios, you can use lineItems instead:
| Approach | Field | Use Case |
|---|---|---|
| SimplePlan | planId | Single price per plan |
| AdvancedPlan | lineItems | Multi-price checkout, tiered pricing |
When to Use lineItems
- Base fee + per-seat pricing
- Metered usage components (AI tokens, API calls)
- Tiered pricing with volume discounts
- Optional add-ons customers can select at checkout
// SimplePlan - single price{ name: 'starter-monthly', planId: process.env.STRIPE_PRICE_STARTER!, // ...}// AdvancedPlan - multiple prices{ name: 'pro-monthly', primaryPriceId: process.env.STRIPE_PRICE_PRO_BASE!, lineItems: [ { id: 'base', type: 'flat', priceId: process.env.STRIPE_PRICE_PRO_BASE!, ... }, { id: 'seats', type: 'usage', priceId: process.env.STRIPE_PRICE_SEATS!, ... }, ], // ...}For complete documentation on multi-line item checkout, tiered pricing, and optional add-ons, see Advanced Pricing.
Next: Advanced Pricing →