StarterApp Docs
Billing

Feature Gating

Control access based on subscription plans

Feature gating controls which users can access specific functionality based on their subscription tier. Access control happens in Convex actions using ctx.check() for real-time enforcement.

Server-Side Only

Never rely on client-side checks for security. Client checks are for UX only (showing/hiding UI). Convex actions enforce the actual access control.

Configuration

Define features and assign them to products in autumn.config.ts:

import { feature, product, featureItem, priceItem } from "atmn";

// Boolean feature (on/off)
export const dashboard = feature({
  id: "dashboard",
  name: "Dashboard Access",
  type: "boolean",
});

// Usage-based feature (metered)
export const apiCalls = feature({
  id: "api_calls",
  name: "API Calls",
  type: "single_use",
});

export const prioritySupport = feature({
  id: "priority_support",
  name: "Priority Support",
  type: "boolean",
});

// Paid tier includes dashboard + 10k API calls
export const paid = product({
  id: "starterapp-demo-paid",
  name: "Paid",
  items: [
    featureItem({
      feature_id: dashboard.id,
      included_usage: 1, // Boolean: 1 = enabled
    }),
    featureItem({
      feature_id: apiCalls.id,
      included_usage: 10_000,
      interval: "month",
    }),
    priceItem({ price: 10, interval: "month" }),
  ],
});

// Premium tier includes everything + priority support + 50k calls
export const premium = product({
  id: "starterapp-demo-premium",
  name: "Premium",
  items: [
    featureItem({ feature_id: dashboard.id, included_usage: 1 }),
    featureItem({ feature_id: prioritySupport.id, included_usage: 1 }),
    featureItem({
      feature_id: apiCalls.id,
      included_usage: 50_000,
      interval: "month",
    }),
    priceItem({ price: 25, interval: "month" }),
  ],
});

Push configuration to UseAutumn:

npx atmn push

Pro Tip

Feature checks will fail until you push the configuration. Run npx atmn push after every change to features or limits.

Server-Side Gating Patterns

Pattern 1: Convex Action Gating

The most secure pattern. Gate features inside Convex actions where the actual work happens.

import { v } from "convex/values";
import { userAction } from "./_helpers/builders";
import { AppError } from "./_helpers/errors";
import { api } from "./_generated/api";

export const sendMessage = userAction({
  args: { body: v.string() },
  handler: async (ctx, args) => {
    // Check feature access
    const result = await ctx.check({ featureId: "messages" });

    if (!result.data?.allowed) {
      throw new AppError("PLAN_REQUIRED", "Upgrade to send messages");
    }

    // Perform the action
    const id = await ctx.runMutation(api.messages._insertMessage, {
      body: args.body,
      userId: ctx.viewerId,
    });

    // Track usage
    await ctx.track({ featureId: "messages", value: 1 });

    return id;
  },
});

Pattern 2: Next.js Layout Gating

Gate entire sections by checking access in Server Components:

export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";

import { getCurrentSession } from "@workspace/app-shell/lib/auth/server";
import { redirect } from "next/navigation";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getCurrentSession();

  if (!session) {
    redirect("/sign-in");
  }

  // For feature gating, create a Convex action that checks dashboard access
  // then call it here with fetchAction()
  // See Pattern 1 for the Convex action implementation

  return <>{children}</>;
}

Cache Configuration

The three exports at the top (revalidate, dynamic, fetchCache) disable Next.js caching. Without these, Next.js might serve stale subscription data.

Pattern 3: API Route Gating

Gate Next.js API routes using session validation + Convex actions:

export const runtime = "nodejs";
export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";

import { api } from "@convex/_generated/api";
import { createAuth } from "@convex/lib/auth";
import { getToken } from "@convex-dev/better-auth/nextjs";
import { auth } from "@workspace/auth/server";
import { secureErrorJson, secureUserJson } from "@workspace/security";
import { fetchAction } from "convex/nextjs";
import type { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  try {
    // Require auth
    const session = await auth.api.getSession({ headers: req.headers });
    if (!session?.user?.id) {
      return secureErrorJson(
        { message: "Unauthorized", code: "UNAUTHENTICATED" },
        { status: 401, userScoped: true }
      );
    }

    const token = await getToken(createAuth as Parameters<typeof getToken>[0]);

    // Call Convex action (which checks feature access internally)
    const result = await fetchAction(
      api.premium.performAction,
      {},
      { token }
    );

    return secureUserJson({ ok: true, data: result }, { status: 200 });
  } catch (error) {
    const isPlanError = error instanceof Error && error.message.includes("PLAN_REQUIRED");

    if (isPlanError) {
      return secureErrorJson(
        { message: "Upgrade required", code: "PLAN_REQUIRED" },
        { status: 402, userScoped: true }
      );
    }

    return secureErrorJson(
      { message: "Internal error", code: "INTERNAL" },
      { status: 500, userScoped: true }
    );
  }
}

Client-Side Patterns (UX Only)

Heads up

Client-side checks are for UX only. They control what UI users see, not what actions they can perform. Always enforce access in Convex actions.

Check Feature Access

Use billing.check() for client-side UI decisions:

'use client';
import { useServices } from '~/lib/providers/services-context';
import { UpgradeButton } from './upgrade-button';

export function AnalyticsPanel() {
  const { billing } = useServices();

  // Check feature access on client
  const result = billing.check({ featureId: "analytics" });
  const hasAccess = result?.data?.allowed;

  if (!hasAccess) {
    return (
      <div className="rounded-lg border border-dashed p-8 text-center">
        <p className="text-muted-foreground">
          Analytics available on Premium plan
        </p>
        <UpgradeButton />
      </div>
    );
  }

  return <div>{/* Analytics UI */}</div>;
}

Display Current Plan

Show subscription status using billing.useCustomer():

'use client';
import { useServices } from '~/lib/providers/services-context';
import { Badge } from '@workspace/ui/components/badge';

export function CurrentPlanBadge() {
  const { billing } = useServices();
  const { customer, loading } = billing.useCustomer();

  if (loading) {
    return <Badge variant="outline">Loading...</Badge>;
  }

  const product = customer?.products?.[0];
  const planName = product?.name || 'Free';

  return <Badge variant="default">{planName}</Badge>;
}

Testing Feature Gates

Test feature gates by mocking the services context:

import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { TestServicesProvider } from "@workspace/app-shell/lib/test-utils";
import { AnalyticsPanel } from "./analytics-panel";

test("shows upgrade prompt when feature not allowed", () => {
  const mockBilling = {
    check: vi.fn().mockReturnValue({ data: { allowed: false } }),
    useCustomer: vi.fn().mockReturnValue({ customer: null, loading: false }),
  };

  render(
    <TestServicesProvider services={{ billing: mockBilling }}>
      <AnalyticsPanel />
    </TestServicesProvider>
  );

  expect(screen.getByText(/available on Premium plan/i)).toBeInTheDocument();
});

test("shows analytics when feature allowed", () => {
  const mockBilling = {
    check: vi.fn().mockReturnValue({ data: { allowed: true } }),
    useCustomer: vi.fn().mockReturnValue({ customer: null, loading: false }),
  };

  render(
    <TestServicesProvider services={{ billing: mockBilling }}>
      <AnalyticsPanel />
    </TestServicesProvider>
  );

  expect(screen.queryByText(/available on Premium plan/i)).not.toBeInTheDocument();
});

Common Patterns

Troubleshooting