Storyie started as a personal diary app. Small user base, one developer, no team. So when the architecture doc listed multi-tenancy, feature gates, and RBAC, the obvious reaction was: is this necessary?
The answer turned out to be yes — but only once billing entered the picture. This post covers how we designed the tenant layer, what the feature gate looks like in practice, and the one production race condition we should have seen coming.
TL;DR
- A tenant is the billing subject. Users belong to tenants; subscriptions belong to tenants. This single indirection makes Stripe integration, plan enforcement, and future team support far cleaner than storing a
plancolumn on the users table. - Feature gates split into two kinds: boolean (is this feature available?) and numeric (how much of it is available?). A unified
canPerformAction()handles both. - RBAC is a numeric role hierarchy (
owner: 4,admin: 3,member: 2,viewer: 1) plus a separateisBillingContactflag for billing access that does not require owner-level role. - The client side reads the same plan data via React context hooks. Server gate = enforcement; client hook = presentation.
- Platform-agnostic math (period windows, usage calculations) lives in
@storyie/subscriptionso mobile can import it without touching Next.js internals.
Layer | Responsibility |
|---|---|
Tenant | Billing subject, slug, subdomain |
TenantMember | User ↔ Tenant join, role, |
Subscription | Plan, billing cycle, Stripe customer ID |
Feature gate (server) |
|
RBAC (server) |
|
Context hooks (client) |
|
Why "user = billing unit" breaks down
The obvious first pass is to add a plan column to the users table. It works until it doesn't:
- Shared subscriptions — if two family members want to share one Pro plan, which user row holds the Stripe customer ID?
- Stripe webhook targeting — a webhook fires when a subscription upgrades. If the subscription is attached to a user, you update one row. If you later need to propagate that to a group, you need a join table anyway.
- Plan change blast radius — user-scoped plans make it awkward to reason about what data a given subscription covers.
A tenant is just the entity that sits between users and subscriptions. It owns the Stripe customer, the plan, and the usage counters. Users are members of tenants. The billing logic becomes a straight lookup: tenant → subscription → plan → features.
The model
User → TenantMember → Tenant → Subscription → Plan → Features- User — authenticated identity (Supabase Auth).
- Tenant — organization unit with a
slugthat can double as a subdomain. - TenantMember — join table with a role and an optional
isBillingContactflag. - Subscription — attached to the tenant, not the user. Holds the Stripe customer ID and billing cycle.
- Plan / Features — a plan has a map of feature keys to values (booleans or numeric caps).
Tenant auto-creation at signup
The hardest UX constraint was that users should not know tenants exist. Sign up, get dropped into the app. No "create your organization" step.
We handle this in a single transaction at signup time:
export async function createTenant(userId: string, slug: string, displayName?: string) {
const result = await db.transaction(async (tx) => {
// 1. テナント作成
const [tenant] = await tx.insert(tenants).values({ slug, displayName: displayName ?? slug }).returning();
// 2. 作成者を owner として登録
await tx.insert(tenantMembers).values({
tenantId: tenant.id,
userId,
role: "owner",
isBillingContact: true,
acceptedAt: new Date(),
});
// 3. デフォルト(free)プランのサブスクリプション作成
const [defaultPlan] = await tx.select().from(subscriptionPlans)
.where(and(eq(subscriptionPlans.isDefault, true), eq(subscriptionPlans.isActive, true)))
.limit(1);
const [subscription] = await tx.insert(subscriptions).values({
tenantId: tenant.id,
planId: defaultPlan.id,
status: "active",
billingCycle: "monthly",
}).returning();
return { tenant, subscription };
});
return result;
}Tenant, member record, and default subscription are created atomically. A failure at any step rolls back all three — no orphaned tenants, no users without a subscription.
The race condition we found in production
Webhooks and direct signup requests can fire simultaneously. If two operations both try to insert a tenant with the same slug, one will hit a duplicate key error. The fix is to catch that specific error and fall back to attaching the user to the existing tenant:
if (isDuplicateSlugError(error)) {
const existingTenant = await tenantQueries.getTenantBySlug(userSlug);
if (existingTenant) {
const existingMember = await tenantQueries.getTenantMember(existingTenant.id, userId);
if (!existingMember) {
await tenantQueries.createTenantMember({
tenantId: existingTenant.id,
userId,
role: "owner",
isBillingContact: true,
acceptedAt: new Date(),
});
}
}
}The pattern — optimistically insert, recover on conflict — is standard distributed systems practice. We added it after seeing the error in production logs. In hindsight it should have been there from day one; webhook timing is unpredictable.
Feature gates
Two kinds of gates
Kind | Example keys | Check function |
|---|---|---|
Boolean |
|
|
Numeric |
|
|
Boolean gates answer "can this tenant use this feature at all." Numeric gates answer "has this tenant used up their quota."
Period-based usage tracking
Some quotas reset monthly (diary count). Others accumulate forever (total note count). Rather than branching on feature type in every caller, we centralize the period logic:
const CUMULATIVE_FEATURES = ["notes_count"];
function getPeriodForFeature(featureKey: string): { start: Date; end: Date } {
if (CUMULATIVE_FEATURES.includes(featureKey)) {
return getCumulativePeriod(); // 2000年〜2099年の全期間
}
return getCurrentMonthPeriod(); // 今月の1日〜末日
}CUMULATIVE_FEATURES maps to a period window spanning decades — effectively "all time." Monthly features get the current calendar month. Call sites pass a feature key and get back a usage count; the period is invisible.
The unified action guard
Most call sites want to check both the boolean gate and the numeric limit in one shot:
export async function canPerformAction(
tenantId: string,
featureKey: string,
usageKey?: string
): Promise<ActionPermissionResult> {
const hasAccess = await canAccessFeature(tenantId, featureKey);
if (!hasAccess) return { allowed: false, reason: "feature_not_available" };
if (usageKey) {
const usageResult = await checkUsageLimit(tenantId, usageKey);
if (!usageResult.withinLimit) return { allowed: false, reason: "usage_limit_exceeded" };
}
return { allowed: true };
}Drop this at the top of a Server Action or API route handler and the plan enforcement is done. If the plan changes, the feature value in the database changes, and canPerformAction automatically starts returning different results — no code change required.
RBAC
Numeric role hierarchy
const ROLE_HIERARCHY: Record<MemberRole, number> = {
owner: 4,
admin: 3,
member: 2,
viewer: 1,
};Numeric values make permission checks a simple comparison rather than a switch statement:
export async function hasPermission(
tenantId: string, userId: string, requiredRole: MemberRole
): Promise<boolean> {
const member = await tenantQueries.getTenantMember(tenantId, userId);
if (!member) return false;
return ROLE_HIERARCHY[member.role] >= ROLE_HIERARCHY[requiredRole];
}An owner passes any role check. A viewer passes only viewer checks.
Billing access as a separate concern
Role and billing access are not the same thing. A finance person should be able to manage the subscription without becoming an owner of all content and settings. We handle this with a dedicated guard:
export async function requireBillingAccess(tenantId: string, userId: string): Promise<void> {
const member = await tenantQueries.getTenantMember(tenantId, userId);
if (member?.role === "owner") return; // owner は常にOK
if (member?.isBillingContact) return; // 課金担当者もOK
throw new PermissionError("Only the owner or billing contact can perform billing operations", "owner", member?.role);
}At current scale this is never exercised — every tenant is a solo user who is both owner and billing contact. But the column and condition were cheap to add, and they avoid a migration when team features arrive.
Client-side hooks
Server-side guards enforce plan limits. Client-side hooks control what the UI shows. Both read from the same plan data, which is fetched once at layout level and placed in React context.
export function useTenant(): TenantContextValue {
const context = useContext(TenantContext);
if (context === undefined) throw new Error("useTenant must be used within a TenantProvider");
return context;
}
// Boolean feature check
export function useHasFeature(featureKey: string, value = "true"): boolean {
const featureValue = useFeatureValue(featureKey);
return featureValue === value;
}
// Numeric limit lookup
export function useFeatureLimit(featureKey: string): number {
const featureValue = useFeatureValue(featureKey);
if (!featureValue || featureValue.toLowerCase() === "unlimited") return Infinity;
const numericValue = parseInt(featureValue, 10);
return isNaN(numericValue) ? Infinity : numericValue;
}A component that wants to show an upgrade prompt when the user hits their diary limit does:
const limit = useFeatureLimit("max_diaries_per_month");
const count = useDiaryCount(); // from TanStack Query or similar
if (count >= limit) return <UpgradeBanner />;The server guard prevents the actual diary creation. The client hook gates the UI. Neither has to know about the other's implementation.
The @storyie/subscription shared package
Period math — computing the current calendar month window, deciding whether a feature is cumulative — is pure TypeScript with no platform dependencies. We put it in packages/subscription so the Expo app can import it without pulling in any Next.js internals.
// packages/subscription/src/utils.ts
export function getCurrentMonthPeriod(): { start: Date; end: Date } { ... }The rule is the same as for lexical-common: no "use client", no "use server", no "use dom", no next/*, no expo-*. See Building a Monorepo with pnpm and TypeScript for the broader workspace conventions.
What this design actually improved
Billing logic is readable. Every Stripe webhook handler has a clear path: find the subscription by Stripe customer ID → find the tenant → update the plan. No user-scoped branching.
Adding a new gated feature is three steps. Add the feature key to the plan's features map in the database. Call canAccessFeature() or canPerformAction() on the server. Call useHasFeature() on the client. The gate works immediately for every existing plan without touching application logic.
Future team support has a clear extension point. The tenant member table and role hierarchy already exist. Adding team invites is "insert a member row with acceptedAt null, set it on acceptance" — not a schema redesign.
What we would do differently
The tenant auto-creation race condition should have been handled from day one. The slug collision is predictable in any environment where webhooks and direct requests can race. We added the recovery logic after seeing it in production, which meant it shipped without it for longer than it should have.
The feature gate and RBAC themselves are solid, but they added roughly two days of work to a solo project. If you are not planning to charge money, skip it — the indirection is not worth it for a free tool. If subscription billing is on the roadmap at all, the Drizzle migration cost is low enough that building the tenant abstraction early is worth it.
Takeaways
- Multi-tenancy at small scale is about billing structure, not team management. The tenant is the entity that owns the Stripe customer and the subscription — that alone justifies it.
- Separate boolean features from numeric limits. Trying to encode both in one abstraction leads to awkward call sites.
- Keep period math in a shared, platform-agnostic package. Both web and mobile need to reason about monthly vs. cumulative quotas.
- Treat
isBillingContactas orthogonal to role. Billing access and content access are different concerns; conflating them causes problems the moment you have more than one person per tenant. - Design the race condition recovery into tenant creation upfront. You will encounter it in production regardless.
Related Posts
- Building a Monorepo with pnpm and TypeScript — workspace conventions and cross-package dependency rules
- Multi-tenant subdomain routing in Next.js — how tenant slugs map to subdomains
- Subscription and billing architecture — the Drizzle schema behind the subscription and plan tables
Try Storyie
If you want to see what the plan gating looks like from the user side, sign up at storyie.com — the free plan limits are enforced by the same canPerformAction() and useHasFeature() calls described above.