Skip to content
BuildBase

Entitlement Checks

Gate a feature, a limit or a quota by its slug, with the answer the server composes for each workspace.

Gate what a workspace may do by the slug your code names, not by the plan it is on. BuildBase composes one answer per workspace, the plan plus anything granted on top of it, and the SDK reads it. A check follows a plan change, an add-on or an override on its own.

Note

Requires @buildbase/sdk 1.0.0 or later. useEntitlement, useLimit, the When... gates on this page and invalidateEntitlement arrive with it; @buildbase/sdk 0.0.90 has none of them. On a server without the entitlement, the hooks read the older fields: a workspace feature as WhenWorkspaceFeatureEnabled reads it, a limit only for members who can read the subscription, and a quota from its usage.

import {
  useEntitlement,
  useLimit,
  WhenEntitled,
  WhenWithinLimit,
} from '@buildbase/sdk/react';

function Projects({ projects }: { projects: Project[] }) {
  const analytics = useEntitlement('advanced-analytics');
  const limit = useLimit('max-projects', { current: projects.length });

  return (
    <>
      <WhenEntitled
        slug="advanced-analytics"
        fallbackComponent={<UpgradeCard />}
      >
        <Analytics />
      </WhenEntitled>

      <WhenWithinLimit
        slug="max-projects"
        current={projects.length}
        fallbackComponent={<UpgradeCard />}
      >
        <NewProjectButton />
      </WhenWithinLimit>

      {limit.pressure === 'nearing' && (
        <p>{limit.remaining} projects left on your plan.</p>
      )}
    </>
  );
}

Why by slug

WhenSubscriptionToPlans plans={['grow']} answers "is this workspace on Grow". It is right for upsell copy and wrong for gating a capability: it breaks the day you add a plan, rename one, or sell the feature as an add-on. Gate on the slug instead. The plan editor sets what each plan includes, and the check reads it.

The answer

useEntitlement(slug, options?) answers a feature, a limit or a quota slug:

FieldMeaning
kindfeature, limit, quota, or unknown when the plan does not mention the slug
stategranted, locked or unknown
allowed, lockedallowed is state !== 'locked'; locked is state === 'locked'
limitThe ceiling for a limit, included for a quota; null for a feature or when unlimited
unlimitedThe limit has no ceiling
used, remainingYour current for a limit; the workspace's usage for a quota
sourceWhere the answer came from: plan, floor, add, override or default (see below)
standingentitled (a live plan), lapsed (payment failed past its retries, or paused) or none (no plan)
planSlugThe plan's slug; null with no plan
expiresAtWhen the earliest grant behind the answer ends, if any
loading, errorloading until this workspace's answer is in; error when it could not be read
refetchRead it again

Options: current (how many the workspace has, for a limit), wanted (how many to add, default 1), subject: { workspaceId } for a workspace other than the selected one, and strictAbsent.

For a limit, pass the count you already hold: the screen that lists the projects knows how many there are. A quota counts as allowed while it has units left, or past that when the plan allows overage.

Fails open

Only an explicit no locks. While the answer loads nothing locks, a failed read is unknown, and a slug the plan does not mention is unknown, all of which allow. That keeps a workspace on an older plan version, written before you added a slug, from losing the feature the day you start gating on it.

Once every plan version you publish carries every slug you gate on, read an unmentioned slug as locked:

<SaaSOSProvider entitlement={{ strictAbsent: true }} {...rest}>

or per check, useEntitlement('advanced-analytics', { strictAbsent: true }).

A workspace with no plan, or one whose payment lapsed, is not unknown: its answer is known, and its paid features read locked. standing says which. See Failed payments for how a payment lapses.

Where the answer came from

source names the layer that decided the slug:

  • plan: the plan the workspace holds. During a downgrade that waits for the end of the period, features and limits stay on the plan being left until then, and quotas move at once.
  • floor or add: a grant on top of the plan, such as an add-on or a comp.
  • override: a workspace feature the plan does not govern, read from the workspace's own setting.
  • default: a workspace feature at its default.

Limits

useLimit(slug, { current }) reads a limit against your count: limit, unlimited, used, remaining, reached and pressure. pressure is quiet, nearing or reached; nearing is the last fifth, never less than the last one, so a plan of three reads nearing at two. An unlimited or unknown limit never reads reached, and a ceiling of zero always does.

Gates

ComponentRenders children when
<WhenEntitled slug>the workspace is entitled (unknown counts, by default)
<WhenNotEntitled slug>it is not: the upsell side
<WhenLimitReached slug current>the limit is reached
<WhenWithinLimit slug current>there is room, or the limit is unlimited or unknown
<WhenUserEntitled slug>the signed-in person's own plan and grants allow it

Each takes loadingComponent and fallbackComponent, and the entitlement gates take whileUnknown: 'allow' (the default) treats an unknown answer as entitled, 'deny' as not. A gate showing its locked side reports feature_gate_shown, and a reached limit limit_gate_shown, once per page; see Events.

WhenWorkspaceFeatureEnabled stays the gate for a flag your users toggle. It renders nothing for a slug it has no value for, unless you pass whileUnknown="allow".

Keeping it fresh

The SDK's own billing calls (a checkout, a plan change, a cancellation) refresh the answer. After a change made somewhere else, call invalidateEntitlement():

import { invalidateEntitlement } from '@buildbase/sdk';

await fetch('/api/addons', { method: 'POST', body });
invalidateEntitlement();

When the BuildBase API refuses a call with 402 and PLAN_LIMIT_REACHED or FEATURE_NOT_IN_PLAN, the SDK refreshes the answer for you, at most once every five seconds.

On your server

const { allowed, remaining } = await bb.entitlements.check({
  workspaceId,
  slug: 'max-projects',
  current: await countProjects(workspaceId),
});

if (!allowed) {
  return Response.json({ error: 'Project limit reached' }, { status: 402 });
}

bb.entitlements.get({ workspaceId }) returns the whole answer. Both read the workspace, so a member without billing permission is answered too. On the MCP server the tool is check_entitlement.