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
Related Documentation
- Subscriptions - Configure subscription tiers
- Usage-Based Billing - Track and limit usage
- Overview - Billing architecture and setup