Keeping a pricing page honest against your plans
Why pricing pages drift from the plans you actually bill, and how to render the table from your catalog instead of copying it, plus what to do with the numbers you cannot render.
In short
A pricing page drifts because it holds a second copy of your prices. Delete the copy: render the table from the published plan group so the page and checkout read one source. Keep what the catalog cannot express in a constants file, give it a verification date, and make a check fail when that date goes stale.
Count the prices on your own pricing page. Not the plan cards, which probably come from your billing system already. Count the title tag, the hero sentence, the FAQ that explains annual billing, the footnote under the comparison table. Each one is a second copy of a number your billing system also holds, and the copies are the only things on that page capable of lying.
Ours has four. I went looking while writing this, which is the only reason I can tell you how many, and I found the fourth one hiding inside the file I was about to hold up as the good example.
The usual advice is to sync. A webhook that writes prices into your database, a config file you push into Stripe, a cache you warm on deploy. All three keep the copy and promise to keep it fresh, which is a promise about your future attention rather than about your code. Hold fewer copies instead. The ones you cannot delete are debt, and debt gets a due date.
What you are building
One decision, written down: which numbers on your pricing page are rendered from the plan catalog at request time, and which are copies you have agreed to maintain by hand.
When it works, you publish a plan version in the console and the public table changes within about five minutes, with no deploy and no pull request. When it does not, a visitor reads "Plans from $49/mo" in their browser tab while the table underneath quotes something else, and no test in your suite has an opinion about the difference. The person who notices is a customer, and they notice at checkout.
Before you start
You need billing currencies set on the organization, at least one published plan version, and a pricing group whose slug you can paste into your site. Currencies come first and are hard to change later, so settle those before you build any plans. If you are still deciding how many pricing tables you need, that is a different question and worth answering before this one.
Step 1: count the copies
Open every file that renders your pricing route. Grep it for a currency symbol, for the word trial, and for any allowance figure. Write down what you find and where each number comes from.
You will end up with a table longer than you expected, sorted into exactly
three kinds of row. Here is ours, for website/app/pricing/:
| Surface | What it prints | Where the number comes from |
|---|---|---|
| Plan cards and comparison table | every price, allowance and overage rate | usePublicPlans(), fetched per request |
| Page title and meta description | "Plans from $49/mo" | PLAN_FACTS.entryPrice, a constant |
| Hero sentence and the trust badges | the trial length | PLAN_FACTS.trial.days, a constant |
| The annual-billing FAQ | "ten months for twelve" | typed by hand into the FAQ array |
| The saving badge on the year toggle | "-17%", "Save 17% with annual billing" | typed by hand into pricing-content.tsx |
Only the first row cannot drift. The second and third are derived from one constants file, so they move together and they move when somebody updates that file. The last two are prose that happens to agree with a constant we already have, which means they agree until the day they do not.
That last row is the one worth sitting with. Ten months for twelve is 16.7%, rounded to 17 by a person, typed into the same file that renders the live table. The honest half and the drifting half share a filename. Change the yearly multiplier in the console tomorrow and the plan cards update themselves while the badge beside them goes on promising 17%, and it will keep promising it until somebody reads that line on purpose.
Key takeaway
Sort every number on the page into rendered, derived or typed. Rendered cannot drift. Derived drifts as a group, on a schedule you control. Typed drifts alone and silently, which is why it is the row to delete first.
That sorting is the whole audit. Do it before you write any code, because step 2 is only worth doing to the rows it can actually move.
Step 2: render everything the catalog can answer
Anything that appears inside the page body can be fetched. Plan names, prices per interval and currency, what each plan includes, metered allowances and the overage rate past them: all of it travels in the public plans payload, so none of it needs a copy.
PricingPage is headless. It fetches the plan group and hands you the data,
and you own the markup:
import { PricingPage } from '@buildbase/sdk/react';
export function Pricing() {
return (
<PricingPage slug="main-pricing">
{({ loading, items, plans }) => {
if (loading) return <p>Loading plans...</p>;
return (
<div className="grid grid-cols-3 gap-4">
{plans.map((plan) => (
<article key={plan._id}>
<h3>{plan.name}</h3>
<ul>
{items
.filter(
(item) =>
plan.features[item.slug] ||
item.slug in plan.limits ||
item.slug in plan.quotas
)
.map((item) => (
<li key={item.slug}>{item.name}</li>
))}
</ul>
</article>
))}
</div>
);
}}
</PricingPage>
);
}After this step, changing a price in the console changes the page. The public plans response is cached for five minutes, and every item or category write clears that cache. So a change shows up on the next request. Or, if the cache clear does not land, once the five minutes run out. Cache clearing is best-effort. Plan for the slower of the two and you will never be surprised by the faster one.
Two details decide whether the rendered half is honest or merely live:
Read the allowance for the interval on screen. A plan's yearly tier can carry a larger allowance than its monthly one. If your toggle says yearly and your table still reads the monthly quota, you have built a live page that displays the wrong number, which is worse than a static one.
Take the overage rate from the currency's pricing variant. That figure, in
quotaOverages, is what billing actually charges. There is no second rate in
the payload to reach for, and inventing a per-unit price because the real one is
billed per thousand is how a page ends up off by three orders of magnitude.
Entitlement is resolved by item slug and never by position, so reordering or badging a row is presentation and changes nothing about what a workspace can do. That is what makes the pricing page safe to rearrange and unsafe to hand-write. It also means the slugs are doing real work, which is an argument for structuring the catalog deliberately before the page is built on top of it.
Step 3: give every surviving copy a date
Some numbers cannot be rendered, and pretending otherwise is how this advice
stops being useful. A Next.js title tag is built on the server before any
client-side fetch resolves. Ours reads Plans from $49/mo from a constant, and
it will keep reading from a constant, because the alternative is blocking the
page render on a billing API call to put a price in a browser tab.
So the copy stays. What it gets is a stamp:
// abbreviated: the real constant carries a few more keys
export const PLAN_FACTS = {
entryPlan: 'Launch',
entryPrice: '$49/mo',
trial: { days: 7, requiresCard: false },
billingIntervals: 'Yearly is ten months for twelve; quarterly is ~10% off',
verified: '9 September 2026',
} as const;Then put the stamp where a reader can see it. Our homepage plan summary prints
Prices in USD. Verified 9 September 2026. above the table. Publishing the date
is not decoration. It converts a silent copy into one that announces its own age,
and it means the first person to catch the drift can be anyone rather than
whoever happens to open the constants file.
Now the uncomfortable part. As I write this, that stamp is 38 days old. The mechanism worked exactly as designed: it told me. Nothing failed, because nothing was watching the date.
Step 4: make something fail when the stamp goes stale
A date nobody checks is a comment. Give it a check that exits non-zero:
// scripts/check-price-stamp.mjs
import { PLAN_FACTS } from '@ord/shared';
const MAX_AGE_DAYS = 30;
const stamped = new Date(PLAN_FACTS.verified);
const ageDays = Math.floor((Date.now() - stamped.getTime()) / 86_400_000);
if (Number.isNaN(stamped.getTime())) {
console.error(`check-price-stamp: cannot parse "${PLAN_FACTS.verified}"`);
process.exit(1);
}
if (ageDays > MAX_AGE_DAYS) {
console.error(
`check-price-stamp: PLAN_FACTS.verified is ${ageDays} days old ` +
`(max ${MAX_AGE_DAYS}). Re-check it against the live plans, then ` +
`update the stamp.`
);
process.exit(1);
}
console.log(`check-price-stamp: ${ageDays} days old, within ${MAX_AGE_DAYS}.`);Run it in CI on every pull request that touches the constants file, or on a schedule if you would rather be told on a Monday than at merge time. If your constants live in a workspace package, build it first: the import above resolves against the built output, not the source. I ran it against our own constants while writing. It exits 1 today, which is the correct answer and the reason this step exists.
Pick the threshold deliberately. Thirty days works if your prices are stable. If you are still moving prices weekly before launch, make it seven and accept the noise, because during that period the copy is wrong more often than it is right.
Warning
Do not point the check at a figure your billing system already renders. A check that guards the live table is a check that fails for no reason, and the first time someone silences it they will silence the one that mattered.
What breaks anyway, and how you find out
The plan version a customer bought is not the one on the page. Plans are versioned. The page renders what is published today; a subscriber keeps the version they signed up on. This is correct behaviour and it still produces support tickets. The customer reads your current page, then compares it to their invoice. Answer from their plan version, never from the page, and check entitlements by item slug rather than guessing from the plan name.
An item is on the plan but missing from the page. Check whether it is marked internal only. That flag drops the item from the public payload entirely and strips its slug from each plan's features, limits and quotas, so it is absent rather than merely unrendered. The plan still grants it. That is the intended behaviour for a rollout switch and a confusing half hour if you have forgotten the flag exists.
A badge that no data backs. Our pricing page picks the "most popular" plan by array index: with more than two plans it is the second one. That is a layout convention, not a fact about what customers buy, and the day somebody reorders the group the badge moves on its own. If a badge makes a claim, render it from something that can be wrong. If it is decoration, say so in a comment so the next person does not go looking for the query behind it.
The table went blank and the page did not. Decide now what renders when the fetch fails. Ours falls back to a card that carries no prices at all: it says pricing is being finalized, keeps the trial line, and offers a contact link. A fallback that quotes a price is a hardcoded price with extra steps, and it will be serving last quarter's numbers at the exact moment your API is down and nobody can check.
That last one is the whole post compressed into one decision. When you cannot reach the source of truth, say less. Do not guess fluently.
npm i @buildbase/sdk