Plans & Features
Define products with code-first plans and feature types.
Plans are defined in TypeScript code and synced to the database. This gives you type safety, version history, and a single source of truth.
Feature Types
BirrJS supports two feature types:
| Type | Description | Example |
|---|---|---|
boolean | Access/no-access toggle | Analytics, Reports, API access |
metered | Usage-based with a limit | Messages sent, API calls, storage |
Define a feature with feature():
import { feature } from "@birrjs/core";
const messages = feature({ id: "messages", type: "metered" });
const analytics = feature({ id: "analytics", type: "boolean" });feature() returns a callable object. When invoked with a config, it produces a FeatureInclude for use inside a plan().
Plan Groups
Plans can be organized into groups with the group option. A customer can have one active subscription per group at a time.
| Pattern | Group |
|---|---|
| Free / Pro / Ultra tiers | "tier" |
| One-off addons | "addons" |
| Per-seat pricing | "seats" |
export const pro = plan({
id: "pro",
name: "Pro",
group: "tier",
// ...
});If a plan has default: true, it must belong to a group.
Defining a Plan
import { plan } from "@birrjs/core";
export const pro = plan({
id: "pro",
name: "Pro",
group: "tier",
default: false,
price: { amount: 29, interval: "monthly" },
includes: [
messages({ limit: 5000, reset: "month" }),
analytics(),
],
});Plan options
| Option | Type | Description |
|---|---|---|
id | string | Lowercase alphanumeric with _/- |
name | string | Display name (1-100 chars) |
group | string | Group for organizing plans. Required if default: true |
default | boolean | Default plan for a group. A customer without an active subscription is treated as being on this plan — no subscription record is created |
price | { amount, interval, currency? } | Price in ETB (decimal). E.g. 29 = 29 ETB. Currency defaults to "ETB". Stored as minor units internally — 29 becomes 2900 in the database |
price.currency | string | Currency code (e.g. "ETB", "USD") |
includes | FeatureInclude[] | Feature definitions with limits and resets |
Price intervals
"daily","weekly","monthly","yearly"
Reset intervals (for metered features)
"day","week","month","year"
Adding Plans to Your Instance
Pass your plans array to createBirr():
import { createBirr } from "@birrjs/core";
import { free, pro } from "./birrjs-plans";
export const birrjs = createBirr({
// ...
plans: [free, pro],
});This enables type inference for all plan and feature IDs.
Syncing Plans to the Database
birrjs pushThis compares your code-first plan definitions against the database using SHA-256 hashes. If a plan changed, a new version is created with an incremented version number.
Dry run
Preview changes without writing to the database:
birrjs statuscreateBirr() also runs a dry run check automatically in development and warns if plans are out of sync.
Plan IDs must be unique. If you rename a plan, the old one stays in the database — create a new plan and deprecate the old one.
Listing Plans
Synced plans are available via the listPlans endpoint. Use this to render pricing cards, plan selection UI, or admin dashboards.
const { plans, total } = await client.listPlans({ limit: 20, offset: 0 });Response
| Field | Type | Description |
|---|---|---|
plans | Plan[] | Array of plan objects (latest version first) |
total | number | Total number of plans |
limit | number | Requested page size |
offset | number | Requested page offset |
Each plan object:
| Field | Type | Description |
|---|---|---|
id | string | Plan identifier (e.g. "pro") |
name | string | Display name |
group | string | Plan group (e.g. "tier") |
priceAmount | number | null | Price in minor units (cents). Divide by 100 for display |
priceInterval | string | null | Billing interval: "daily", "weekly", "monthly", "yearly" |
currency | string | Currency code |
isDefault | boolean | Whether this is the default plan |
version | number | Plan version (increments on each change) |
listPlans returns plans ordered by version descending. If a plan has multiple versions (e.g. after a price change), the latest version is returned first. Use .filter() or .findLast() to get only the latest version per plan ID.
Pricing card example
"use client";
import { client } from "@/lib/birrjs-client";
import { useQuery } from "@tanstack/react-query";
export function PricingCards() {
const { data, isLoading } = useQuery({
queryKey: ["plans"],
queryFn: () => client.listPlans({ limit: 10, offset: 0 }),
});
if (isLoading) return <div>Loading...</div>;
// Filter out the default plan for display
const plans = (data?.plans ?? []).filter((p) => !p.isDefault);
return (
<div className="grid grid-cols-3 gap-4">
{plans.map((plan) => (
<div key={plan.id} className="rounded-lg border p-6 space-y-4">
<h3 className="text-lg font-bold">{plan.name}</h3>
{plan.priceAmount ? (
<p className="text-2xl font-bold">
{plan.priceAmount / 100}
{" "}
<span className="text-sm font-normal text-muted-foreground">
{plan.currency}/{plan.priceInterval}
</span>
</p>
) : (
<p className="text-2xl font-bold">Free</p>
)}
</div>
))}
</div>
);
}Prices are stored in minor units (cents). priceAmount: 2900 means 29.00 currency units. When defining a plan, write price: { amount: 29 } — the normalization layer handles the conversion. When reading from the API, divide by 100: plan.priceAmount / 100.
Type Inference
BirrJS infers plan IDs and feature IDs from your plans array. Typos like planId: "proe" or featureId: "mesages" are caught at compile time.
Access inferred types via $infer on your birrjs instance:
type PlanIds = typeof birrjs.$infer.planId;
type FeatureIds = typeof birrjs.$infer.featureId;This powers full autocomplete in subscribe, check, and report across your entire app.
Version History
Each plan change creates a new version row. The database stores all versions, so customers on an old plan keep their entitlements. Only the latest version is used for new subscriptions.