Usage Metering and Subscription Enforcement
Track per-tenant usage and gate access based on plan limits and billing status.
Usage Metering and Subscription Enforcement is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Usage Metering Matters in SaaS
In a multi-tenant SaaS application, different customers pay for different tiers. A Starter plan might allow 1,000 API calls per month while an Enterprise plan allows unlimited access. Without usage metering, every tenant gets the same experience regardless of what they pay.
Usage metering serves two purposes:
- Enforcement: Block or degrade service when limits are reached
- Billing signals: Feed accurate consumption data to your billing provider (e.g. Stripe)
In Next.js 15 with the App Router, metering fits naturally into Server Actions and Route Handlers — the two places where real work happens on the server.
Data Model: Tenants, Plans, and Usage
Start with a schema that captures the relationship between a tenant, their active subscription plan, and their current usage counters. A minimal Postgres schema looks like this:
tenants— one row per organisationsubscription_plans— limits per feature per tiertenant_usage— rolling counters reset on billing cycle
Keeping usage in a dedicated table (rather than summing event logs on every request) makes limit checks a single indexed read, which is critical for low-latency Server Actions.
// types/billing.ts
export type PlanTier = 'starter' | 'pro' | 'enterprise';
export interface SubscriptionPlan {
id: string;
tier: PlanTier;
limits: {
apiCallsPerMonth: number; // -1 = unlimited
teamMembers: number;
storageMb: number;
};
}
export interface TenantUsage {
tenantId: string;
billingPeriodStart: Date;
apiCallsUsed: number;
teamMembersActive: number;
storageMbUsed: number;
}
export interface Tenant {
id: string;
name: string;
planId: string;
subscriptionStatus: 'active' | 'past_due' | 'canceled' | 'trialing';
}Resolving the Current Tenant in App Router
Every enforcement check needs to know which tenant is acting. In App Router, the tenant identity is typically encoded in the session JWT or derived from the subdomain. A shared utility function resolves it once and throws if unauthenticated.
Placing this in a lib/tenant.ts module keeps Server Actions and Route Handlers DRY — both call the same resolver rather than each parsing the session independently.
// lib/tenant.ts
import { cookies } from 'next/headers';
import { createServerClient } from '@supabase/ssr';
export interface TenantContext {
tenantId: string;
userId: string;
planId: string;
subscriptionStatus: string;
}
export async function requireTenantContext(): Promise<TenantContext> {
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ cookies: { getAll: () => cookieStore.getAll() } }
);
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) throw new Error('Unauthenticated');
const { data: membership } = await supabase
.from('tenant_memberships')
.select('tenant_id, tenants(plan_id, subscription_status)')
.eq('user_id', user.id)
.single();
if (!membership) throw new Error('No tenant found for user');
return {
tenantId: membership.tenant_id,
userId: user.id,
planId: (membership.tenants as any).plan_id,
subscriptionStatus: (membership.tenants as any).subscription_status,
};
}Checking Subscription Status Before Any Action
Before checking usage limits, always verify that the tenant's subscription is in good standing. A past_due or canceled account should be blocked even if they haven't hit their usage limits yet.
Centralise this logic in a guard function that both Server Actions and Route Handlers can call. Throw a typed error so callers can render the right UI (e.g. a "Reactivate subscription" banner).
// lib/billing-guard.ts
import { requireTenantContext, TenantContext } from './tenant';
export class SubscriptionError extends Error {
constructor(
message: string,
public readonly code: 'SUBSCRIPTION_INACTIVE' | 'LIMIT_EXCEEDED' | 'FEATURE_NOT_AVAILABLE'
) {
super(message);
this.name = 'SubscriptionError';
}
}
const ACTIVE_STATUSES = new Set(['active', 'trialing']);
export async function assertActiveSubscription(ctx?: TenantContext): Promise<TenantContext> {
const tenantCtx = ctx ?? await requireTenantContext();
if (!ACTIVE_STATUSES.has(tenantCtx.subscriptionStatus)) {
throw new SubscriptionError(
`Subscription is ${tenantCtx.subscriptionStatus}. Please update your billing details.`,
'SUBSCRIPTION_INACTIVE'
);
}
return tenantCtx;
}Atomic Usage Increment with Limit Check
The most critical part of metering is the atomic check-and-increment. A naive pattern — read current usage, compare to limit, then write — has a race condition: two concurrent requests can both pass the check before either increments the counter.
The correct approach uses a single SQL statement that checks and increments atomically, returning whether the operation succeeded. In Postgres this is a conditional UPDATE ... RETURNING or a stored procedure.
// lib/usage.ts
import { createClient } from '@/lib/supabase-admin';
export async function incrementApiCallUsage(
tenantId: string,
limit: number
): Promise<{ allowed: boolean; used: number }> {
const supabase = createClient();
// Atomic: only increment if under the limit (-1 means unlimited)
const { data, error } = await supabase.rpc('increment_api_usage', {
p_tenant_id: tenantId,
p_limit: limit,
});
if (error) throw new Error(`Usage increment failed: ${error.message}`);
return {
allowed: data.allowed as boolean,
used: data.new_count as number,
};
}
/*
Corresponding Postgres function (run once as a migration):
CREATE OR REPLACE FUNCTION increment_api_usage(
p_tenant_id UUID,
p_limit INT
) RETURNS JSON AS $$
DECLARE
v_current INT;
v_allowed BOOLEAN;
BEGIN
SELECT api_calls_used INTO v_current FROM tenant_usage
WHERE tenant_id = p_tenant_id
AND billing_period_start = date_trunc('month', now())
FOR UPDATE;
v_allowed := (p_limit = -1) OR (v_current < p_limit);
IF v_allowed THEN
UPDATE tenant_usage
SET api_calls_used = api_calls_used + 1
WHERE tenant_id = p_tenant_id
AND billing_period_start = date_trunc('month', now());
END IF;
RETURN json_build_object('allowed', v_allowed, 'new_count', v_current + 1);
END;
$$ LANGUAGE plpgsql;
*/Fetching Plan Limits at Runtime
Plan limits must be fetched from the database (or a fast cache) at runtime — never hardcoded in application logic. This allows you to change plan limits without a deployment.
Cache plan data aggressively: limits rarely change, so a short in-memory cache with a TTL (or Next.js's built-in unstable_cache) avoids a DB round-trip on every request.
// lib/plans.ts
import { unstable_cache } from 'next/cache';
import { createClient } from '@/lib/supabase-admin';
import type { SubscriptionPlan } from '@/types/billing';
export const getPlanLimits = unstable_cache(
async (planId: string): Promise<SubscriptionPlan> => {
const supabase = createClient();
const { data, error } = await supabase
.from('subscription_plans')
.select('id, tier, limit_api_calls, limit_team_members, limit_storage_mb')
.eq('id', planId)
.single();
if (error || !data) throw new Error(`Plan ${planId} not found`);
return {
id: data.id,
tier: data.tier,
limits: {
apiCallsPerMonth: data.limit_api_calls,
teamMembers: data.limit_team_members,
storageMb: data.limit_storage_mb,
},
};
},
['plan-limits'],
{ revalidate: 300 } // 5-minute TTL
);Wiring It Together in a Server Action
Now combine the guard, plan lookup, and usage increment into a single Server Action. The action runs entirely on the server; the client never touches billing logic.
The pattern is always the same three steps:
- 1. Authenticate — resolve tenant context
- 2. Guard — assert active subscription, fetch limits, check usage
- 3. Execute — perform the real business logic
// app/actions/generate-report.ts
'use server';
import { requireTenantContext } from '@/lib/tenant';
import { assertActiveSubscription, SubscriptionError } from '@/lib/billing-guard';
import { getPlanLimits } from '@/lib/plans';
import { incrementApiCallUsage } from '@/lib/usage';
export async function generateReportAction(
reportType: string
): Promise<{ success: boolean; error?: string; reportId?: string }> {
try {
// Step 1: Resolve tenant
const ctx = await requireTenantContext();
// Step 2: Guard — subscription status + usage
await assertActiveSubscription(ctx);
const plan = await getPlanLimits(ctx.planId);
const { allowed, used } = await incrementApiCallUsage(
ctx.tenantId,
plan.limits.apiCallsPerMonth
);
if (!allowed) {
return {
success: false,
error: `Monthly API limit of ${plan.limits.apiCallsPerMonth} reached (used: ${used}). Upgrade your plan to continue.`,
};
}
// Step 3: Real work
const reportId = await createReport(ctx.tenantId, reportType);
return { success: true, reportId };
} catch (err) {
if (err instanceof SubscriptionError) {
return { success: false, error: err.message };
}
throw err; // unexpected errors bubble up
}
}
async function createReport(tenantId: string, type: string): Promise<string> {
// ... actual report generation logic
return crypto.randomUUID();
}Middleware-Level Enforcement for API Routes
Server Actions are great for form-driven flows, but tenants also consume usage through public API routes (e.g. /api/v1/data). Enforce metering here with a reusable middleware wrapper rather than copy-pasting guard code into every Route Handler.
This wrapper pattern is sometimes called an API middleware chain or handler factory. It keeps each Route Handler focused on business logic.
// lib/with-metering.ts
import { NextRequest, NextResponse } from 'next/server';
import { requireTenantContext } from './tenant';
import { assertActiveSubscription, SubscriptionError } from './billing-guard';
import { getPlanLimits } from './plans';
import { incrementApiCallUsage } from './usage';
type Handler = (req: NextRequest, ctx: { tenantId: string }) => Promise<NextResponse>;
export function withMetering(handler: Handler) {
return async (req: NextRequest): Promise<NextResponse> => {
try {
const tenant = await requireTenantContext();
await assertActiveSubscription(tenant);
const plan = await getPlanLimits(tenant.planId);
const { allowed } = await incrementApiCallUsage(
tenant.tenantId,
plan.limits.apiCallsPerMonth
);
if (!allowed) {
return NextResponse.json(
{ error: 'rate_limit_exceeded', message: 'Monthly API limit reached.' },
{ status: 429 }
);
}
return handler(req, { tenantId: tenant.tenantId });
} catch (err) {
if (err instanceof SubscriptionError) {
return NextResponse.json(
{ error: 'subscription_inactive', message: err.message },
{ status: 402 }
);
}
return NextResponse.json({ error: 'internal_error' }, { status: 500 });
}
};
}
// Usage in app/api/v1/data/route.ts:
// export const GET = withMetering(async (req, { tenantId }) => {
// const data = await fetchData(tenantId);
// return NextResponse.json(data);
// });Surfacing Usage to the Tenant: the Usage Dashboard
Tenants need visibility into their usage so they can make informed decisions about upgrading. A usage API endpoint returns the current period's consumption alongside the plan limits.
Returning both used and limit lets the frontend render a progress bar without knowing plan details separately. Return a percentage field pre-computed on the server to simplify the client.
// app/api/billing/usage/route.ts
import { NextResponse } from 'next/server';
import { requireTenantContext } from '@/lib/tenant';
import { getPlanLimits } from '@/lib/plans';
import { createClient } from '@/lib/supabase-admin';
export async function GET() {
const ctx = await requireTenantContext();
const plan = await getPlanLimits(ctx.planId);
const supabase = createClient();
const { data: usage } = await supabase
.from('tenant_usage')
.select('api_calls_used, storage_mb_used, team_members_active')
.eq('tenant_id', ctx.tenantId)
.gte('billing_period_start', new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString())
.single();
const apiCallsUsed = usage?.api_calls_used ?? 0;
const limit = plan.limits.apiCallsPerMonth;
return NextResponse.json({
planTier: plan.tier,
billingPeriod: new Date().toISOString().slice(0, 7),
apiCalls: {
used: apiCallsUsed,
limit,
percentage: limit === -1 ? 0 : Math.round((apiCallsUsed / limit) * 100),
unlimited: limit === -1,
},
storage: {
usedMb: usage?.storage_mb_used ?? 0,
limitMb: plan.limits.storageMb,
},
teamMembers: {
active: usage?.team_members_active ?? 0,
limit: plan.limits.teamMembers,
},
});
}Resetting Usage on Billing Cycle Renewal
Usage counters must reset when a billing period renews. The cleanest approach is a Stripe webhook handler that listens for invoice.paid events and resets the counters for that tenant.
Never rely on a cron job that checks dates — it can drift, fire twice, or miss a renewal. Stripe events are the authoritative signal that a new billing period has started.
- Verify the webhook signature with
stripe.webhooks.constructEvent - Use an
upsertto create or reset thetenant_usagerow for the new period - Store the
stripe_subscription_idon the tenant so you can look it up from the event
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@/lib/supabase-admin';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
if (event.type === 'invoice.paid') {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = invoice.subscription as string;
const periodStart = new Date((invoice.period_start) * 1000);
const supabase = createClient();
const { data: tenant } = await supabase
.from('tenants')
.select('id')
.eq('stripe_subscription_id', subscriptionId)
.single();
if (tenant) {
await supabase.from('tenant_usage').upsert({
tenant_id: tenant.id,
billing_period_start: periodStart.toISOString(),
api_calls_used: 0,
storage_mb_used: 0,
team_members_active: 0,
}, { onConflict: 'tenant_id,billing_period_start' });
}
}
return NextResponse.json({ received: true });
}Feature Gating by Plan Tier
Beyond quantity limits (how many API calls), SaaS products also gate features by plan tier. A Starter tenant should not be able to enable SSO or access the audit log, regardless of their usage count.
Model feature flags as a static map keyed by tier. Check the feature in the Server Action or Route Handler before proceeding. This keeps feature definitions in one place and makes tier changes a code-only update.
// lib/features.ts
import type { PlanTier } from '@/types/billing';
const FEATURE_MAP: Record<PlanTier, Set<string>> = {
starter: new Set(['basic_reports', 'api_access']),
pro: new Set(['basic_reports', 'api_access', 'advanced_reports', 'webhooks', 'audit_log']),
enterprise: new Set([
'basic_reports', 'api_access', 'advanced_reports',
'webhooks', 'audit_log', 'sso', 'custom_roles', 'sla_support'
]),
};
export function hasFeature(tier: PlanTier, feature: string): boolean {
return FEATURE_MAP[tier]?.has(feature) ?? false;
}
// Usage in a Server Action:
// import { hasFeature } from '@/lib/features';
// import { SubscriptionError } from '@/lib/billing-guard';
//
// const plan = await getPlanLimits(ctx.planId);
// if (!hasFeature(plan.tier, 'sso')) {
// throw new SubscriptionError(
// 'SSO is only available on the Enterprise plan.',
// 'FEATURE_NOT_AVAILABLE'
// );
// }Knowledge Check: Atomic Usage Enforcement
A team is building a multi-tenant SaaS app. They implement usage metering for API calls like this:
- Read
api_calls_usedfrom the database - If
used < limit, proceed - After the operation completes, increment
api_calls_usedby 1
What is the primary problem with this approach?
Recap: Usage Metering and Subscription Enforcement
You now have a complete, production-grade metering system for a multi-tenant Next.js 15 SaaS. Here is a summary of the key patterns covered:
- Typed data model: Separate
subscription_plans(limits) fromtenant_usage(counters) to make reads fast and limits configurable without deploys. - Tenant context resolver: A single
requireTenantContext()function used by all Server Actions and Route Handlers. - Subscription status guard: Always check
activeortrialingstatus before checking limits — apast_dueaccount is blocked regardless of usage. - Atomic increment: Use a Postgres function with
FOR UPDATEto check and increment in one statement, eliminating race conditions. - Cached plan limits: Use
unstable_cachewith a short TTL so plan data is not fetched on every request. - Reusable wrapper:
withMetering()wraps Route Handlers cleanly, keeping business logic separate from billing logic. - Stripe webhook resets: Listen for
invoice.paidto reset usage counters — never rely on cron jobs for billing period boundaries. - Feature gating: A static
FEATURE_MAPper tier controls capability access independently of quantity limits.
Combining these patterns gives each tenant a fair, enforceable, and transparent service experience while protecting your infrastructure from over-consumption.
Frequently asked questions
Is the “Usage Metering and Subscription Enforcement” lesson free?
Yes — the full text of “Usage Metering and Subscription Enforcement” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.
What will I learn in “Usage Metering and Subscription Enforcement”?
Track per-tenant usage and gate access based on plan limits and billing status. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?
No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Usage Metering and Subscription Enforcement” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?
Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Subdomain and Path-Based Tenant Resolution
- Row-Level Tenant Data Isolation Patterns
- Per-Tenant Theming and Feature Flags
- Usage Metering and Subscription Enforcement