For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: 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
- Declare typed feature keys in
packages/billing/shared/src/config/features.ts. - Attach those keys to each plan in the billing configuration.
- Resolve the active plan with
getActivePlan()and check access withisFeatureAvailable(). - Protect API routes with
enforceFeatureAvailable()middleware. - Gate UI with the same helpers and nudge upgrades with
getHigherPlans().
What you are building
Most SaaS products mix two kinds of restrictions:
| Type | Example | TurboStarter helper |
|---|---|---|
| Boolean features | "Teams" only on Premium | isFeatureAvailable() |
| Usage limits | Max 3 projects on Free | checkPlanLimit() |
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:
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:
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:
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:
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:
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 (
FeaturesListcomponent) 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:
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:
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:
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:
"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:
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():
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):
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:
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:
- Sign in as a Free user — gated UI shows upgrade prompt, API returns
402. - Complete checkout for Premium — feature unlocks without redeploying.
- Cancel subscription — access revokes after the billing provider syncs webhooks.
Checklist for new features
When you add a monetized capability:
- Add a constant to
features.tsand spread it into the right plan tier. - Add the feature to the plan's
featuresarray in billing config. - Add a
billing:feature.*translation string. - Protect the API route with
enforceFeatureAvailable(). - Gate the page or component with
isFeatureAvailable(). - If the feature has quotas, define
limitsand callcheckPlanLimit()before mutations. - Add tests for at least one allowed and one denied plan.
How is this guide?
Last updated on