Entitlement gates
Gate UI by capability and feature with FeatureGate and RequireCapability.
Gate UI by capability and feature with FeatureGate and RequireCapability.
Entitlement gates reveal a subtree only when the current subscriber's pinned plan grants it. <FeatureGate> checks a feature flag; <RequireCapability> checks a numeric or boolean capability. Both are self-managed — they read the entitlement snapshot through the SDK hooks, so you pass nothing beyond the key under <FartherShoreRoot>.
These mirror the platform's contract: a @Capability in your @Product class declares what a plan can do, and @Plan grants it. CronCloud declares @Capability("managed-cron", { includesFeatures: ["cron-jobs"] }) and both plans grant it (Starter caps cron_jobs: 10, Pro cron_jobs: 100). See Capabilities and Plans.
import { FeatureGate, RequireCapability } from "@farthershore/farthershore-js/components";
<FeatureGate>Reveal children only when the pinned plan enables a feature gate. While the snapshot resolves it renders nothing (no flash of forbidden content, but aria-busy so assistive tech announces the pending state). On denial it renders your renderUpsell (an upsell CTA) or renderDisabled (a plain disabled affordance), else nothing.
import { FeatureGate, UpgradePrompt } from "@farthershore/farthershore-js/components";
import { FeatureNotGrantedError } from "@farthershore/farthershore-js";
<FeatureGate
feature="cron-jobs"
renderUpsell={() => (
<UpgradePrompt error={new FeatureNotGrantedError("cron-jobs")} />
)}
>
<CronJobsEditor />
</FeatureGate>;
interface FeatureGateProps {
feature: string; // the feature-gate key on the pinned plan
children?: ReactNode; // revealed when granted
renderUpsell?: () => ReactNode; // denied + you want an upsell CTA (wins)
renderDisabled?: () => ReactNode; // denied, plain disabled affordance
}
Resolving → nothing; denied → renderUpsell ?? renderDisabled ?? nothing.
<RequireCapability>Reveal children only when the pinned plan satisfies a capability. For a numeric capability the plan's limit must be >= min (default 1 — "the plan allows at least one"). For a boolean capability min is ignored and the toggle must be true.
import { RequireCapability } from "@farthershore/farthershore-js/components";
// CronCloud: show the bulk-create UI only when the plan allows ≥ 5 cron jobs.
<RequireCapability capability="managed-cron" min={5} renderDisabled={() => <UpsellRow />}>
<BulkCreateCronJobs />
</RequireCapability>;
interface RequireCapabilityProps {
capability: string; // the capability key on the pinned plan
min?: number; // numeric: limit must be >= min (default 1)
children?: ReactNode;
renderUpsell?: () => ReactNode; // denied + you want an upsell CTA (wins)
renderDisabled?: () => ReactNode; // denied, plain disabled affordance
}
<FeatureGate>.That distinction — "absent on a known plan" vs "no plan at all" — is why the gates read hasSubscriber, not just the raw value.
When a component isn't a clean wrap, read the entitlement state directly. All derive from the same cached /me snapshot — no parallel fetches.
import {
useEntitlements,
useFeatureGate,
useCapability,
useCapabilityUsage,
} from "@farthershore/farthershore-js/react";
useFeatureGate(key)const { enabled, loading } = useFeatureGate("cron-jobs");
// Gate on `loading` first (enabled is false while loading) to avoid a flash.
if (loading) return null;
return enabled ? <CronJobsEditor /> : <Upsell />;
useCapability(key)const { value, hasSubscriber, loading } = useCapability("managed-cron");
// value: the raw limit (numeric cap | boolean toggle | null when absent/loading)
// hasSubscriber: false for signed-out / no-subscription (empty snapshot)
value is the raw limit; combine with hasSubscriber to apply the absent-vs-no-plan rule yourself.
useEntitlements()The full snapshot — { hasSubscriber, featureGates, capabilityLimits } — when you need to branch on several gates at once.
const { data, loading } = useEntitlements();
const canCron = data?.featureGates["cron-jobs"] === true;
const jobCap = data?.capabilityLimits["managed-cron"]; // number | boolean | undefined
useCapabilityUsage() — the "N of M" surfaceThe limit lives in the snapshot, but current usage is a live count, so this reads the dedicated /me/capability-usage route. It returns { limit, current } per capability for the pinned plan — render "7 of 10 cron jobs used". Signed-out / no subscription → an empty map.
const { data } = useCapabilityUsage();
const cron = data?.["managed-cron"]; // { limit: 10, current: 7 } | undefined
For the zero-prop rendered surface, mount <CapabilityUsageCard/> (from /components) — it wraps this hook and renders a "used / limit" row per capability for the pinned plan.
Gating the UI is a courtesy, not enforcement. The Gateway is the source of truth: an over-limit or under-entitled request is denied server-side (a LimitExceededError / FeatureNotGrantedError) even if your UI didn't gate it. Always handle those errors too — see gateway enforcement and limits & credits.
renderUpsell pairs naturally with <UpgradePrompt> plus the matching error. The same <UpgradePrompt> also handles a LimitExceededError thrown by a mutation at the cap, so a denied click and a gated section share one upsell affordance.
import { FeatureGate, UpgradePrompt } from "@farthershore/farthershore-js/components";
import { FeatureNotGrantedError } from "@farthershore/farthershore-js";
<FeatureGate
feature="cron-jobs"
renderUpsell={() => <UpgradePrompt error={new FeatureNotGrantedError("cron-jobs")} />}
>
<CronJobsEditor />
</FeatureGate>;
<FartherShoreRoot>.