Shared subscription limits across web and mobile: a DB-free package design

Storyie Engineering Team
7 min read

How we extracted plan-limit logic into a zero-dependency TypeScript package that works identically on Next.js, Expo, and in unit tests — no database client required.

Storyie has two subscription plans — Free and Pro — with limits like "up to 31 diary entries per month" and "up to 3 images per entry." Both the Next.js web app and the Expo mobile app need to enforce and display those limits. This post is about how we structured that logic so a single change propagates everywhere.

TL;DR

  • We extracted all plan-limit logic into @storyie/subscription, a package with zero runtime dependencies (no Supabase, no Drizzle, no Stripe).
  • The package is pure functions + TypeScript constants. Any environment that can run TypeScript can import it.
  • The database has a subscription_plans table, but the package doesn't read it. Constants are kept in sync manually; with two plans that's viable.
  • Monthly period calculation is UTC-fixed so server, browser, and device all see the same boundary.
  • Limiting the package to pure computation was the key decision. It made testing trivial and removed a whole class of platform-specific branching.

Layer

Handles

@storyie/subscription

Types, constants, pure limit-check functions, period calculation

Web server

Fetches usage counts via Drizzle, passes them to the package

Web client

Reads totals from API responses, calls same package functions

Expo

Reads totals from Supabase query results, calls same functions

The problem: scattered if statements and copy-pasted constants

Before the package existed, limit enforcement lived inside individual Server Actions. Something like "if the user is on Free and has more than 31 entries this month, return an error" was written directly where the diary was created.

That works until you need it in more than one place:

  1. Mobile needs the same check. The Expo app wants to show "4 entries left this month" in the UI. Duplicating 31 into the mobile codebase means changing it later requires finding every copy.
  2. Changing a limit is risky. When we wanted to adjust the Pro image cap, there was no single source of truth to update. You had to grep for the value and hope you found everything.
  3. Logic is untestable in isolation. When limit logic is mixed with database queries, you can't unit test it without mocking the database. That friction means the tests don't get written.

Package structure: computation only

packages/subscription/
  src/
    types/       → PlanName, PlanLimits, UsageInfo, ContentLimitResult, ...
    constants/   → FREE_PLAN_LIMITS, PRO_PLAN_LIMITS
    utils/       → canCreateContent, getLimitsForPlan, getCurrentMonthPeriod, ...
    errors/      → ContentLimitError

The package.json has no runtime dependencies at all. The only imports are from the TypeScript standard library. No Supabase client, no Drizzle, no Stripe SDK.

The ContentLimitResult type as documentation

interface ContentLimitResult {
  allowed: boolean;             // can the action proceed?
  reason?: ContentLimitReason;  // "limit_exceeded" | "no_subscription"
  usage?: UsageInfo;            // { current, limit, remaining }
}

This shape serves double duty as documentation. Callers that only need to gate an action check result.allowed. Callers that render remaining counts use result.usage.remaining. The reason field drives specific error copy rather than a generic message. Because the type is flat, it serializes cleanly over an API boundary — Expo can receive it as JSON from a Next.js route and run the same display logic on it.

null for unlimited

Plan limits use null to mean "no limit":

const FREE_PLAN_LIMITS: PlanLimits = {
  diaryEntriesPerMonth: 31,
  imagesPerEntry: 1,
  aiAssistsPerMonth: 5,
};

const PRO_PLAN_LIMITS: PlanLimits = {
  diaryEntriesPerMonth: null,  // unlimited
  imagesPerEntry: 3,
  aiAssistsPerMonth: null,     // unlimited
};

The check function handles null in one place:

function canCreateContent(limit: number | null, current: number): boolean {
  if (limit === null) return true;
  return current < limit;
}

UI components receive null in usage.limit and render "Unlimited" rather than a count. No platform-specific branching required.

Why no database dependency

The database is only reachable from the server. The Expo client runs on the device, the browser client runs in the user's browser — neither has a database connection. Putting a Drizzle or Supabase import into this package would immediately make it unusable in those environments.

The design instead separates fetching usage data from deciding what to do with it:

  • Web server: Drizzle query to get the count → canCreateContent(count, limits.diaryEntriesPerMonth)
  • Web client: API response with totals → same call
  • Expo: Supabase query result → same call
  • Tests: hardcoded numbers → same call

Each environment is responsible for getting the usage data using whatever mechanism is native to it. The package only does the calculation.

The database does have a subscription_plans table with similar values. We don't automate sync between the table and the package constants — with two plans and infrequent changes, a manual update in the same commit is enough. If we reach three or four plans, the calculus changes.

Monthly period calculation

Diary limits are per month, so the package needs to answer "what is the current month's start and end?" That sounds trivial until you realize the web server, the browser, and a phone in Tokyo don't agree on what time it is.

We fixed the calculation to UTC:

function getCurrentMonthPeriod(): { start: Date; end: Date } {
  const now = new Date();
  const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
  const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
  return { start, end };
}

UTC means the same window regardless of which runtime calls it. A user in UTC+9 resets at 9 AM local time on the first of the month rather than midnight — a reasonable tradeoff at this scale. Tracking per-user timezones everywhere the calculation runs would be a significant complexity increase for a small UX benefit.

How each platform uses the package

Web server (Server Action)

import { canCreateContent, FREE_PLAN_LIMITS } from "@storyie/subscription";
import { getDiaryCountThisMonth } from "@/lib/db/queries/diaries";

export async function createDiary(userId: string) {
  const count = await getDiaryCountThisMonth(userId);
  const limits = getLimitsForPlan(userPlan);
  const result = canCreateContent(limits.diaryEntriesPerMonth, count);

  if (!result.allowed) {
    throw new ContentLimitError(result.reason);
  }
  // proceed with creation
}

Expo (displaying remaining count)

import { getLimitsForPlan } from "@storyie/subscription";

function DiaryCountBadge({ plan, usedThisMonth }: Props) {
  const limits = getLimitsForPlan(plan);
  const remaining =
    limits.diaryEntriesPerMonth === null
      ? null
      : limits.diaryEntriesPerMonth - usedThisMonth;

  return (
    <Text>
      {remaining === null ? "Unlimited" : `${remaining} entries left this month`}
    </Text>
  );
}

The getLimitsForPlan call is identical in both environments. The difference is purely in how each side got usedThisMonth.

Testing

Because the package has no external dependencies, every test is a plain function call:

describe("canCreateContent", () => {
  it("allows when under limit", () => {
    expect(canCreateContent(31, 30).allowed).toBe(true);
  });

  it("blocks when at limit", () => {
    expect(canCreateContent(31, 31).allowed).toBe(false);
  });

  it("always allows when limit is null", () => {
    expect(canCreateContent(null, 9999).allowed).toBe(true);
  });
});

No mocks, no test database, no beforeAll setup. The test suite runs in under a second. When we raised the AI-assist cap from 20 to 30, the change was one constant and one test assertion.

Retrospective

What worked well

Limit changes cost almost nothing. When we adjusted the Pro AI-assist cap, we updated one constant in one file. The change propagated to the web server, web client, Expo display logic, and tests simultaneously — because they all import the same package.

The types document the domain. Looking at PlanLimits or ContentLimitResult tells you what the subscription system cares about. New engineers understand the plan structure by reading the type definitions, not by grepping for magic numbers across two apps.

Boundary testing is fast. We can test every significant boundary — zero, exactly-at-limit, one-over, null-unlimited — without any test infrastructure. That confidence is worth more than the small cost of maintaining the package.

What we'd do differently

Two plans may not have justified this structure. If Storyie had stayed simple with hardcoded limits that never changed, a shared package would have been over-engineered. What made it worthwhile wasn't the architecture itself — it was needing to display limits in two completely different environments without duplicating logic.

The dual-maintenance of constants and database is a known debt. Right now it works because changes are rare. With more plans or more frequent adjustments, we'd move toward fetching limits from the server at app startup and caching them, so the database becomes the only source of truth.

Takeaways

  • Separating "fetch usage data" from "decide what the limit means" is the core idea. Each platform does the fetch in whatever way is native to it; the package does the pure calculation.
  • null for unlimited avoids a separate concept (Infinity, a union type, an enum) — it serializes cleanly as JSON and reads naturally in TypeScript.
  • UTC-fixed period calculation is the simplest thing that works correctly across runtimes. Per-user timezone support is a valid future direction, but adds state that has to flow everywhere.
  • Zero runtime dependencies is a hard constraint worth maintaining. The moment a database client appears in the package, the mobile client can't import it.

Related Posts

Try Storyie

Storyie is a diary app for web and mobile. The Free plan covers 31 entries per month; Pro removes the entry limit and increases the image cap. You can start writing at storyie.com and pick up where you left off on the iOS app — the same entry, on both platforms, with the same limits applied everywhere.