Plans & Pricing
Configure subscription tiers and per-currency pricing across monthly, yearly, and lifetime billing intervals using pricingConfig.
Plans live in pricingConfig - a standalone named export from packages/config/src/pricing.ts, not a key on config. It holds plans: PricePlan[], a defaultPlan ID, and optional credits/products blocks, and renders the marketing pricing page even when config.payment.enabled is false.
Shipped plans
Three plans ship out of the box; tier order is array position - reorder only to change upgrade/downgrade logic.
| Plan | id | type | Notable |
|---|---|---|---|
| Free | free | free | defaultPlan - assigned to new users |
| Starter | starter | subscription | month + year |
| Pro | pro | subscription | featured: true, month + year |
defaultPlanships"free"(setnullfor no default plan).defaultApiRateLimitis the fallback when a plan omitsapiRateLimit(ships{ maxRequests: 100 }).- No plan ships a free trial; add
trialDays(and optionallygrantCreditsOnTrial) to any paid plan to offer one.
PricePlan fields
Each entry in plans is a PricePlan (packages/config/src/types/index.ts).
| Field | Type | Notes |
|---|---|---|
id | string | Stable code identifier (e.g. "pro"); becomes user.plan |
type | "subscription" | "one_time" | "free" | Billing model |
name | string | i18n key - resolved at render |
description | string | i18n key |
features | string[] | i18n keys - bullet list |
limits | string[] | i18n keys - usage limits (optional) |
prices | PriceOption[] | One entry per interval |
featured | boolean | Highlight as recommended (only pro by default) |
credits | number | Granted on subscribe - see Credits |
creditInterval | number | Days between credit refresh (e.g. 30) |
apiRateLimit | ApiRateLimitConfig | { maxRequests, timeWindow? } per plan; timeWindow defaults to 24h (86400000 ms) |
trialDays | number | Free trial length |
grantCreditsOnTrial | boolean | Grant credits during trial (default false) |
name, description, features, and limits are i18n keys, not literal copy. Edit packages/i18n/translations/en/web.json - never hardcode text in pricing.ts. See i18n.
PriceOption fields
A plan's prices array carries one PriceOption per billing interval.
| Field | Type | Notes |
|---|---|---|
interval | "month" | "year" | "lifetime" | Billing cycle |
amounts | Record<string, number> | Major units keyed by currency (e.g. { USD: 29, EUR: 26 }) |
anchorAmounts | Record<string, number> | Optional strike-through "was" prices |
stripePriceId | string | Provider price ref (Stripe) |
polarProductId | string | Provider price ref (Polar) |
featured | boolean | Highlight this option (e.g. "Best Value") |
Every currency code used in amounts must exist in config.currency.list, or it can't be selected or displayed.
A plan, annotated
The pro plan shows the full shape - two intervals, anchor pricing, and one provider ref per price. The generated config carries only the provider you picked at init (stripePriceId for Stripe, polarProductId for Polar) and amounts in the single currency you chose:
// packages/config/src/pricing.ts
{
id: "pro",
type: "subscription",
name: "pricing.plans.plan_3.name", // i18n key
description: "pricing.plans.plan_3.description",
featured: true, // recommended badge
features: ["pricing.plans.plan_3.features.item_1", /* … */],
prices: [
{
stripePriceId: "", // your chosen provider's ref - fill from the dashboard
interval: "month",
amounts: { USD: 29 },
anchorAmounts: { USD: 39 }, // strike-through
featured: true,
},
{ stripePriceId: "", interval: "year", amounts: { USD: 290 }, anchorAmounts: { USD: 349 } },
],
credits: 50, // granted on subscribe
creditInterval: 30, // refresh every 30 days
apiRateLimit: { maxRequests: 5000 },
}Generated plans ship with empty stripePriceId/polarProductId strings. Create the matching prices in your provider dashboard and paste the IDs back here, or checkout fails.
Currency
Display currency comes from config.currency in packages/config/src/index.ts.
| Key | Type | Default | Description |
|---|---|---|---|
base | string | "USD" | Base currency code for pricing |
list | Currency[] | USD, EUR | Selectable currencies (symbol, code, place, space) |
countryMap | { default } & Record<string,string> | 27 EU → EUR | Maps ISO country code → currency code |
- The
mainCurrencyexport resolves thebasecurrency object fromlist. countryMap.defaultis the fallback when a visitor's country isn't mapped.- Currency selection + rendering are framework-specific - see Data fetching and Theming.
Frequently asked questions
Why isn't pricingConfig on config?
Pricing is a large, frequently-edited data set, so it lives in its own module to keep config/src/index.ts lean. Import it directly: import { pricingConfig } from "@repo/config".
Do plans render when payments are off?
Yes. Plan cards always render from pricingConfig; only checkout and the active provider are hidden when config.payment.enabled is false.
How do I add a currency?
Add a Currency object to config.currency.list, then add that code to every amounts/anchorAmounts map you want to support. Codes missing from list are unselectable.
What's the difference between a plan and a product?
Plans set a recurring entitlement and change user.plan; one-time products grant standalone ownership and never touch user.plan. See One-time products.