Edition & Plan Limits Created on
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
Section titled “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
Section titled “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)
Section titled “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)
Section titled “Edition Detection (edition.ts)”getEdition() resolves the current edition using this priority:
SAYR_EDITION_BAKED(build-time, Docker images — cannot be overridden)SAYR_EDITIONenv var (local development)SAYR_CLOUD=truelegacy fallback- 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)
Section titled “Limit Constants (capabilities.ts)”// Cloud free plan limitsconst FREE_LIMITS: PlanLimits = { members: 5, savedViews: 3, issueTemplates: 3, teams: 1, releases: 0,};
// Cloud pro plan -- effectively unlimitedconst CLOUD_PLAN_LIMITS = { free: FREE_LIMITS, pro: { members: 1000, savedViews: null, issueTemplates: null, teams: null, releases: null },};
// Self-hosted editions -- unlimited everythingconst SELF_HOSTED_LIMITS: PlanLimits = { members: 1000, savedViews: null, issueTemplates: null, teams: null, releases: null,};Pure Functions (Browser-Safe)
Section titled “Pure Functions (Browser-Safe)”These accept edition as a parameter and never touch process.env:
| Function | Purpose |
|---|---|
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
Section titled “Server-Only Wrappers”These call getEdition() internally and exist for backend convenience:
| Function | Equivalent 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
Section titled “Import Rules”// Backend (server-side) -- can use anythingimport { canCreateResource, getLimitReachedMessage, isCloud } from "@repo/edition";
// Frontend (browser) -- ONLY pure functions and typesimport { canCreate, isOverLimit, getLimitsForEdition, getResourceLimitMessage } from "@repo/edition";import type { Edition, PlanLimits } from "@repo/edition";Frontend Hooks
Section titled “Frontend Hooks”usePlanLimits()
Section titled “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()
Section titled “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:
| Property | Type | Description |
|---|---|---|
edition | Edition | Current edition |
isCloud | boolean | Whether running on cloud |
plan | string | null | Org’s plan ("free", "pro", etc.) |
limits | PlanLimits | Resolved limits for this org |
counts | Record<keyof PlanLimits, number> | Current resource counts |
canCreateResource(resource) | (r) => boolean | Can the org create another of this resource? |
isOverLimit(resource) | (r) => boolean | Is the org currently over this limit? |
getLimitMessage(resource) | (r) => string | Upgrade message for this resource |
PlanLimitBanner Component
Section titled “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
Section titled “Backend Enforcement”Every creation endpoint uses a two-tier approach:
Tier 1: Instance-Level (enforceLimit())
Section titled “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())
Section titled “Tier 2: Plan-Level (canCreateResource())”import { canCreateResource, getLimitReachedMessage } from "@repo/edition";
// Count org-specific resourcesconst 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
Section titled “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
Section titled “UX Rules for Gating”These rules apply to any resource with plan limits:
Create
Section titled “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>)}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
Section titled “Delete”Always allowed, regardless of limits.
Summary Table
Section titled “Summary Table”| Action | At limit (count = max) | Over limit (count > max) |
|---|---|---|
| Create | Blocked | Blocked |
| Edit (open editor) | Allowed | Allowed |
| Edit (save changes) | Allowed | Blocked |
| Delete | Allowed | Allowed |
Adding Limits to a New Resource
Section titled “Adding Limits to a New Resource”Follow these steps to add plan-based limits to a new resource type.
1. Add to PlanLimits type
Section titled “1. Add to PlanLimits type”export interface PlanLimits { // ...existing fields myNewResource: number | null;}2. Set limits in constants
Section titled “2. Set limits in constants”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()
Section titled “3. Add to formatResourceName()”case "myNewResource": return "My new resources";4. Add count to frontend hooks
Section titled “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
Section titled “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
Section titled “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
Section titled “File Reference”| File | Purpose |
|---|---|
packages/edition/src/types.ts | Edition, PlanLimits, PlanId types |
packages/edition/src/edition.ts | getEdition() and boolean helpers (server-only) |
packages/edition/src/capabilities.ts | Limit constants, pure functions, server wrappers |
packages/edition/src/index.ts | Re-exports everything |
apps/start/src/hooks/usePlanLimits.ts | usePlanLimits() and usePlanLimitsFromData() hooks |
apps/start/src/components/generic/PlanLimitBanner.tsx | Reusable limit banner component |
apps/backend/util.ts | enforceLimit() instance-level utility |
apps/backend/routes/api/internal/v1/organization.ts | Enforcement for views, templates, members, teams |
apps/backend/routes/api/internal/v1/release.ts | Enforcement for releases |
Related Guides
Section titled “Related Guides”- Editions — User-facing edition overview and Cloud plan limits
- Architecture Overview — How the systems connect
- Adding Features — End-to-end feature implementation walkthrough