This takes an existing better-auth app with organizations to its first gated feature, in five steps. Each step ends on a check. You need better-auth 1.7.7 or newer already set up with a database.
Install
npm install @byo-oss/entitlementsRequires Node 20 or newer, better-auth ^1.7.7, better-call ^1.4.0, @better-auth/core ^1.7.7 (at the same version as better-auth) and zod ^4.0.0. better-auth already depends on @better-auth/core and zod 4, so npm and pnpm resolve them. Install them yourself when your package manager is strict about peers (Yarn). An app still on zod 3 has to move to zod 4 first. The better-auth organization plugin is required: every table references organization.
Optional peers, each behind its own import so the root never loads them:
| Package | Needed for |
|---|---|
@better-auth/stripe (^1.7.7) |
@byo-oss/entitlements/stripe |
react (>=18) |
@byo-oss/entitlements/react |
vitest (>=3) |
@byo-oss/entitlements/testing (ESM only) |
The other entry points:
| Import | What it holds |
|---|---|
@byo-oss/entitlements |
the plugin, createEntitlements, deleteOrganizationData, exportEntitlementsData, entitlementsStatements, the error codes and event types, and the types |
@byo-oss/entitlements/catalog |
defineEntitlements, upgradeTo and the catalog types. Imports nothing, so a static site can use it |
@byo-oss/entitlements/client |
the better-auth client plugin, plus the catalog helpers and entitlementsStatements |
In a monorepo, keep the catalog in one shared plans package that imports only @byo-oss/entitlements/catalog, and have the server, the web app and the pricing site import that package. Add @byo-oss/entitlements to every package that imports it, plans included. A package that only imports /catalog still installs the peers: pnpm’s auto-install-peers (on by default) covers it; with auto-install-peers=false, npm 6 or Yarn, list better-auth, better-call, @better-auth/core and zod in that package’s dependencies.
Write the catalog and add the plugin
The catalog lists your features and limits, and what each plan sets for them:
import { defineEntitlements } from "@byo-oss/entitlements/catalog";
export const catalog = defineEntitlements({
features: ["exports", "sso", "prioritySupport"],
limits: {
apiCalls: { kind: "metered", period: "day" }, // day | month (UTC) | billing (subscription period)
members: { kind: "capacity" }, // you supply the count
projects: { kind: "capacity" },
retentionDays: { kind: "value" }, // a plan setting, read with getLimit
},
plans: {
free: {
features: { exports: false, sso: false, prioritySupport: false },
limits: { apiCalls: 1_000, members: 5, projects: 3, retentionDays: 7 },
display: { name: "Free" },
},
pro: {
features: { exports: true, sso: false, prioritySupport: false },
limits: { apiCalls: 50_000, members: 50, projects: "unlimited", retentionDays: 90 },
display: { name: "Pro" },
},
},
defaultPlan: "free",
planAliases: { legacy: "pro" }, // an old plan key still in Stripe maps to its current plan
});organization() must come before entitlements(): migrations create tables in plugin order, and every entitlements table references organization. Pass the id of your staff organization, the one whose members may set overrides (see Overrides):
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins/organization";
import { entitlements } from "@byo-oss/entitlements";
import { catalog } from "./catalog";
import { ac, roles } from "./permissions"; // step 2
export const auth = betterAuth({
// ...your existing config
plugins: [
organization({ ac, roles }), // before entitlements()
entitlements({
catalog,
staffOrganizationId: process.env.STAFF_ORGANIZATION_ID,
}),
],
});- With no
source, every organization is ondefaultPlan(plus overrides). AddfromBetterAuthStripe()when you take payments, see Billing. - Where
entitlements()sits relative tostripe()doesn’t matter: a source connects ininit. Onlyorganization()has to come first. - Without the organization plugin, boot fails with
ORGANIZATION_PLUGIN_REQUIRED. WithoutstaffOrganizationId(or your ownauthorize), the override endpoints deny every call and boot logs one warning. - Never rename a plan key that has subscribers. Add a
planAliasesentry from the old key to the current plan instead.
Done when the app typechecks. It boots after step 3: on a SQL schema better-auth checks at boot that its tables exist.
Grant the entitlements permissions
Spread entitlementsStatements into your access control and give your roles entitlements actions:
import { createAccessControl } from "better-auth/plugins/access";
import { adminAc, defaultStatements, memberAc, ownerAc } from "better-auth/plugins/organization/access";
import { entitlementsStatements } from "@byo-oss/entitlements";
export const ac = createAccessControl({ ...defaultStatements, ...entitlementsStatements });
export const roles = {
owner: ac.newRole({ ...ownerAc.statements, entitlements: ["read", "override"] }),
admin: ac.newRole({ ...adminAc.statements, entitlements: ["read", "override"] }),
member: ac.newRole({ ...memberAc.statements, entitlements: ["read"] }),
};entitlements:readlets a member see their own organization’s plan, features, limits and usage.entitlements:overrideis only ever checked in the staff organization, never in the organization being changed. A customer organization’s owner holds the action but isn’t a member of the staff organization, so they can’t override their own plan.
Done when ac includes entitlements and the roles typecheck.
Run the migration
The plugin declares its tables; your existing better-auth workflow creates them:
npx @better-auth/cli migrateUse generate instead of migrate for drizzle or prisma. It adds entitlementSnapshot, entitlementOverride and entitlementUsage, and changes nothing on better-auth’s own tables. On MongoDB, create the two unique indexes in Schema first. Done when the migration is applied to the dev database.
Add the client plugin
import { createAuthClient } from "better-auth/react";
import { organizationClient } from "better-auth/client/plugins";
import { entitlementsClient } from "@byo-oss/entitlements/client";
export const authClient = createAuthClient({
plugins: [organizationClient(), entitlementsClient()],
});Done when authClient.entitlements.get typechecks.
Make the first check
createEntitlements gives you the server API, typed by your catalog. Keep it in server code:
import { createEntitlements } from "@byo-oss/entitlements";
import { auth } from "./auth";
import { catalog } from "./catalog";
export const gate = createEntitlements(auth, catalog);
export async function canExport(organizationId: string) {
return gate.hasFeature(organizationId, "exports");
// { allowed: false, reason: "not_in_plan", key: "exports", plan: "free", upgradeTo: ["pro"] }
}"exprts" is a type error. Done when a Free organization gets allowed: false with upgradeTo: ["pro"].
Next
- Checks covers limits, usage and capacity.
- Billing connects Stripe.
- React gates the UI and shows usage.
- Background work sets up the sweep, which you need once there is a billing source.
For a complete, runnable wiring, see the example app in examples/tanstack-start: TanStack Start with better-auth on SQLite and an end-to-end suite. Run it with pnpm install, pnpm migrate, pnpm seed and pnpm dev from that directory (Node 22.13 or newer). Its README has the details and the sign-ins.