Sayr

Edition & Plan Limits

How the edition detection, plan limits, and resource gating system works across backend and frontend

This guide explains the internal architecture of Sayr's edition and plan limit system. It covers the @repo/edition package, frontend hooks, backend enforcement patterns, and how to add limits to new resources.

Architecture Overview

The edition system has three layers:

┌─────────────────────────────────────────────────────┐
│                     Frontend                         │
│  usePlanLimits() / usePlanLimitsFromData()           │
│  PlanLimitBanner, disabled buttons, tooltips         │
│  Edition from: import.meta.env.VITE_SAYR_EDITION    │
└──────────────────────┬──────────────────────────────┘
                       │ fetch (POST/PATCH)
┌──────────────────────▼──────────────────────────────┐
│                     Backend                          │
│  enforceLimit()  — instance-level (self-hosted)      │
│  canCreateResource() — plan-level (per-org)          │
│  Edition from: process.env / SAYR_EDITION_BAKED      │
└──────────────────────┬──────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────┐
│               @repo/edition                          │
│  Types, constants, pure functions, server wrappers   │
└─────────────────────────────────────────────────────┘

The frontend provides optimistic UX (disabling buttons, showing banners) while the backend is the source of truth and enforces limits with 403 responses.

The @repo/edition Package

All edition and limit logic lives in packages/edition/src/. It exports both server-only wrappers and pure functions that are safe for browser use.

Types (types.ts)

type Edition = "cloud" | "community" | "enterprise";

interface PlanLimits {
   members: number | null;       // null = unlimited
   savedViews: number | null;
   issueTemplates: number | null;
   teams: number | null;
   releases: number | null;
}

type CloudPlan = "free" | "pro";
type SelfHostedPlan = "self-hosted";
type PlanId = CloudPlan | SelfHostedPlan;

Edition Detection (edition.ts)

getEdition() resolves the current edition using this priority:

  1. SAYR_EDITION_BAKED (build-time, Docker images -- cannot be overridden)
  2. SAYR_EDITION env var (local development)
  3. SAYR_CLOUD=true legacy fallback
  4. Default: "community"

This function reads process.env and is server-only. Never import getEdition(), isCloud(), isSelfHosted(), isCommunity(), or isEnterprise() in browser code.

Limit Constants (capabilities.ts)

// Cloud free plan limits
const FREE_LIMITS: PlanLimits = {
   members: 5, savedViews: 3, issueTemplates: 3, teams: 1, releases: 0,
};

// Cloud pro plan -- effectively unlimited
const CLOUD_PLAN_LIMITS = {
   free: FREE_LIMITS,
   pro: { members: 1000, savedViews: null, issueTemplates: null, teams: null, releases: null },
};

// Self-hosted editions -- unlimited everything
const SELF_HOSTED_LIMITS: PlanLimits = {
   members: 1000, savedViews: null, issueTemplates: null, teams: null, releases: null,
};

Pure Functions (Browser-Safe)

These accept edition as a parameter and never touch process.env:

FunctionPurpose
getLimitsForEdition(edition, plan)Get PlanLimits for an edition + plan combo
canCreate(edition, resource, currentCount, plan)true if creation is allowed (count < limit)
isOverLimit(edition, resource, currentCount, plan)true if count > limit (user must delete to get back under)
getResourceLimitMessage(edition, resource, plan)Human-readable upgrade message
formatResourceName(resource)"savedViews" to "Saved views"

Server-Only Wrappers

These call getEdition() internally and exist for backend convenience:

FunctionEquivalent Pure Call
getEffectiveLimits(plan)getLimitsForEdition(getEdition(), plan)
canCreateResource(resource, count, plan)canCreate(getEdition(), resource, count, plan)
getLimitReachedMessage(resource, plan)getResourceLimitMessage(getEdition(), resource, plan)

Import Rules

// Backend (server-side) -- can use anything
import { canCreateResource, getLimitReachedMessage, isCloud } from "@repo/edition";

// Frontend (browser) -- ONLY pure functions and types
import { canCreate, isOverLimit, getLimitsForEdition, getResourceLimitMessage } from "@repo/edition";
import type { Edition, PlanLimits } from "@repo/edition";

Never import getEdition(), isCloud(), getEffectiveLimits(), canCreateResource(), or getLimitReachedMessage() in frontend/browser code. These call process.env which does not exist in the browser. Vite only replaces import.meta.env.VITE_* -- anything using process.env will silently resolve to undefined, causing getEdition() to default to "community" and all limits to appear unlimited.

Frontend Hooks

usePlanLimits()

For pages that have the main org layout context (useLayoutOrganization()):

import { usePlanLimits } from "@/hooks/usePlanLimits";

function MyComponent() {
   const { canCreateResource, isOverLimit, getLimitMessage, limits, counts, isCloud } = usePlanLimits();

   if (!canCreateResource("savedViews")) {
      // Show lock icon or disable button
   }

   if (isOverLimit("savedViews")) {
      // Block editing too (user must delete to get back under)
   }

   const message = getLimitMessage("savedViews");
   // "You've reached the maximum of 3 saved views on the free plan..."
}

This hook reads the edition from import.meta.env.VITE_SAYR_EDITION and the plan/counts from the org context.

usePlanLimitsFromData()

For settings pages that use useLayoutOrganizationSettings() instead of useLayoutOrganization():

import { usePlanLimitsFromData } from "@/hooks/usePlanLimits";

function SettingsPage() {
   const { organization, views, issueTemplates, releases } = useLayoutOrganizationSettings();

   const planLimits = usePlanLimitsFromData({
      plan: organization.plan,
      memberCount: organization.members.length,
      viewCount: views.length,
      issueTemplateCount: issueTemplates.length,
      releaseCount: releases.length,
   });

   // Same API as usePlanLimits()
}

Both hooks return the same PlanLimitsReturn shape:

PropertyTypeDescription
editionEditionCurrent edition
isCloudbooleanWhether running on cloud
planstring | nullOrg's plan ("free", "pro", etc.)
limitsPlanLimitsResolved limits for this org
countsRecord<keyof PlanLimits, number>Current resource counts
canCreateResource(resource)(r) => booleanCan the org create another of this resource?
isOverLimit(resource)(r) => booleanIs the org currently over this limit?
getLimitMessage(resource)(r) => stringUpgrade message for this resource

PlanLimitBanner Component

A reusable banner shown when a limit is reached. Matches the visual style of the existing seat limit banner on the members page.

import { PlanLimitBanner } from "@/components/generic/PlanLimitBanner";

<PlanLimitBanner
   title="Saved view limit reached"
   description={getLimitMessage("savedViews")}
/>

Renders a Tile with border-destructive/30 bg-destructive/5 styling and a lock icon.

Backend Enforcement

Every creation endpoint uses a two-tier approach:

Tier 1: Instance-Level (enforceLimit())

import { enforceLimit } from "@/util";

const limitRes = await enforceLimit({
   c,
   limitKey: "savedViews",
   table: schema.savedView,
   traceName: "saved_view.count_all",
   entityName: "saved view",
   traceAsync,
   recordWideError,
});
if (limitRes) return limitRes;

This counts all rows in the table (not per-org) and is designed for self-hosted instance-level caps. On cloud, it returns undefined immediately (isCloud() early return). Always capture and check the return value.

Tier 2: Plan-Level (canCreateResource())

import { canCreateResource, getLimitReachedMessage } from "@repo/edition";

// Count org-specific resources
const orgViews = await db.query.savedView.findMany({
   where: eq(schema.savedView.organizationId, orgId),
});

if (!canCreateResource("savedViews", orgViews.length, org.plan)) {
   return c.json({
      success: false,
      error: getLimitReachedMessage("savedViews", org.plan),
   }, 403);
}

This checks the org's plan-level limit. Active on both cloud and self-hosted.

PATCH/Edit Over-Limit Checks

When a user's org is over the limit (e.g., downgraded from pro to free with 5 views), editing existing items is also blocked. This is checked on PATCH endpoints:

import { getEffectiveLimits } from "@repo/edition";

const limits = getEffectiveLimits(org.plan);
if (limits.savedViews !== null) {
   const orgViews = await db.query.savedView.findMany({
      where: eq(schema.savedView.organizationId, orgId),
   });
   if (orgViews.length > limits.savedViews) {
      return c.json({
         success: false,
         error: "You are over the saved view limit. Please delete some views before editing.",
      }, 403);
   }
}

UX Rules for Gating

These rules apply to any resource with plan limits:

Create

Block entirely when at or over the limit. Replace the create button with a disabled/locked state and tooltip explaining the limit.

{canCreateResource("savedViews") ? (
   <CreateButton />
) : (
   <Tooltip content={getLimitMessage("savedViews")}>
      <Button variant="ghost" disabled>
         <IconLock className="size-4" />
      </Button>
   </Tooltip>
)}

Edit

Never fully block access to the editor. Users must always be able to open the edit sheet/dialog (so they can delete items to get back under the limit). Instead, disable the Save button inside the editor when over limit.

<Button
   disabled={isOverLimit("savedViews")}
   onClick={handleSave}
>
   Save
</Button>
{isOverLimit("savedViews") && (
   <p className="text-destructive text-xs">
      You are over the saved view limit. Delete some views before editing.
   </p>
)}

Delete

Always allowed, regardless of limits.

Summary Table

ActionAt limit (count = max)Over limit (count > max)
CreateBlockedBlocked
Edit (open editor)AllowedAllowed
Edit (save changes)AllowedBlocked
DeleteAllowedAllowed

Adding Limits to a New Resource

Follow these steps to add plan-based limits to a new resource type.

1. Add to PlanLimits type

// packages/edition/src/types.ts
export interface PlanLimits {
   // ...existing fields
   myNewResource: number | null;
}

2. Set limits in constants

// packages/edition/src/capabilities.ts
export const CLOUD_PLAN_LIMITS = {
   free: { ...existing, myNewResource: 3 },
   pro: { ...existing, myNewResource: null },
};

export const SELF_HOSTED_LIMITS = { ...existing, myNewResource: null };
export const FREE_LIMITS = { ...existing, myNewResource: 3 };

3. Add to formatResourceName()

case "myNewResource":
   return "My new resources";

4. Add count to frontend hooks

In usePlanLimits.ts, add the count to the counts object in buildPlanLimits():

const counts: Record<keyof PlanLimits, number> = {
   // ...existing
   myNewResource: data.myNewResourceCount,
};

Update both usePlanLimits() and usePlanLimitsFromData() to accept/derive the count.

5. Add backend enforcement

In the POST creation endpoint:

// Tier 1: instance-level (self-hosted)
const limitRes = await enforceLimit({
   c, limitKey: "myNewResource", table: schema.myNewResource,
   traceName: "my_new_resource.count_all", entityName: "resource",
   traceAsync, recordWideError,
});
if (limitRes) return limitRes;

// Tier 2: plan-level (per-org)
const orgResources = await db.query.myNewResource.findMany({
   where: eq(schema.myNewResource.organizationId, orgId),
});
if (!canCreateResource("myNewResource", orgResources.length, org.plan)) {
   return c.json({
      success: false,
      error: getLimitReachedMessage("myNewResource", org.plan),
   }, 403);
}

In the PATCH edit endpoint, add an over-limit check using getEffectiveLimits().

6. Add frontend gating

Use usePlanLimits() or usePlanLimitsFromData() in the relevant page components. Follow the UX rules above for create/edit/delete gating. Add PlanLimitBanner where appropriate.

File Reference

FilePurpose
packages/edition/src/types.tsEdition, PlanLimits, PlanId types
packages/edition/src/edition.tsgetEdition() and boolean helpers (server-only)
packages/edition/src/capabilities.tsLimit constants, pure functions, server wrappers
packages/edition/src/index.tsRe-exports everything
apps/start/src/hooks/usePlanLimits.tsusePlanLimits() and usePlanLimitsFromData() hooks
apps/start/src/components/generic/PlanLimitBanner.tsxReusable limit banner component
apps/backend/util.tsenforceLimit() instance-level utility
apps/backend/routes/api/internal/v1/organization.tsEnforcement for views, templates, members, teams
apps/backend/routes/api/internal/v1/release.tsEnforcement for releases

On this page