Entitlements
Plan-based feature gating and access control
ShipAddons includes a robust entitlements system that lets you gate features based on subscription plans. This enables tiered pricing models where different plans unlock different capabilities.
The examples delivered with the boilerplate are used to check entitlements at API route level, but it will depend on the business use case where is best to check for entitlements.
The usage of entitlements is optional. Can be replaced by a credits-based system with ease.
How It Works
┌─────────────────┐ 1. API Request ┌─────────────────┐
│ React Client │──────────────────────────►│ Next.js API │
│ │ (with Bearer token) │ │
└─────────────────┘ └────────┬────────┘
│
│ 2. Check entitlement
▼
┌─────────────────┐
│ Entitlements │
│ Library │
└────────┬────────┘
│
│ 3. Fetch subscription
▼
┌─────────────────┐
│ Supabase │
└────────┬────────┘
│
│ 4. Compare plan levels
▼
┌─────────────────┐
│ Return Result │
│ (200/401/403) │
└─────────────────┘Plan Hierarchy
Plans are organized in a hierarchy where higher-tier plans inherit access to all lower-tier features:
// web/lib/entitlements.ts
export const PLAN_HIERARCHY: Record<Plan["id"], number> = {
BASIC: 0, // Free tier - no paid features
ESSENTIAL: 1, // Mid tier - includes essential features
PRO: 2, // Top tier - includes all features
};Key principle: A PRO user can access ESSENTIAL and BASIC features. An ESSENTIAL user can access BASIC features but not PRO features.
Feature Requirements
Map feature names to their minimum required plan:
// web/lib/entitlements.ts
export const FEATURE_REQUIREMENTS: Record<string, Plan["id"]> = {
basic_feature: "BASIC",
essential_feature: "ESSENTIAL",
pro_feature: "PRO",
// Add your own features:
// "export_pdf": "ESSENTIAL",
// "ai_analysis": "PRO",
// "team_collaboration": "PRO",
};Creating a Gated API Endpoint
Use checkEntitlement() to gate API endpoints by plan:
// web/app/api/pro_feature/route.ts
import { NextRequest } from "next/server";
import { corsOptionsResponse, corsJson } from "@/lib/cors";
import { checkEntitlement } from "@/lib/entitlements";
export async function OPTIONS(request: NextRequest) {
return corsOptionsResponse(request);
}
export async function GET(request: NextRequest) {
// Check if user has PRO plan
const result = await checkEntitlement(request, "PRO");
if (!result.entitled) {
return corsJson(
request,
{ success: false, error: result.error },
{ status: result.status }
);
}
// User is entitled - proceed with feature logic
return corsJson(request, {
success: true,
message: "Pro feature accessed successfully",
userPlan: result.userPlanId,
});
}Entitlement Result
The checkEntitlement() function returns a structured result:
interface EntitlementResult {
/** Whether the user is entitled */
entitled: boolean;
/** The user's current plan ID (null if no subscription) */
userPlanId: string | null;
/** The plan required for access */
requiredPlan: Plan["id"];
/** Error message if not entitled or auth failed */
error?: string;
/** HTTP status code to return */
status: number;
}Response Status Codes
| Status | Meaning |
|---|---|
200 | User is entitled - proceed with feature |
401 | Not authenticated - user needs to log in |
403 | Not entitled - user needs to upgrade plan |
Feature-Based Entitlements
For feature-based checks (instead of direct plan checks), use checkFeatureEntitlement():
// Check by feature name instead of plan
const result = await checkFeatureEntitlement(request, "ai_analysis");
if (!result.entitled) {
return corsJson(
request,
{ success: false, error: result.error },
{ status: result.status }
);
}This approach decouples your code from specific plan names, making it easier to restructure pricing tiers later.
Client-Side Usage
Call gated endpoints from the React add-on using authGet:
// addon/src/client/components/Home.tsx
import { authGet } from "../utils/api";
import { toast } from "sonner";
const handleTestFeature = async (featureName: string) => {
if (!session?.accessToken) {
toast.error("Please log in first");
return;
}
try {
const response = await authGet<FeatureResponse>(
`/api/${featureName}`,
session.accessToken
);
if (response.success) {
toast.success("Feature accessed!", {
description: response.message,
});
} else {
toast.error("Access denied", {
description: response.error || "Upgrade required",
});
}
} catch (err) {
toast.error("Request failed");
}
};Helper Functions
Check Plan Level
import { getPlanLevel } from "@/lib/entitlements";
getPlanLevel("PRO"); // returns 2
getPlanLevel("ESSENTIAL"); // returns 1
getPlanLevel("BASIC"); // returns 0
getPlanLevel(null); // returns 0 (defaults to BASIC)Check Plan Entitlement (No Auth)
import { isEntitledToPlan } from "@/lib/entitlements";
isEntitledToPlan("PRO", "ESSENTIAL"); // true - PRO can access ESSENTIAL
isEntitledToPlan("ESSENTIAL", "PRO"); // false - ESSENTIAL cannot access PRO
isEntitledToPlan("BASIC", "ESSENTIAL"); // false - BASIC cannot access paid
isEntitledToPlan(null, "BASIC"); // true - everyone can access BASICCheck Feature Entitlement (No Auth)
import { isEntitledToFeature } from "@/lib/entitlements";
isEntitledToFeature("PRO", "essential_feature"); // true
isEntitledToFeature("BASIC", "pro_feature"); // falseAdding a New Plan
To add a new plan tier (e.g., ENTERPRISE):
1. Update constants
// web/constants.ts
export const plans: Record<Plan["id"], Plan> = {
// ... existing plans
ENTERPRISE: {
id: "ENTERPRISE",
name: "Enterprise",
description: "For large teams",
priceInCents: 5000,
mode: "subscription",
features: ["Everything in Pro", "Dedicated support", "Custom integrations"],
},
};2. Update plan hierarchy
// web/lib/entitlements.ts
export const PLAN_HIERARCHY: Record<Plan["id"], number> = {
BASIC: 0,
ESSENTIAL: 1,
PRO: 2,
ENTERPRISE: 3, // Add new tier
};3. Add features for the new plan
export const FEATURE_REQUIREMENTS: Record<string, Plan["id"]> = {
// ... existing features
custom_integrations: "ENTERPRISE",
dedicated_support: "ENTERPRISE",
};Adding a New Feature
To gate a new feature:
1. Define the requirement
// web/lib/entitlements.ts
export const FEATURE_REQUIREMENTS: Record<string, Plan["id"]> = {
// ... existing features
export_pdf: "ESSENTIAL",
};2. Create the API endpoint
// web/app/api/export_pdf/route.ts
import { NextRequest } from "next/server";
import { corsOptionsResponse, corsJson } from "@/lib/cors";
import { checkFeatureEntitlement } from "@/lib/entitlements";
export async function OPTIONS(request: NextRequest) {
return corsOptionsResponse(request);
}
export async function POST(request: NextRequest) {
const result = await checkFeatureEntitlement(request, "export_pdf");
if (!result.entitled) {
return corsJson(
request,
{ success: false, error: result.error },
{ status: result.status }
);
}
// Generate PDF...
return corsJson(request, {
success: true,
pdfUrl: "https://...",
});
}Subscription Status Handling
The entitlements system only grants access for active subscriptions:
// Active statuses that grant entitlements
const activeStatuses = ["active", "trialing"];
// If subscription is not active, user is treated as BASIC
const effectivePlanId = isActiveSubscription ? subscription?.plan_id : null;| Subscription Status | Effective Plan |
|---|---|
active | User's actual plan |
trialing | User's actual plan |
past_due | BASIC (no paid features) |
canceled | BASIC (no paid features) |
null (no subscription) | BASIC |
Error Handling
Common entitlement errors:
| Error | Cause | Solution |
|---|---|---|
| "Unauthorized" | Missing or invalid JWT | User needs to authenticate |
| "Upgrade to PRO plan required" | User's plan is too low | Show upgrade prompt |
| "Feature not configured" | Feature not in FEATURE_REQUIREMENTS | Add feature to config |
Best Practices
-
Use feature names, not plans: Gate by feature (
export_pdf) not plan (PRO). This makes it easy to move features between tiers. -
Centralize configuration: Keep all plan/feature mappings in
entitlements.ts. Don't hardcode plan checks in individual routes. -
Handle errors gracefully: Show upgrade prompts, not generic errors, when users hit entitlement limits.
-
Log access attempts: Consider logging when users hit entitlement limits for analytics.
-
Cache appropriately: Subscription data doesn't change often. Consider caching for performance.