BuildBaseBuildBase

Pricing Page

Order, group, highlight and badge what your plans include, and show metered allowances with their overage rate.

The order of rows on a pricing page is data you set in the console, not markup you hard-code. It travels in the public plans payload, so the console preview, <PricingPage> and usePublicPlans() all receive the same order, and a change in the console reaches every one of them without a deploy.

All of it is presentation only. Entitlement is resolved by item slug, never by position, so reordering, highlighting or badging an item on a published plan creates no new plan version, triggers no Stripe sync, and cannot change what any workspace is allowed to do.

What controls the order

Four fields on a product (subscription item) and one on its category:

FieldSet onEffect
sortOrderCategory (Billing → Product categories)Order of the sections in a comparison table, lowest first
sortOrderProduct (Billing → Products)Position within its category, lowest first
highlightProductMarks a selling point. Meant to lead a plan card ahead of plain sort order
badgeProductFree-text label shown beside the item - New, Beta, Add-on. Empty means no badge
internalOnlyProductStill gates the capability, but never appears on a public pricing surface

The public items array comes back sorted by category order, then the item's own order, then name. Ties fall back to the name, so items nobody has ordered read alphabetically rather than in whatever order they were created. The console tables, the item picker in the plan version editor and the console's pricing group preview use the same comparison, so what you approve in the preview is what ships.

Plan columns are ordered separately: plans comes back in the order the plans were added to the pricing group version. There is no reordering UI for plan columns yet.

Tip

Space sort values 10 apart (10, 20, 30). You can then slot a new item between two existing ones without renumbering everything after it.

Arranging the page in the console

Go to Billing → Products and click Reorder. The Arrange the pricing page dialog loads every item at once, grouped the way the pricing page groups them, so what you arrange is what a buyer reads.

  • Drag a section header to move a whole section; drag a row to move an item.
  • Or use the up and down arrows on each row, which work from the keyboard and on touch screens. At the end of a section they carry the row into the next one.
  • Dropping a row into a different section moves the item to that category.
  • Save order writes only what changed. Opening the dialog and saving without moving anything writes nothing.

To set a single value instead, edit the product and use Sort order, Highlight, Badge and Internal only. Category order is the Sort order field under Billing → Product categories.

The same moves are available over the API, one request per list:

curl -X PATCH https://api.console.buildbase.app/api/subscriptions/items/reorder \
  -H "Authorization: $BUILDBASE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"_id":"665f...","sortOrder":10},{"_id":"665e...","sortOrder":20,"category":"665a..."}]}'

category is optional per entry and moves the item to that category. Every referenced category is checked before anything is written, so a reorder naming an archived category is rejected whole rather than half-applied. Categories reorder the same way at PATCH /api/subscriptions/item-categories/reorder, without the category field.

The public plans response is cached for five minutes, and every item or category write clears that cache, so a new order shows on the next request. Clearing is best-effort: if it fails, the old order can last until the five minutes run out.

What internalOnly hides

An item marked Internal only, and any archived item, is dropped from the public items array. Its slug is also stripped from each plan's features, limits and quotas, so the flag is absent from the payload rather than merely unrendered. The plan still grants it: a workspace on that plan gets the capability, and your app can check it as usual.

Use it for flags that belong in a plan but not on a marketing page - a rollout switch, a support tier toggle, an internal quota.

Rendering it with PricingPage

<PricingPage> is headless. It hands you items and plans in the order above and leaves the layout to you, so the fields only show up if your render function reads them.

The SDK types do not declare sortOrder, highlight, badge or the category's sortOrder yet, although the API returns them. Widen the item type to read them:

import { PricingPage } from '@buildbase/sdk/react';

type PresentedItem = {
  slug: string;
  name: string;
  type: 'feature' | 'limit' | 'quota';
  highlight?: boolean;
  badge?: string;
  category: { name: string; slug: string };
};

function Pricing() {
  return (
    <PricingPage slug="main-pricing">
      {({ loading, items, plans }) => {
        if (loading) return <p>Loading plans...</p>;
        const rows = items as PresentedItem[];

        return (
          <div className="grid grid-cols-3 gap-4">
            {plans.map((plan) => {
              const included = rows.filter(
                (item) =>
                  plan.features[item.slug] ||
                  item.slug in plan.limits ||
                  item.slug in plan.quotas
              );
              // Highlighted items lead; the rest keep the configured order.
              const card = [
                ...included.filter((item) => item.highlight),
                ...included.filter((item) => !item.highlight),
              ].slice(0, 6);

              return (
                <div key={plan._id}>
                  <h3>{plan.name}</h3>
                  <ul>
                    {card.map((item) => (
                      <li key={item.slug}>
                        {item.name}
                        {item.badge && <span> {item.badge}</span>}
                      </li>
                    ))}
                  </ul>
                </div>
              );
            })}
          </div>
        );
      }}
    </PricingPage>
  );
}

For a comparison table, group consecutive items by category.slug. They already arrive in section order, so a single pass is enough and you never sort on the client.

The pricing page on buildbase.app reads the same payload and goes one step further: when a plan includes everything the plan below it does, its card reads "Everything in Grow, plus" (naming the plan below) and lists only what is new or larger. That check is computed per render, and a plan that drops something the cheaper one has falls back to its full list. It is a pattern for your own render function, not something <PricingPage> does for you.

Metered allowances and overage

A metered item (type: 'quota') has two halves a buyer wants to see: how much is included, and what happens past it. Both are in the payload. Resolve them for the interval and currency on screen with getQuotaDisplayWithVariant, and format them with formatQuotaWithPrice:

import {
  formatQuotaWithPrice,
  getQuotaDisplayWithVariant,
  PricingPage,
} from '@buildbase/sdk/react';

function ComparisonTable({ interval, currency }) {
  return (
    <PricingPage slug="main-pricing">
      {({ items, plans }) => (
        <table>
          <tbody>
            {items.map((item) => (
              <tr key={item.slug}>
                <td>{item.name}</td>
                {plans.map((plan) => (
                  <td key={plan._id}>
                    {item.type === 'quota'
                      ? formatQuotaWithPrice(
                          getQuotaDisplayWithVariant(
                            plan,
                            currency,
                            item.slug,
                            interval
                          ),
                          item.name.toLowerCase(),
                          { currency }
                        )
                      : item.type === 'limit'
                        ? plan.limits[item.slug]
                        : plan.features[item.slug]
                          ? 'Yes'
                          : '-'}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </PricingPage>
  );
}

A few things to get right, all of which the helpers handle if you pass them the interval and currency the visitor is looking at:

  • The allowance follows the billing toggle. A plan's yearly tier can carry a larger allowance than its monthly one, so read the quota for the interval on screen rather than always monthly.
  • The overage rate comes from the currency's pricing variant. quotaOverages on the variant is what billing charges, and the public payload carries no other figure. getQuotaDisplayWithVariant reads it from there for the currency you pass.
  • The unit size belongs in the rate. A rate billed per 1,000 reads "then $2.00 / 1,000 emails", not a bare per-unit price.
  • No overage means a hard limit. When allowOverage is false the allowance is a ceiling, which is a different offer from one that costs more past the line. formatQuotaWithPrice prints it as "10000 included (hard limit)".

Pricing is in cents; the payload's notes field says as much. formatQuotaWithPrice converts for you.

On the buildbase.app pricing page, a plan card draws metered allowances and hard ceilings in their own block above the feature list, and the comparison table prints the same figure from the same function, so the card and the table cannot disagree. The six-row card limit is applied to the ordered list before that split, so which rows make the card stays the admin's decision.

Next Steps