Plans & Pricing
Plan limits are defined once in apps/api/lib/plans.ts and referenced by the auth plugin config (plan definitions) and the tRPC billing router (query responses).
Plan Limits
// apps/api/lib/plans.ts
export const planLimits = {
free: { members: 1 },
starter: { members: 5 },
pro: { members: 50 },
} as const;This is the single source of truth for what each plan includes. Add new limit fields here – they'll automatically flow to both the auth plugin and tRPC responses.
Auth Plugin Configuration
Plans are registered with the @better-auth/stripe plugin in apps/api/lib/auth.ts. stripePlugin() proves the four required values are set first, so the config uses narrowed locals rather than the optional env fields:
// apps/api/lib/auth.ts (stripe plugin config)
stripe({
stripeClient: new Stripe(secretKey, {
appInfo: { name: "React Starter Kit" },
}),
stripeWebhookSecret: webhookSecret,
createCustomerOnSignUp: true,
subscription: {
enabled: true,
plans: [
{
name: "starter",
priceId: starterPriceId,
limits: planLimits.starter,
},
{
name: "pro",
priceId: proPriceId,
annualDiscountPriceId: env.STRIPE_PRO_ANNUAL_PRICE_ID,
limits: planLimits.pro,
freeTrial: { days: 14 },
},
],
},
});The free tier has no Stripe plan – users without an active subscription are treated as free. The limits objects are stored on the Stripe subscription metadata and returned by the plugin.
Stripe Dashboard Setup
For each paid plan, create a Product and Price in the Stripe Dashboard:
- Create a product (e.g., "Starter Plan")
- Add a recurring price (e.g., $9/month)
- Copy the price ID (
price_...) to the corresponding environment variable
| Plan | Environment variable | Product example |
|---|---|---|
| Starter | STRIPE_STARTER_PRICE_ID | "Starter Plan" – $9/month |
| Pro (monthly) | STRIPE_PRO_PRICE_ID | "Pro Plan" – $29/month |
| Pro (annual) | STRIPE_PRO_ANNUAL_PRICE_ID | "Pro Plan" – $290/year |
INFO
Use Stripe test mode during development. The price IDs are different between test and live modes.
How Limits Are Exposed
The billing.subscription tRPC procedure returns the current plan and its limits:
// apps/api/routers/billing.ts
const sub = await ctx.db.query.subscription.findFirst({
where: (s, { eq, and, inArray }) =>
and(
eq(s.referenceId, referenceId),
inArray(s.status, ["active", "trialing"]),
),
});
return {
enabled,
plan,
status: sub?.status ?? null,
limits: planLimits[plan as PlanName],
// ...
};When Stripe is disabled, the query returns enabled: false with free limits and the settings page omits billing controls. When Stripe is enabled but no active subscription exists, it returns enabled: true with the same free limits. Enforce limits in application logic – tRPC middleware for server-side checks, UI guards for client-side gating.
Adding or Modifying Plans
- Update limits – edit
planLimitsinapps/api/lib/plans.ts - Update auth config – add/edit the plan entry in
apps/api/lib/auth.ts - Create Stripe product – add the product and price in the Stripe Dashboard
- Set env var – add the new
STRIPE_*_PRICE_IDto.env.localand Cloudflare secrets - Update UI – add the plan option to the billing card in
apps/app/routes/(app)/settings.tsx