Skip to content

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 ​

ts
// 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:

ts
// 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:

  1. Create a product (e.g., "Starter Plan")
  2. Add a recurring price (e.g., $9/month)
  3. Copy the price ID (price_...) to the corresponding environment variable
PlanEnvironment variableProduct example
StarterSTRIPE_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:

ts
// 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 ​

  1. Update limits – edit planLimits in apps/api/lib/plans.ts
  2. Update auth config – add/edit the plan entry in apps/api/lib/auth.ts
  3. Create Stripe product – add the product and price in the Stripe Dashboard
  4. Set env var – add the new STRIPE_*_PRICE_ID to .env.local and Cloudflare secrets
  5. Update UI – add the plan option to the billing card in apps/app/routes/(app)/settings.tsx