Nutzungsmessung und Durchsetzung von Abonnementgrenzen
Verfolgen Sie die Nutzung pro Tenant und begrenzen Sie den Zugriff anhand von Planlimits und Abrechnungsstatus.
Nutzungsmessung und Durchsetzung von Abonnementgrenzen ist eine kostenlose Next.js 15 Fullstack (App Router + Server Actions)-Lektion auf CoddyKit. Dies ist Lektion 4 von 4. Du kannst die komplette Lektion unten kostenlos lesen – dann übst du sie direkt im Browser mit einem integrierten Code-Editor und einem KI-Tutor rund um die Uhr. Sie ist Teil des Next.js 15 Fullstack (App Router + Server Actions)-Lernpfads, und dein Fortschritt wird über Web und CoddyKit-App synchronisiert. Der Next.js 15 Fullstack (App Router + Server Actions)-Kurs umfasst insgesamt 4 Lektionen.
Teile dieser Lektion wurden noch nicht übersetzt und werden auf Englisch angezeigt.
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.
Häufig gestellte Fragen
Ist die Lektion „Nutzungsmessung und Durchsetzung von Abonnementgrenzen“ kostenlos?
Ja — der vollständige Text von „Nutzungsmessung und Durchsetzung von Abonnementgrenzen“ ist hier im Web kostenlos zu lesen. Um sie interaktiv zu üben (integrierter Code-Editor und 24/7 KI-Tutor) und den Rest des Next.js 15 Fullstack (App Router + Server Actions)-Kurses freizuschalten, upgrade auf CoddyKit PRO. Der Next.js 15 Fullstack (App Router + Server Actions)-Kurs umfasst insgesamt 4 Lektionen.
Was lerne ich in „Nutzungsmessung und Durchsetzung von Abonnementgrenzen“?
Verfolgen Sie die Nutzung pro Tenant und begrenzen Sie den Zugriff anhand von Planlimits und Abrechnungsstatus. Du übst Next.js 15 Fullstack (App Router + Server Actions) mit praktischem Code, den du direkt im Browser ausführst, und ein 24/7 KI-Tutor beantwortet deine Fragen während du die Lektion bearbeitest.
Brauche ich Erfahrung, um Next.js 15 Fullstack (App Router + Server Actions) zu starten?
Keine Vorkenntnisse erforderlich. Next.js 15 Fullstack (App Router + Server Actions) auf CoddyKit ist für Anfänger bis fortgeschrittene Lernende strukturiert, sodass du hier starten oder von Anfang an beginnen und in deinem eigenen Tempo voranschreiten kannst. Dies ist Lektion 4 von 4.
Wie lange dauert die Lektion „Nutzungsmessung und Durchsetzung von Abonnementgrenzen“?
Die meisten CoddyKit-Lektionen dauern etwa 5–10 Minuten. Jede ist kompakt und interaktiv, sodass du stetig Fortschritte machst und genau dort weitermachst, wo du aufgehört hast – im Web und in der App.
Kann ich in dieser Next.js 15 Fullstack (App Router + Server Actions)-Lektion Code schreiben und ausführen?
Ja. Jede Next.js 15 Fullstack (App Router + Server Actions)-Lektion enthält einen integrierten Code-Editor, sodass du echten Code direkt in deinem Browser schreibst und ausführst und sofort KI-Feedback erhältst — ohne lokale Einrichtung erforderlich.
Alle Lektionen in diesem Kurs
- Tenant-Auflösung per Subdomain und Pfad
- Muster zur Isolation von Tenant-Daten auf Zeilenebene
- Tenant-spezifisches Theming und Feature Flags
- Nutzungsmessung und Durchsetzung von Abonnementgrenzen