BirrJS
Concepts

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:

TypeDescriptionExample
booleanAccess/no-access toggleAnalytics, Reports, API access
meteredUsage-based with a limitMessages 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.

PatternGroup
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

OptionTypeDescription
idstringLowercase alphanumeric with _/-
namestringDisplay name (1-100 chars)
groupstringGroup for organizing plans. Required if default: true
defaultbooleanDefault 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.currencystringCurrency code (e.g. "ETB", "USD")
includesFeatureInclude[]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 push

This 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 status

createBirr() 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

FieldTypeDescription
plansPlan[]Array of plan objects (latest version first)
totalnumberTotal number of plans
limitnumberRequested page size
offsetnumberRequested page offset

Each plan object:

FieldTypeDescription
idstringPlan identifier (e.g. "pro")
namestringDisplay name
groupstringPlan group (e.g. "tier")
priceAmountnumber | nullPrice in minor units (cents). Divide by 100 for display
priceIntervalstring | nullBilling interval: "daily", "weekly", "monthly", "yearly"
currencystringCurrency code
isDefaultbooleanWhether this is the default plan
versionnumberPlan 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.