Skip to content

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.

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.

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.

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;

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.

// 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,
};

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"

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)
// 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";

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.

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

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.

Every creation endpoint uses a two-tier approach:

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.

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.

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);
}
}

These rules apply to any resource with plan limits:

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>
)}

Always allowed, regardless of limits.

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

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

packages/edition/src/types.ts
export interface PlanLimits {
// ...existing fields
myNewResource: number | null;
}
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 };
case "myNewResource":
return "My new resources";

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.

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

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

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