← Blog

A SaaS pricing page built from the plan config, and the two values that still drift

Build the pricing page from the plan config that enforces quotas and model access, then guard the two values that still drift: feature copy and Stripe.

A pricing page is a promise, and in an AI product it is an expensive one: "1,000 requests on Opus" is a cost line. If the page says more than the code enforces, customers hit a 402 they were never warned about. If the code allows more than the page says, you pay for the difference. The kit keeps the two in line by building the pricing page from the plan config, the same objects the quota check and the model gate read. That covers most of it. Two values still come from somewhere else, and while checking them for this post I found a way to move a paying customer to the Free plan just by raising your prices.

One plan object, four readers

Every plan lives in src/lib/billing/plans.ts:

export interface Plan {
  id: PlanId;
  label: string;
  /** Display price in USD/month. The real charge is whatever the Stripe price says. */
  priceUsd: number;
  /** Stripe Price ID (price_...). Undefined for the free plan. */
  stripePriceId?: string;
  /** Hard cap on model requests per calendar month. */
  monthlyRequestLimit: number;
  /** Models this plan may use. Requests for anything else are clamped to defaultModel. */
  allowedModels: ModelId[];
  /** Model used when the client doesn't (or isn't allowed to) pick one. */
  defaultModel: ModelId;
  /** Short selling points for the pricing page. */
  features: string[];
}

Four places read it:

  • /pricing maps over [PLANS.free, PLANS.pro, PLANS.scale] and renders label, priceUsd and features for each.
  • The chat route gets the plan from checkQuota. It returns a 402 once monthlyRequestLimit is spent, and clamps the requested model to allowedModels, falling back to defaultModel. How the clamp works is in gating Claude models by subscription plan.
  • Checkout validates the plan with z.enum(["pro", "scale"]) and charges PLANS[plan].stripePriceId. It returns a 500 if that price is not configured.
  • The Stripe webhook maps a subscription back to a plan with planByPriceId(item.price.id).

Even the over-quota error is built from the plan rather than typed separately, so it cannot disagree with the cap:

error: `You've used all ${quota.limit} requests on the ${quota.plan.label} plan this month.`,

Drift point one: the feature strings

The cards show features, and those are literal strings:

pro: {
  monthlyRequestLimit: 1_000,
  allowedModels: ["claude-opus-4-8", "claude-sonnet-5", "claude-haiku-4-5"],
  features: [
    "1,000 requests / month",
    "Claude Opus 4.8, Sonnet 5 & Haiku 4.5",
    "Usage dashboard",
  ],
},

The first two lines repeat monthlyRequestLimit and allowedModels by hand. Set the limit to 500 and the card still says 1,000 while the 402 fires at 500. Typecheck, lint and build all pass. Nothing knows the string is a number.

The fix is to derive the lines that state an enforced limit and keep features for what the code cannot express:

import { MODELS } from "@/lib/ai/models";
import type { Plan } from "@/lib/billing/plans";

const requests = new Intl.NumberFormat("en-US");
const models = new Intl.ListFormat("en", { style: "long", type: "conjunction" });

export function planFeatures(plan: Plan): string[] {
  return [
    `${requests.format(plan.monthlyRequestLimit)} requests / month`,
    models.format(plan.allowedModels.map((id) => MODELS[id].label)),
    ...plan.features, // now only the extras: "Usage dashboard", "No credit card required"
  ];
}

Run against the real config, the Pro card comes out as "1,000 requests / month" and "Claude Opus 4.8, Claude Sonnet 5, and Claude Haiku 4.5". The labels come from the same model registry that holds the prices. You give up some hand-tuned phrasing: "Sonnet 5 & Haiku 4.5" becomes the longer list, and Scale shows its models instead of "Everything in Pro". For the lines a customer can hold you to, that is the right trade. The kit's plans.ts still has the literal strings today. The function above is the change I would make.

Drift point two: the price Stripe actually charges

priceUsd says so in its own comment: it is a display price, and the real charge is whatever the Stripe price says. The two are tied by nothing except care. Stripe prices are effectively immutable: in stripe-node 22.2.0, PriceUpdateParams has no unit_amount. So a price change means creating a new Price, which gets a new ID, then pointing STRIPE_PRICE_PRO at it and remembering to edit priceUsd as well.

A check that runs in CI with test keys, or before each deploy, catches the mismatch:

import { PAID_PLANS } from "@/lib/billing/plans";
import { stripe } from "@/lib/billing/stripe";

export async function checkPlanPrices(): Promise<void> {
  for (const plan of PAID_PLANS) {
    if (!plan.stripePriceId) throw new Error(`${plan.id}: no Stripe price configured`);
    const price = await stripe().prices.retrieve(plan.stripePriceId);
    const problems = [
      price.unit_amount !== plan.priceUsd * 100 &&
        `charges ${price.unit_amount} cents, the page says $${plan.priceUsd}`,
      price.currency !== "usd" && `currency is ${price.currency}`,
      price.recurring?.interval !== "month" &&
        `bills per ${price.recurring?.interval ?? "one-time"}`,
      !price.active && "price is archived",
    ].filter(Boolean);
    if (problems.length) throw new Error(`${plan.id} (${price.id}): ${problems.join("; ")}`);
  }
}

unit_amount is an integer in cents, which is why the comparison multiplies by 100. The interval check catches the classic copy-paste mistake of putting a yearly price id in the monthly slot. I typechecked this against the installed SDK, but it is not in the kit yet.

The price change that downgrades paying customers

Here is the trap. When you point STRIPE_PRICE_PRO at the new price, existing subscribers stay on the old one. Stripe does not move them. The webhook's reverse lookup only knows the current id, and an unknown price falls through to Free:

const plan = item?.price?.id ? planByPriceId(item.price.id) : null;
// ...
plan: plan?.id ?? "free",

I ran it with STRIPE_PRICE_PRO set to a new id: planByPriceId returned null for the old price. So the next customer.subscription.updated event for a grandfathered subscriber writes plan: "free". From then on getUserPlan serves them 25 requests a month on Haiku, while Stripe keeps charging them $20.

The fix is to keep old prices mapped:

/** On Plan: prices existing subscribers may still be on after a price change. */
legacyPriceIds?: string[];

export function planByPriceId(priceId: string): Plan | null {
  return (
    Object.values(PLANS).find(
      (p) => p.stripePriceId === priceId || p.legacyPriceIds?.includes(priceId),
    ) ?? null
  );
}

Log loudly when a price maps to nothing instead of quietly writing "free". Alternatively, map by price.product, which stays the same when you add a new price under the same product.

Things that bite

  • Prose is a promise too. If your FAQ says when a plan switch takes effect, check it against the customer portal settings. Stripe schedules an update for the end of the period only when one of the portal's subscription_update.schedule_at_period_end conditions matches. Otherwise the switch is immediate, and so is the webhook's plan change.
  • Free has no price id, on purpose. The checkout enum leaves "free" out, so a crafted request cannot start a $0 subscription. The page links Free to sign-up instead.
  • The cap and the copy should share a unit. "Requests / month" on the card has to mean what checkQuota counts: one row per model request, counted per UTC calendar month. If you meter tokens, the card should say tokens.

Why the free tier is capped and Haiku-only in the first place is covered in preventing free-tier abuse in an AI SaaS. Shipwright, the Next.js and Claude starter kit this blog documents, ships this plan config wired to the quota check, the model gate, Checkout and the webhook. The live pricing page is rendered from it.