Feature Gating
Rank Pilot AI SEO controls access to tools and AI-heavy actions through a
shared plan/feature model, enforced both client-side (for UI/UX) and
server-side (for actual authorisation). The core pieces are the
usePlanFeatures and usePlanUsage hooks, the check-feature-access edge
function, and the LockedFeatureCard / upgrade-prompt components.
Data model
Two Supabase tables drive gating:
subscription_plans— one row per tier (Free,Growth,Pro,Enterprise), includingtier_name,monthly_price,yearly_priceandsort_order.plan_features— one row per(plan_id, feature_key)pair, withfeature_name,enabled(boolean) andlimit_value(an integer usage cap, ornullfor unlimited).
Feature keys checked across the product include basic_site_audit,
full_technical_audit, ai_keyword_research, competitor_analysis,
backlink_analysis, rank_monitoring, content_optimizer,
autopilot_basic, autopilot_full, unlimited_scans, export_reports,
custom_prompts, priority_ai_model, white_label_reports and
api_access.
usePlanFeatures
src/hooks/usePlanFeatures.ts is the single source of truth for "what tier is
this user on, and what can they do":
- Checks whether the current user has the
adminrole via thehas_roleRPC — admins get unrestricted access to every feature. - Reads the user's row from
user_subscriptions(tier,is_trial,trial_end_at,trial_tier). If a trial is active and not expired, the trial tier is used instead of the base tier; otherwise it falls back to the base tier, or"free"if there's no subscription row at all. - Maps legacy internal tier names to the display names used in
subscription_plans(free → Free,essential → Growth,pro → Pro,agency/enterprise → Enterprise). - Fetches the matching plan's
plan_featuresrows, plus all plans and all features (for building comparison tables).
It exposes:
userTier— the resolved, display-cased tier name.hasFeatureAccess(featureKey)—truefor admins, otherwise looks up whether the feature isenabledon the user's current plan.getFeatureLimit(featureKey)—nullfor admins (unlimited),0if the feature isn't enabled, otherwise the plan'slimit_value(nullmeaning unlimited).getRequiredTier(featureKey)— walks plans insort_orderand returns the name of the cheapest tier that has the feature enabled, defaulting to"Enterprise"if none do. This is what upgrade prompts point users towards.allPlans/allFeatures/userFeatures— raw data for rendering full comparison tables (used onPricing.tsxandBilling.tsx).
The demo account (demo@rankpilot.app) is hard-coded to resolve to the
agency (Enterprise) tier so sales demos always show full functionality.
usePlanUsage
src/hooks/usePlanUsage.ts builds on usePlanFeatures to compare actual
consumption against plan limits, so the UI can nudge users before they hit a
hard wall rather than only blocking them afterwards. It:
- Counts this month's
site_audits, all-timekeywords, and this month'sblog_articlesfor the current user (used as proxies forbasic_site_audit,ai_keyword_researchandcontent_optimizerusage). - For every enabled feature with a numeric
limit_value, computesused,limit,percent(capped at 100),isNear(≥80% consumed) andisOver(used ≥ limit). - Works out
nextTier— the next tier up from the user's current one insort_order, defaulting to"Growth"for a first-time free user or"Enterprise"past Pro. - Returns
items(all tracked usage),warnings(only the near-limit ones) andnextTier, whichBilling.tsxand other dashboard widgets use to show progress bars and "You're approaching your monthly limit — Upgrade →" messaging.
Server-side enforcement: check-feature-access
Client-side hooks are for UX only; the check-feature-access Supabase edge
function is the authoritative gate that back-end operations should call
before performing a restricted action. Given a feature_key and the caller's
auth token, it:
- Verifies the JWT and resolves the user.
- Grants unconditional access if the user has the
adminrole. - Looks up
user_subscriptionsthe same way as the client hook (respecting an active, unexpired trial'strial_tier). - Maps the tier to a plan name and fetches the matching
plan_featuresrow for the requestedfeature_key. - Returns
{ allowed: true, limit }if the feature is enabled on that plan, or{ allowed: false, limit: 0, required_tier }— withrequired_tierworked out by finding the lowest-sort_orderplan that has the feature enabled — if it isn't.
Any caller (edge function or frontend) can call this to enforce gating outside of direct UI checks, since it doesn't trust client-reported tier information.
Locked-feature UI
LockedFeatureCard(src/components/LockedFeatureCard.tsx) renders a dashed-border placeholder with a lock icon, a "{tier}feature" badge, a short explanation, and an "Upgrade to{tier}" button (colour-coded per tier — blue for Growth, amber for Pro, purple for Enterprise). It's used in place of a locked tool's normal UI, with theonUpgradecallback typically routing to/pricing.UpgradePromptandFeatureGateModalprovide inline/modal variants of the same upsell pattern for contextual prompts (e.g. hitting a usage limit mid-action) rather than a full page section.FeatureGateandPlanLimitBannercomponents wrap page sections or show persistent banners that read fromusePlanFeatures/usePlanUsageto decide whether to render the real content, a locked card, or a near-limit warning banner.AeoAccessGateis a specialised version of this pattern for the AEO/GEO add-on (seeplans-and-billing.md), showing an "Included" card for Enterprise and a "Buy 7-Day Pass" card for Pro, with Stripe Checkout wired directly into the gate.
What each tier unlocks (summary)
- Free — Basic Site Audit, daily rank tracking, suggestion-only Autopilot, unlimited scans/crawls, PDF/CSV export, Google Tools.
- Growth — adds full technical audit, AI keyword research, competitor and backlink analysis, AI content generation, Auto-Fix (20 pages/month) and API access.
- Pro — adds full Autopilot (push to CMS), Auto-Fix up to 100 pages/month, 500 AI content credits, custom prompts and a priority AI model.
- Enterprise — unlimited Auto-Fix, Core Web Vitals auto-fixes, white-label reports, and (on a paid, non-trial subscription) AEO/GEO mode included.
Exact limits and toggles are ultimately driven by the plan_features table,
so the values above reflect the current defaults shown on the pricing and
billing pages — always treat that table, not this document, as the live
source of truth.