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',
},
],
},
],
},
],
});

Setting the Billing Provider

Set the provider via environment variable:

NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy, paddle

Also 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: [/* ... */],
}
FieldRequiredDescription
idYesUnique identifier (your choice, not the provider's ID)
nameYesDisplay name in pricing table
descriptionYesShort description shown to users
currencyYesISO currency code (e.g., "USD", "EUR")
plansYesArray of pricing plans
badgeNoBadge text (e.g., "Popular", "Best Value")
highlightedNoVisually highlight this product
enableDiscountFieldNoShow coupon/discount input at checkout
featuresNoFeature list for pricing table
hiddenNoHide 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: [/* ... */],
}
FieldRequiredDescription
idYesUnique identifier (your choice)
nameYesDisplay name
paymentTypeYes'recurring' or 'one-time'
intervalRecurring only'month' or 'year'
lineItemsYesArray of line items (charges)
trialDaysNoFree trial period in days
customNoMark as custom/enterprise plan (see below)
hrefCustom onlyLink for custom plans
labelCustom onlyButton label for custom plans
buttonLabelNoCustom 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:

TypeUse CaseExample
flatFixed recurring price$29/month
per_seatPer-user pricing$10/seat/month
meteredUsage-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',
},
],
}
FieldRequiredDescription
idYesMust match your provider's Price ID
nameYesDisplay name
costYesPrice (for UI display only)
typeYes'flat'

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
],
},
],
}
FieldRequiredDescription
idYesMust match your provider's Price ID
nameYesDisplay name
costYesBase cost (usually 0 for metered)
typeYes'metered'
unitYesUnit label (e.g., "requests", "GBs", "tokens")
tiersYesArray 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'.

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
],
},
],
}
FieldRequiredDescription
idYesMust match your provider's Price ID
nameYesDisplay name
costYesBase cost (usually 0 for tiered)
typeYes'per_seat'
tiersYesArray of pricing tiers

Common patterns:

// Free tier + flat per-seat
tiers: [
{ upTo: 5, cost: 0 }, // 5 free seats
{ upTo: 'unlimited', cost: 15 },
]
// Volume discounts
tiers: [
{ 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

Full per-seat billing guide →

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:

  • paymentType must be 'one-time'
  • No interval field
  • Line items can only be type: 'flat'
  • Data is stored in orders and order_items tables

Full one-off payments guide →

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 },
],
},
],
}

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
}
FieldRequiredDescription
customYesSet to true
labelYesPrice label (e.g., "Custom pricing", "$5,000+")
hrefYesLink destination (e.g., "/contact", "mailto:sales@...")
buttonLabelNoCustom CTA text
lineItemsYesMust 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:

ValidationRule
Unique Plan IDsPlan IDs must be unique across all products
Unique Line Item IDsLine item IDs must be unique across all plans
Provider constraintsLemon Squeezy: max 1 line item per plan
Required tiersMetered and per-seat items require tiers array
One-time paymentsMust have type: 'flat' line items only
Recurring paymentsMust 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: [],
},
],
},
],
});