For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: text/markdown.

Feature-based access

Gate TurboStarter web features by subscription plan. Define entitlements, enforce access in API routes, and show upgrade prompts in the UI.

Feature-based access lets you unlock parts of your product only when a customer is on the right billing plan. Teams on Free might get basic reports, while Premium unlocks collaboration, and Enterprise adds SSO and audit logs.

TurboStarter already models plans, variants, subscriptions, and orders in one shared billing config. This recipe shows how to turn that data into real access control — on the server and in the React UI — without scattering plan checks across your codebase.

TL;DR

  1. Declare typed feature keys in packages/billing/shared/src/config/features.ts.
  2. Attach those keys to each plan in the billing configuration.
  3. Resolve the active plan with getActivePlan() and check access with isFeatureAvailable().
  4. Protect API routes with enforceFeatureAvailable() middleware.
  5. Gate UI with the same helpers and nudge upgrades with getHigherPlans().

What you are building

Most SaaS products mix two kinds of restrictions:

TypeExampleTurboStarter helper
Boolean features"Teams" only on PremiumisFeatureAvailable()
Usage limitsMax 3 projects on FreecheckPlanLimit()

Boolean features answer "can this user open this screen or call this endpoint?" Limits answer "can they create one more of this resource?"

Both read from the same billing config, so your pricing table, API enforcement, and dashboard UI stay in sync.

How plan resolution works

When a user (or organization) checks out, webhooks sync purchases into your database. At runtime, TurboStarter combines:

  • Subscriptions — active recurring plans
  • Orders — successful one-time purchases
  • Entitlements — mobile store purchases merged on hybrid flows

getActivePlan() picks the highest matching plan and defaults to free when nothing is active:

packages/billing/shared/src/utils/plan.ts
export const getActivePlan = (summary?: Summary | Summary[]) => {
  // resolves subscriptions, orders, and entitlements
  // returns the highest plan id, or BillingPlan.FREE
};

getPlanFeatures() returns the cumulative feature set for a plan — higher tiers inherit everything below them:

packages/billing/shared/src/utils/plan.ts
export const getPlanFeatures = (id: string) => {
  const index = config.plans.findIndex((plan) => plan.id === id);
  return Array.from(
    new Set(config.plans.slice(0, index + 1).flatMap((p) => p.features)),
  );
};

That inheritance model means you only list new capabilities on higher tiers in features.ts, then spread lower tiers:

packages/billing/shared/src/config/features.ts
const FREE_FEATURES = {
  SYNC: "SYNC",
  BASIC_SUPPORT: "BASIC_SUPPORT",
  // ...
} as const;

const PREMIUM_FEATURES = {
  ...FREE_FEATURES,
  TEAM_COLLABORATION: "TEAM_COLLABORATION",
  ADVANCED_REPORTS: "ADVANCED_REPORTS",
} as const;

export const FEATURES = {
  [BillingPlan.FREE]: FREE_FEATURES,
  [BillingPlan.PREMIUM]: PREMIUM_FEATURES,
  // ...
} as const;

export type Feature = /* union of all feature values */;

One source of truth

Keep feature keys in features.ts, reference them in the billing config with Object.values(FEATURES[BillingPlan.PREMIUM]), and import the same constants everywhere else. Never hardcode "TEAM_COLLABORATION" in a component when the constant already exists.

Add the isFeatureAvailable helper

The kit ships getActivePlan() and getPlanFeatures() in @workspace/billing. Add a small helper that combines them — this is the function you will call from middleware, server actions, and React components.

Add this to packages/billing/shared/src/utils/plan.ts:

packages/billing/shared/src/utils/plan.ts
import type { Feature } from "../config/features";

export const isFeatureAvailable = <
  Entitlement extends { id: string; active: boolean; variantId?: string },
  Subscription extends { status: SubscriptionStatus; variantId: string },
  Order extends { status: PaymentStatus; variantId: string },
  Summary extends {
    entitlements?: Entitlement[];
    subscriptions?: Subscription[];
    orders?: Order[];
  },
>(
  summary: Summary | Summary[],
  feature: Feature,
) => {
  const plan = getActivePlan(summary);
  return getPlanFeatures(plan).includes(feature);
};

It is already re-exported through @workspace/billing via packages/billing/shared/src/utils/index.ts, so no extra export wiring is needed.

Wire features into the billing config

Each plan in packages/billing/shared/src/config/index.ts should list the features that plan unlocks for access control, not just marketing copy:

packages/billing/shared/src/config/index.ts
import { FEATURES } from "./features";

export const config = billingConfigSchema.parse({
  plans: [
    {
      id: BillingPlan.FREE,
      name: "plan.free.name",
      features: Object.values(FEATURES[BillingPlan.FREE]),
      limits: {
        projects: 3,
        members: 1,
      },
      variants: [
        /* ... */
      ],
    },
    {
      id: BillingPlan.PREMIUM,
      name: "plan.premium.name",
      features: Object.values(FEATURES[BillingPlan.PREMIUM]),
      limits: {
        projects: 10,
        members: 5,
        storage: null, // unlimited
      },
      variants: [
        /* ... */
      ],
    },
  ],
}) satisfies BillingConfig;

The features array powers:

  • the pricing table (FeaturesList component)
  • getPlanFeatures() / isFeatureAvailable()
  • translated labels via billing:feature.* keys

Add i18n entries for each feature key in your locale files so the UI reads naturally.

Enforce access on API routes

Never rely on UI hiding alone. A motivated user can still call your API directly. Protect sensitive endpoints the same way you protect authenticated routes.

Create reusable middleware in packages/api/src/middleware.ts:

packages/api/src/middleware.ts
import {
  FEATURES,
  getCustomersWithPurchasesByReferenceId,
  isFeatureAvailable,
} from "@workspace/billing";
import type { Feature } from "@workspace/billing";

export const enforceFeatureAvailable = (feature: Feature) =>
  createMiddleware<{
    Variables: {
      user: User;
    };
  }>(async (c, next) => {
    const referenceId =
      c.req.query("referenceId") ??
      c.req.valid("json")?.referenceId ??
      c.var.user.id;

    const summary = await getCustomersWithPurchasesByReferenceId(referenceId);

    if (!isFeatureAvailable(summary, feature)) {
      throw new HttpException(HttpStatusCode.PAYMENT_REQUIRED, {
        code: "error.upgradeRequired",
      });
    }

    await next();
  });

Use it on any route that should require a paid capability:

packages/api/src/modules/organization/router.ts
export const organizationRouter = new Hono().get(
  "/teams",
  enforceAuth,
  enforceFeatureAvailable(FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION),
  async (c) => c.json(/* ... */),
);

402 Payment Required signals to the client that an upgrade — not a login — is the fix. Pair it with a translated error code the UI can map to an upgrade modal.

Check the billing reference

For organization-scoped billing, pass the organization id as referenceId when fetching the summary. The billing router already uses enforceAccessToReference() so only owners with billing permissions can manage checkout — mirror that reference id in feature checks.

Gate React UI with the same helpers

Fetch the billing summary once, derive the plan, and branch in components. The account switcher already follows this pattern:

apps/web/src/modules/organization/account-switcher.tsx
const summary = useQuery(
  billing.queries.summary.get(
    activeOrganization.data?.id ?? session.data?.user.id,
  ),
);
const activePlan = getActivePlan(summary.data);

For feature-specific screens, prefer isFeatureAvailable() so you do not reimplement inheritance logic:

apps/web/src/modules/teams/teams-page.tsx
"use client";

import { FEATURES, BillingPlan, isFeatureAvailable } from "@workspace/billing";
import { useQuery } from "@tanstack/react-query";

import { billing } from "~/modules/billing/lib/api";
import { UpgradePrompt } from "~/modules/billing/upgrade-prompt";

export const TeamsPage = ({ referenceId }: { referenceId: string }) => {
  const summary = useQuery(billing.queries.summary.get(referenceId));

  if (summary.isLoading) {
    return <TeamsSkeleton />;
  }

  const canUseTeams = isFeatureAvailable(
    summary.data,
    FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION,
  );

  if (!canUseTeams) {
    return (
      <UpgradePrompt
        feature={FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION}
        referenceId={referenceId}
      />
    );
  }

  return <TeamsWorkspace />;
};

Upgrade prompts

Use getHigherPlans() to find the next tier that unlocks a feature:

apps/web/src/modules/billing/upgrade-prompt.tsx
import {
  BillingPlan,
  FEATURES,
  getHigherPlans,
  getActivePlan,
} from "@workspace/billing";

const activePlan = getActivePlan(summary.data);
const upgradeTarget = getHigherPlans(activePlan).find((plan) =>
  plan.features.includes(FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION),
);

Link to pricing or open the billing portal via billing.mutations.portal.get — the pricing usePlan hook already wraps checkout and portal flows.

Conditional navigation

Hide nav items the user cannot use, but keep server enforcement as the real gate:

{
  isFeatureAvailable(
    summary.data,
    FEATURES[BillingPlan.ENTERPRISE].API_ACCESS,
  ) ? (
    <NavLink href={pathsConfig.dashboard.api}>API</NavLink>
  ) : null;
}

Enforce usage limits

When a feature is available but quantity matters — projects, seats, storage — use checkPlanLimit():

packages/api/src/modules/projects/mutations/create.ts
import { checkPlanLimit, getActivePlan } from "@workspace/billing";

const activePlan = getActivePlan(summary);
const projectCount = await countProjects(referenceId);

const { allowed, remaining } = checkPlanLimit({
  id: activePlan,
  key: "projects",
  currentUsage: projectCount,
});

if (!allowed) {
  throw new HttpException(HttpStatusCode.PAYMENT_REQUIRED, {
    code: "error.limitReached",
    message: `You can create ${remaining} more projects on your current plan.`,
  });
}

checkPlanLimit() reads the limits object from your billing config. A value of null means unlimited; a missing key means no cap is configured.

Pass increment when validating bulk actions (for example inviting three members at once).

Gate non-HTTP surfaces (OAuth, MCP, webhooks)

Some capabilities are not plain REST routes. You can still reuse isFeatureAvailable() in Better Auth plugins or background jobs.

Example: block OAuth token issuance unless the user has the right plan (pattern from production TurboStarter apps):

packages/auth/src/plugins/feature-gate.ts
import { APIError, createAuthMiddleware } from "better-auth/api";

import { BillingPlan, FEATURES, isFeatureAvailable } from "@workspace/billing";
import { getCustomersWithPurchasesByReferenceId } from "@workspace/billing/server";

export const hasApiAccess = async (userId: string) => {
  const summary = await getCustomersWithPurchasesByReferenceId(userId);
  return isFeatureAvailable(summary, FEATURES[BillingPlan.ENTERPRISE].API_ACCESS);
};

export const apiAccessTokenGate = () => ({
  id: "api-access-token-gate",
  hooks: {
    before: [
      {
        matcher(ctx) {
          return ctx.path === "/oauth/token";
        },
        handler: createAuthMiddleware(async (ctx) => {
          const userId = /* resolve from token body */;

          if (userId && !(await hasApiAccess(userId))) {
            throw new APIError("FORBIDDEN", {
              error: "access_denied",
              error_description: "API access requires an Enterprise plan.",
            });
          }
        }),
      },
    ],
  },
});

Register the plugin in your Better Auth config alongside existing plugins.

Test your matrix

Add unit tests next to the billing utilities so plan changes do not silently break access:

packages/billing/shared/src/utils/test/plan.test.ts
import { FEATURES, BillingPlan, isFeatureAvailable } from "@workspace/billing";

it("premium users can access team collaboration", () => {
  const summary = {
    subscriptions: [
      { status: SubscriptionStatus.ACTIVE, variantId: "premium-monthly" },
    ],
  };

  expect(
    isFeatureAvailable(
      summary,
      FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION,
    ),
  ).toBe(true);
});

it("free users cannot access team collaboration", () => {
  expect(
    isFeatureAvailable({}, FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION),
  ).toBe(false);
});

Manually verify in the app:

  1. Sign in as a Free user — gated UI shows upgrade prompt, API returns 402.
  2. Complete checkout for Premium — feature unlocks without redeploying.
  3. Cancel subscription — access revokes after the billing provider syncs webhooks.

Checklist for new features

When you add a monetized capability:

  1. Add a constant to features.ts and spread it into the right plan tier.
  2. Add the feature to the plan's features array in billing config.
  3. Add a billing:feature.* translation string.
  4. Protect the API route with enforceFeatureAvailable().
  5. Gate the page or component with isFeatureAvailable().
  6. If the feature has quotas, define limits and call checkPlanLimit() before mutations.
  7. Add tests for at least one allowed and one denied plan.

How is this guide?

Last updated on

On this page

Ship your startup everywhere. In minutes.Try TurboStarter