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

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

FieldTypeRequiredDescription
idstringYesUnique product identifier
namestringYesDisplay name
descriptionstringYesShort description
currencystringYesCurrency code (USD, EUR, etc.)
featuresstring[]YesList of features included
badgestringNoBadge text (e.g., "Popular")
highlightedbooleanNoHighlight 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 perfectly
name: 'custom-plan-1' // Works perfectly
name: 'lifetime-deal' // Works perfectly

Key Point: You can rename plans freely without breaking the UI. The system trusts your metadata (displayName and interval), not naming conventions.

Plan Fields

FieldTypeRequiredDescription
namestringYesUnique plan identifier (Better Auth ID - can be any string)
planIdstringYesProvider plan identifier (Stripe Price ID like price_…, or Polar Product ID like prod_…)
displayNamestringRecommendedDisplay name in UI (if omitted, falls back to capitalized name)
intervalstringYesmonth or year - controls UI display
costnumberYesPrice amount for display purposes
limitsobjectNoPlan limits (seats, storage, etc.) - enforced in your app
freeTrialobjectNoFree 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_PROVIDER env 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 trialing during 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 identifier
  • planId - Provider plan identifier (Stripe price ID or Polar product ID)
  • displayName - UI display name
  • interval - Billing interval (month or year)
  • cost - Price for display
  • limits - 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 planId must 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 requires process.env.VAR! with the non-null assertion since env vars are string | 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 freeTrial for Polar: Polar trials are configured in the product dashboard, not in the billing config. The config's freeTrial only applies to Stripe.
  • Duplicate plan names: Each plan name must 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?
In Stripe Dashboard → Products → click your product → copy the Price ID (starts with price_). Don't use the Product ID (starts with prod_).
How do limits actually get enforced?
Limits are declarative only. Your code must call billing.checkPlanLimit() to enforce them. The UI uses limits for display, but blocking is up to your application logic.
Can I have different limits for monthly vs yearly plans?
Yes. Each plan object has its own limits property. Set different values for each interval if needed.
What happens if I change a plan's name?
Existing subscriptions reference the old name and won't match the new plan. Create a new plan and hide the old one instead.
How do I offer unlimited seats?
Set limits: { seats: null }. Null means unlimited. Your checkPlanLimit() call will return allowed: true for unlimited resources.

Advanced Pricing

The examples above use planId for simple, single-price subscriptions. For complex scenarios, you can use lineItems instead:

ApproachFieldUse Case
SimplePlanplanIdSingle price per plan
AdvancedPlanlineItemsMulti-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 →