Configure SaaS Pricing Plans with the Billing Schema
Define your SaaS pricing with Makerkit's billing schema. Configure products, plans, flat subscriptions, per-seat pricing, metered usage, and one-off payments for Stripe, Lemon Squeezy, or Paddle.
The billing schema defines your products and pricing in a single configuration file. This schema drives the pricing table UI, checkout sessions, and subscription management across all supported providers (Stripe, Lemon Squeezy, Paddle).
Schema Structure
The schema has three levels:
Products (what you sell)└── Plans (pricing options: monthly, yearly) └── Line Items (how you charge: flat, per-seat, metered)Example: A "Pro" product might have "Pro Monthly" and "Pro Yearly" plans. Each plan has line items defining the actual charges.
Quick Start
Create or edit apps/web/config/billing.config.ts:
import { createBillingSchema } from '@kit/billing';const provider = process.env.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe';export default createBillingSchema({ provider, products: [ { id: 'pro', name: 'Pro', description: 'For growing teams', currency: 'USD', badge: 'Popular', plans: [ { id: 'pro-monthly', name: 'Pro Monthly', paymentType: 'recurring', interval: 'month', lineItems: [ { id: 'price_xxxxxxxxxxxxx', // Your Stripe Price ID name: 'Pro Plan', cost: 29, type: 'flat', }, ], }, { id: 'pro-yearly', name: 'Pro Yearly', paymentType: 'recurring', interval: 'year', lineItems: [ { id: 'price_yyyyyyyyyyyyy', // Your Stripe Price ID name: 'Pro Plan', cost: 290, type: 'flat', }, ], }, ], }, ],});Match IDs exactly
Line item id values must match the Price IDs in your billing provider (Stripe, Lemon Squeezy, or Paddle). The schema validates this format but cannot verify the IDs exist in your provider account.
Setting the Billing Provider
Set the provider via environment variable:
NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy, paddleAlso update the database configuration:
UPDATE public.config SET billing_provider = 'stripe';The provider determines which API is called when creating checkouts, managing subscriptions, and processing webhooks.
Products
Products represent what you're selling (e.g., "Starter", "Pro", "Enterprise"). Each product can have multiple plans with different billing intervals.
{ id: 'pro', name: 'Pro', description: 'For growing teams', currency: 'USD', badge: 'Popular', highlighted: true, enableDiscountField: true, features: [ 'Unlimited projects', 'Priority support', 'Advanced analytics', ], plans: [/* ... */],}| Field | Required | Description |
|---|---|---|
id | Yes | Unique identifier (your choice, not the provider's ID) |
name | Yes | Display name in pricing table |
description | Yes | Short description shown to users |
currency | Yes | ISO currency code (e.g., "USD", "EUR") |
plans | Yes | Array of pricing plans |
badge | No | Badge text (e.g., "Popular", "Best Value") |
highlighted | No | Visually highlight this product |
enableDiscountField | No | Show coupon/discount input at checkout |
features | No | Feature list for pricing table |
hidden | No | Hide from pricing table (for legacy plans) |
The id is your internal identifier. It doesn't need to match anything in Stripe or your payment provider.
Plans
Plans define pricing options within a product. Typically, you'll have monthly and yearly variants.
{ id: 'pro-monthly', name: 'Pro Monthly', paymentType: 'recurring', interval: 'month', trialDays: 14, lineItems: [/* ... */],}| Field | Required | Description |
|---|---|---|
id | Yes | Unique identifier (your choice) |
name | Yes | Display name |
paymentType | Yes | 'recurring' or 'one-time' |
interval | Recurring only | 'month' or 'year' |
lineItems | Yes | Array of line items (charges) |
trialDays | No | Free trial period in days |
custom | No | Mark as custom/enterprise plan (see below) |
href | Custom only | Link for custom plans |
label | Custom only | Button label for custom plans |
buttonLabel | No | Custom checkout button text |
Plan ID validation: The schema validates that plan IDs are unique across all products.
Line Items
Line items define how you charge for a plan. Makerkit supports three types:
| Type | Use Case | Example |
|---|---|---|
flat | Fixed recurring price | $29/month |
per_seat | Per-user pricing | $10/seat/month |
metered | Usage-based pricing | $0.01 per API call |
Provider limitations:
- Stripe: Supports multiple line items per plan (mix flat + per-seat + metered)
- Lemon Squeezy: One line item per plan only
- Paddle: Flat and per-seat only (no metered billing)
Flat Subscriptions
The most common pricing model. A fixed amount charged at each billing interval.
{ id: 'pro-monthly', name: 'Pro Monthly', paymentType: 'recurring', interval: 'month', lineItems: [ { id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', // Stripe Price ID name: 'Pro Plan', cost: 29, type: 'flat', }, ],}| Field | Required | Description |
|---|---|---|
id | Yes | Must match your provider's Price ID |
name | Yes | Display name |
cost | Yes | Price (for UI display only) |
type | Yes | 'flat' |
Cost is for display only
The cost field is used for the pricing table UI. The actual charge comes from your billing provider. Make sure they match to avoid confusing users.
Metered Billing
Charge based on usage (API calls, storage, tokens). You report usage through the billing API, and the provider calculates charges at the end of each billing period.
{ id: 'api-monthly', name: 'API Monthly', paymentType: 'recurring', interval: 'month', lineItems: [ { id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', name: 'API Requests', cost: 0, type: 'metered', unit: 'requests', tiers: [ { upTo: 1000, cost: 0 }, // First 1000 free { upTo: 10000, cost: 0.001 }, // $0.001 per request { upTo: 'unlimited', cost: 0.0005 }, // Volume discount ], }, ],}| Field | Required | Description |
|---|---|---|
id | Yes | Must match your provider's Price ID |
name | Yes | Display name |
cost | Yes | Base cost (usually 0 for metered) |
type | Yes | 'metered' |
unit | Yes | Unit label (e.g., "requests", "GBs", "tokens") |
tiers | Yes | Array of pricing tiers |
Tier structure:
{ upTo: number | 'unlimited', // Usage threshold cost: number, // Cost per unit in this tier}The last tier should always have upTo: 'unlimited'.
Provider-specific metered billing
Stripe and Lemon Squeezy handle metered billing differently. See the metered usage guide for provider-specific implementation details.
Per-Seat Billing
Charge based on team size. Makerkit automatically updates seat counts when members are added or removed from a team account.
{ id: 'team-monthly', name: 'Team Monthly', paymentType: 'recurring', interval: 'month', lineItems: [ { id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', name: 'Team Seats', cost: 0, type: 'per_seat', tiers: [ { upTo: 3, cost: 0 }, // First 3 seats free { upTo: 10, cost: 12 }, // $12/seat for 4-10 { upTo: 'unlimited', cost: 10 }, // Volume discount ], }, ],}| Field | Required | Description |
|---|---|---|
id | Yes | Must match your provider's Price ID |
name | Yes | Display name |
cost | Yes | Base cost (usually 0 for tiered) |
type | Yes | 'per_seat' |
tiers | Yes | Array of pricing tiers |
Common patterns:
// Free tier + flat per-seattiers: [ { upTo: 5, cost: 0 }, // 5 free seats { upTo: 'unlimited', cost: 15 },]// Volume discountstiers: [ { upTo: 10, cost: 20 }, // $20/seat for 1-10 { upTo: 50, cost: 15 }, // $15/seat for 11-50 { upTo: 'unlimited', cost: 10 },]// Flat price (no tiers)tiers: [ { upTo: 'unlimited', cost: 10 },]Makerkit handles seat count updates automatically when:
- A new member joins the team
- A member is removed from the team
- A member invitation is accepted
One-Off Payments
Single charges for lifetime access, add-ons, or credits. One-off payments are stored in the orders table instead of subscriptions.
{ id: 'lifetime', name: 'Lifetime Access', paymentType: 'one-time', // No interval for one-time payments lineItems: [ { id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', name: 'Lifetime Access', cost: 299, type: 'flat', }, ],}Key differences from subscriptions:
paymentTypemust be'one-time'- No
intervalfield - Line items can only be
type: 'flat' - Data is stored in
ordersandorder_itemstables
Combining Line Items (Stripe Only)
With Stripe, you can combine multiple line items in a single plan. This is useful for hybrid pricing models:
{ id: 'growth-monthly', name: 'Growth Monthly', paymentType: 'recurring', interval: 'month', lineItems: [ // Base platform fee { id: 'price_base_fee', name: 'Platform Fee', cost: 49, type: 'flat', }, // Per-seat charges { id: 'price_seats', name: 'Team Seats', cost: 0, type: 'per_seat', tiers: [ { upTo: 5, cost: 0 }, { upTo: 'unlimited', cost: 10 }, ], }, // Usage-based charges { id: 'price_api', name: 'API Calls', cost: 0, type: 'metered', unit: 'calls', tiers: [ { upTo: 10000, cost: 0 }, { upTo: 'unlimited', cost: 0.001 }, ], }, ],}Lemon Squeezy and Paddle limitations
Lemon Squeezy and Paddle only support one line item per plan. The schema validation will fail if you add multiple line items with these providers.
Custom Plans (Enterprise/Contact Us)
Display a plan in the pricing table without checkout functionality. Useful for enterprise tiers or "Contact Us" options.
{ id: 'enterprise', name: 'Enterprise', paymentType: 'recurring', interval: 'month', custom: true, label: '$5,000+', // or 'common:contactUs' for i18n href: '/contact', buttonLabel: 'Contact Sales', lineItems: [], // Must be empty array}| Field | Required | Description |
|---|---|---|
custom | Yes | Set to true |
label | Yes | Price label (e.g., "Custom pricing", "$5,000+") |
href | Yes | Link destination (e.g., "/contact", "mailto:sales@...") |
buttonLabel | No | Custom CTA text |
lineItems | Yes | Must be empty array [] |
Custom plans appear in the pricing table but clicking them navigates to href instead of opening checkout.
Legacy Plans
When you discontinue a plan but have existing subscribers, use the hidden flag to keep the plan in your schema without showing it in the pricing table:
{ id: 'old-pro', name: 'Pro (Legacy)', description: 'This plan is no longer available', currency: 'USD', hidden: true, // Won't appear in pricing table plans: [ { id: 'old-pro-monthly', name: 'Pro Monthly (Legacy)', paymentType: 'recurring', interval: 'month', lineItems: [ { id: 'price_legacy_xxx', name: 'Pro Plan', cost: 19, type: 'flat', }, ], }, ],}Hidden plans:
- Don't appear in the pricing table
- Still display correctly in the user's billing section
- Allow existing subscribers to continue without issues
If you remove a plan entirely: Makerkit will attempt to fetch plan details from the billing provider. This works for flat line items only. For complex plans, keep them in your schema with hidden: true.
Schema Validation
The createBillingSchema function validates your configuration and throws errors for common mistakes:
| Validation | Rule |
|---|---|
| Unique Plan IDs | Plan IDs must be unique across all products |
| Unique Line Item IDs | Line item IDs must be unique across all plans |
| Provider constraints | Lemon Squeezy: max 1 line item per plan |
| Required tiers | Metered and per-seat items require tiers array |
| One-time payments | Must have type: 'flat' line items only |
| Recurring payments | Must specify interval: 'month' or 'year' |
Complete Example
Here's a full billing schema with multiple products and pricing models:
import { createBillingSchema } from '@kit/billing';const provider = process.env.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe';export default createBillingSchema({ provider, products: [ // Free tier (custom plan, no billing) { id: 'free', name: 'Free', description: 'Get started for free', currency: 'USD', plans: [ { id: 'free', name: 'Free', paymentType: 'recurring', interval: 'month', custom: true, label: '$0', href: '/auth/sign-up', buttonLabel: 'Get Started', lineItems: [], }, ], }, // Pro tier with monthly/yearly { id: 'pro', name: 'Pro', description: 'For professionals and small teams', currency: 'USD', badge: 'Popular', highlighted: true, features: [ 'Unlimited projects', 'Priority support', 'Advanced analytics', 'Custom integrations', ], plans: [ { id: 'pro-monthly', name: 'Pro Monthly', paymentType: 'recurring', interval: 'month', trialDays: 14, lineItems: [ { id: 'price_pro_monthly', name: 'Pro Plan', cost: 29, type: 'flat', }, ], }, { id: 'pro-yearly', name: 'Pro Yearly', paymentType: 'recurring', interval: 'year', trialDays: 14, lineItems: [ { id: 'price_pro_yearly', name: 'Pro Plan', cost: 290, type: 'flat', }, ], }, ], }, // Team tier with per-seat pricing { id: 'team', name: 'Team', description: 'For growing teams', currency: 'USD', features: [ 'Everything in Pro', 'Team management', 'SSO authentication', 'Audit logs', ], plans: [ { id: 'team-monthly', name: 'Team Monthly', paymentType: 'recurring', interval: 'month', lineItems: [ { id: 'price_team_monthly', name: 'Team Seats', cost: 0, type: 'per_seat', tiers: [ { upTo: 5, cost: 0 }, { upTo: 'unlimited', cost: 15 }, ], }, ], }, ], }, // Enterprise tier { id: 'enterprise', name: 'Enterprise', description: 'For large organizations', currency: 'USD', features: [ 'Everything in Team', 'Dedicated support', 'Custom contracts', 'SLA guarantees', ], plans: [ { id: 'enterprise', name: 'Enterprise', paymentType: 'recurring', interval: 'month', custom: true, label: 'Custom', href: '/contact', buttonLabel: 'Contact Sales', lineItems: [], }, ], }, ],});Related Documentation
- Billing Overview - Architecture and provider comparison
- Stripe Setup - Configure Stripe billing
- Lemon Squeezy Setup - Configure Lemon Squeezy
- Paddle Setup - Configure Paddle
- Per-Seat Billing - Team-based pricing
- Metered Usage - Usage-based pricing
- One-Off Payments - Lifetime deals and add-ons