Guides
Build guide

Structuring a product catalog you will not regret

How to structure a product catalog for SaaS billing: what counts as one product, why prices belong on plan versions, and why the slug is the part you cannot rename later.

Dharmendra Jagodana9 min read

In short

Structure a catalog in three layers: categories group products, products are the things you sell, and prices live on each plan version rather than on the product. Entitlement resolves on the product slug, so pick slugs your code can check and leave display names free to change.

Naming a product feels like a marketing decision. It is an API design decision, and you find that out about six months later, when someone asks to rename Pro to Business and the real question turns out to be whether every workspace on it quietly loses a feature.

Here is the split that decides everything else. A product carries no price. Prices live on each plan version, per currency, under pricingVariants. What a product carries is a slug, and the slug is what entitlement resolves against: a published plan version stores its grants as plain objects keyed by slug, so limits['max-oauth-clients'] is the contract between your catalog and your code. The display name is copy. You can change that on a Tuesday afternoon and nothing anywhere notices.

The hierarchy itself is the easy part. Categories, products, plans: every billing vendor draws those same three boxes and every guide to billing explains them. The part nobody warns you about is which field is permanent.

What you are building

A catalog where the string your code checks and the string your pricing page prints are deliberately different things, and only one of them gets to be permanent.

When it works, you rename a tier in the console and nothing deploys. You move a feature from your middle plan to your entry plan and nothing deploys either, because that is an assignment stored on a plan version, not a line of code. The header of our own entitlement registry says it more precisely than I can:

Code owns the namespace: which slugs exist, what kind each is, and how they pair. The database owns the assignment: what a given plan version grants.

When it does not work, you have a product called pro-features that three plans grant, nobody can explain, and no enforcement point actually reads.

Before you start

You need an organization with Stripe connected, and your billing currencies settled first, because a plan version has to carry a price in every currency you bill in. You also need at least one workspace feature before you start, because a feature-type product is rejected without one. Each maps to exactly one product. Define them first and the wiring falls out of that.

Step one: decide what one product is

A product is one thing you can switch on, cap, or count. If you cannot name the moment your code asks about it, it is not a product. It is a bullet on a pricing page, and those are free.

We give every item in our registry a gating class, decided by three questions in this order:

  1. Would gating this make a new customer leave before they see the product work? Then it is core: it ships on every plan and never carries a fence. Gate the thing someone needs in order to see why you are worth paying for, and you lose more in churn than you ever earn in upgrades.
  2. Does each unit cost you money at the moment it is used, or for as long as it exists? Then it is a cost item. Cap it or meter it to protect margin, not to drive upgrades.
  3. Does it have a distinct buyer, a distinct support burden, and no meaningful "some of it"? Then it is a differentiator, and it can carry a real fence.

Anything that is none of the three is either governance - retention and audit sold as a tier attribute - or not a SKU at all.

Run our own catalog through that and the shape is not what a pricing page suggests. Of 197 items, 82 are differentiators, 76 are cost control, 21 are core and 18 are governance. Sixty-eight of them are marked internal only, which means they gate something but never appear on a public pricing surface.

Key takeaway

Most of a mature catalog is not a sales surface. Roughly a third of ours is internal. Plan for that from the start, or you will end up writing marketing copy for a quota whose only job is to stop one customer filling a disk.

Another number to sit with: 114 of those 197 items are limits, 17 are quotas, and only 66 are features. What people picture as a feature list is mostly numbers.

Step two: write the slug for your code, not your pricing page

Create the category first. It is required on every product, so you cannot defer it, and the console is explicit that a category counts for nothing until a product sits in it. We run nine: auth, billing, messaging, automation, tenancy, content, platform, hosting and support. Platform is the biggest at 61 items, which tells you something about where a catalog actually grows.

Then the slug. Three different surfaces address a product by it, which is the whole argument for treating it as an interface:

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

function Dashboard() {
  return (
    <WhenWorkspaceFeatureEnabled slug="advanced-analytics">
      <AdvancedAnalytics />
    </WhenWorkspaceFeatureEnabled>
  );
}
import BuildBase from '@buildbase/sdk';

const bb = BuildBase({ serverUrl, orgId, getSessionId });

const usage = await bb.usage.record(workspaceId, {
  quotaSlug: 'api-calls',
  quantity: 1,
});

if (usage.available <= 0) {
  return Response.json({ error: 'Quota exceeded' }, { status: 429 });
}

And the plan version itself, which stores features, limits and quotas as plain objects keyed by the same strings. Notice what is not in any of those three places: the product's name, its category, its sort order, its badge.

So name the slug after the capability, not the tier. advanced-analytics is a capability. pro-analytics is a tier that will be renamed. No file states our whole convention, which is a gap rather than a secret. But the pattern across 197 rows is consistent enough to copy: kebab-case throughout, a max- prefix on most counted ceilings (86 of our 114 limits carry it), and the feature named for the thing rather than for who gets it.

One constraint worth inheriting: keep slugs inside ^[a-z0-9_-]+$. Ours are written that way so every one is a valid Stripe lookup_key, which makes a future mirror into Stripe Entitlements a copy rather than a translation table. You get that for the price of not using spaces.

Step three: pair the switch with the ceiling

Most capabilities need two items, not one. A feature decides whether the thing exists; a limit or a quota decides how much of it you get. They are different enforcement points with different failure modes. Collapse them into one item and the catalog starts lying.

Our registry states the pairing with an example: custom-auth-domain decides whether the Domains tab exists at all, while max-auth-domains decides whether you may add a sixth. A limit therefore declares its parent feature, and a limit whose parent is locked reports as not-applicable rather than as zero. In our catalog 133 of the 197 items carry a parent, features included.

Which brings up the one numeric convention you should steal outright:

export const UNLIMITED = -1;

Unlimited is -1. Zero means none allowed. Absent means not on this plan at all. Three states, three values, no overloading. A 0 that means infinity is a bug waiting for its first reader: eventually a plan that grants nothing reads as a plan that grants everything, and that is the expensive direction to fail in. We have one field that still stores zero for unlimited, from before this was settled, and the only reason it has not bitten us is that exactly one boundary translates it. That is a thing to inherit on purpose, not by accident.

The matching rule on the read side: fail open on unknown. Asking whether a feature is granted returns granted, locked or unknown, and the gate allows on unknown. To draw a padlock you ask whether the state is explicitly locked, never whether it is simply not granted. A false unknown costs you one ungated feature. A false locked costs a paying customer a 402 on a screen they bought.

Step four: the half you are allowed to change

Order, names, badges and categories are presentation. Change them whenever you like. Reordering a plan that is already published is a plain save: it creates no new plan version, touches no Stripe product or price, and cannot change what any workspace is entitled to, because entitlement resolves by slug and ordering is stored on the item.

Two small things save you work later. Space your sort order values 10 apart, which is what the field's own documentation recommends and what ours should have been seeded with rather than all zero, so you can insert an item between two others without renumbering the rest. And use the highlight flag rather than sort order to lift a selling point to the top of a plan card, so the card leads with what sells the plan rather than with whatever sorts first.

What breaks later, and how you find out

You rename a slug. The API will let you. A PATCH accepts a new slug and checks it for duplicates against other products of the same type, and that is the end of its concern. Nothing rewrites the plan versions that already store grants under the old key, so the symptom is not an error. It is a customer who paid for something and now sees a padlock, reported days later, on a Saturday.

The supported move is to supersede rather than rename: keep the old item, point it at its successor, and let reads resolve either. Our own catalog carries five of these. The honest one to look at is auth-configs, which is live on real plans today and whose note reads:

The live production row max-oauth-clients replaces. Its description promised a count "by slug" and no auth model has one; no code read it. Kept on the plans that carry it, and served through max-oauth-clients.

A product we shipped, named, sold, and never enforced. The slug stays because plans carry it.

You picked the wrong type. A product is a feature, a limit or a quota, and the type is part of its identity: slugs are unique per type, not globally. Two rows in our registry carry the same sentence, which makes it a pattern rather than an accident: "The live DB row is a FEATURE item and cannot be renamed into a LIMIT - archive it and create this one." Deciding whether a thing is a switch or a number is therefore a step-one decision, not a detail.

You put a number on the pricing page that nothing counts. The cure is the test the Get Started checklist in your console already applies: a product counts as done once it is wired to a workspace feature or picked up by a plan. An item that is neither is a promise with no enforcement point behind it, which is exactly what auth-configs was.

When a smaller catalog is the right answer

Most of this pays off only once a catalog has grown past what one person can hold in their head. If you are selling three plans and gating four things, four products and one category is a complete and correct catalog. The discipline that matters reduces to one line: pick slugs you would be happy to find in your code two years from now.

The expensive version of this is not building the wrong catalog. It is building a reasonable one and then discovering that the one field you chose casually is the one field you cannot take back.

npm i @buildbase/sdk
billing
catalog
entitlements
onboarding

Frequently Asked Questions

What is the difference between a product and a plan?

A product is a thing you sell and it carries no price. A plan packages products and sets prices on a specific plan version, per currency, so you can re-price or run a promotion without touching the catalog at all.

Do you need product categories on day one?

Yes. Unlike most catalogs, a category is required on every product here, so you create at least one before your first product. Keep the set small: a category counts for nothing until a product sits in it.

Can you rename a product after customers are subscribed?

The display name, yes, freely. The slug is what entitlement resolves against, and published plan versions store their grants keyed by that slug, so changing it is a migration rather than an edit. The supported move is to supersede the old slug, not rename it.

Where do metered units belong in the catalog?

As their own quota-type products, separate from the feature that switches the capability on. Usage is recorded against a quota slug, so the thing you meter and the thing you gate are two items with two enforcement points.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime