ShipAddons

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

StatusMeaning
200User is entitled - proceed with feature
401Not authenticated - user needs to log in
403Not 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 BASIC

Check Feature Entitlement (No Auth)

import { isEntitledToFeature } from "@/lib/entitlements";

isEntitledToFeature("PRO", "essential_feature"); // true
isEntitledToFeature("BASIC", "pro_feature"); // false

Adding 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 StatusEffective Plan
activeUser's actual plan
trialingUser's actual plan
past_dueBASIC (no paid features)
canceledBASIC (no paid features)
null (no subscription)BASIC

Error Handling

Common entitlement errors:

ErrorCauseSolution
"Unauthorized"Missing or invalid JWTUser needs to authenticate
"Upgrade to PRO plan required"User's plan is too lowShow upgrade prompt
"Feature not configured"Feature not in FEATURE_REQUIREMENTSAdd feature to config

Best Practices

  1. Use feature names, not plans: Gate by feature (export_pdf) not plan (PRO). This makes it easy to move features between tiers.

  2. Centralize configuration: Keep all plan/feature mappings in entitlements.ts. Don't hardcode plan checks in individual routes.

  3. Handle errors gracefully: Show upgrade prompts, not generic errors, when users hit entitlement limits.

  4. Log access attempts: Consider logging when users hit entitlement limits for analytics.

  5. Cache appropriately: Subscription data doesn't change often. Consider caching for performance.

On this page