Forbruksmåling og håndheving av abonnementer
Følg forbruket per leietaker, og begrens tilgangen basert på plangrenser og faktureringsstatus.
Forbruksmåling og håndheving av abonnementer er en gratis leksjon i Next.js 15 fullstack (App Router + Server Actions) på CoddyKit. Dette er leksjon 4 av 4. Du kan lese valgfritt 3 leksjoner fra denne læringsstien gratis i sin helhet – deretter låser CoddyKit PRO opp alle leksjoner, samt praktisk øving med en innebygd kodeeditor og en AI-veileder som er tilgjengelig døgnet rundt. Den er en del av læringsløpet i Next.js 15 fullstack (App Router + Server Actions), og fremdriften din synkroniseres mellom nettet og CoddyKit-appen. Kurset i Next.js 15 fullstack (App Router + Server Actions) inneholder totalt 4 leksjoner.
Hvorfor forbruksmåling er viktig i SaaS
I en multi-tenant SaaS-applikasjon betaler ulike kunder for ulike nivåer. Et Starter-abonnement kan tillate 1 000 API-kall per måned, mens et Enterprise-abonnement gir ubegrenset tilgang. Uten forbruksmåling får alle tenants den samme opplevelsen, uavhengig av hva de betaler for.
Forbruksmåling har to formål:
- Håndheving: Blokker eller reduser tjenesten når grensene nås
- Signaler til fakturering: Send nøyaktige forbruksdata til faktureringstjenesten din (for eksempel Stripe)
I Next.js 15 med App Router passer forbruksmåling naturlig inn i Server Actions og Route Handlers — de to stedene der det faktiske arbeidet skjer på serveren.
Datamodell: Tenants, abonnementer og forbruk
Start med et skjema som beskriver forholdet mellom en tenant, tenantens aktive abonnementsplan og de nåværende forbrukstellerne. Et minimalt Postgres-skjema kan se slik ut:
tenants— én rad per organisasjonsubscription_plans— grenser per funksjon og nivåtenant_usage— løpende tellere som tilbakestilles ved hver faktureringssyklus
Ved å lagre forbruket i en egen tabell (i stedet for å summere hendelseslogger ved hver forespørsel) blir grensesjekker én enkelt indeksert lesing, noe som er avgjørende for Server Actions med lav forsinkelse.
// 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';
}Finne gjeldende tenant i App Router
Hver håndhevingssjekk må vite hvilken tenant som utfører handlingen. I App Router er tenant-identiteten vanligvis kodet i sesjonens JWT eller hentet fra subdomenet. En felles hjelpefunksjon finner den én gang og kaster en feil hvis brukeren ikke er autentisert.
Ved å plassere dette i en lib/tenant.ts-modul holder du Server Actions og Route Handlers DRY — begge kaller den samme løsningsfunksjonen i stedet for å tolke sesjonen hver for seg.
// 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,
};
}Sjekke abonnementsstatus før enhver handling
Før du sjekker forbruksgrenser, må du alltid kontrollere at tenantens abonnement er i orden. En konto med statusen past_due eller canceled bør blokkeres selv om forbruksgrensene ikke er nådd ennå.
Samle denne logikken i en guard-funksjon som både Server Actions og Route Handlers kan kalle. Kast en typet feil slik at kallende kode kan vise riktig brukergrensesnitt (for eksempel et banner med «Aktiver abonnementet på nytt»).
// 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;
}Atomisk forbruksøkning med grensesjekk
Den mest kritiske delen av forbruksmålingen er den atomiske sjekken og økningen. Et naivt mønster — les gjeldende forbruk, sammenlign med grensen, og skriv deretter — har en kappløpssituasjon: To samtidige forespørsler kan begge bestå sjekken før noen av dem øker telleren.
Den riktige tilnærmingen bruker én enkelt SQL-setning som sjekker og øker atomisk, og returnerer om operasjonen lyktes. I Postgres kan dette være en betinget UPDATE ... RETURNING eller en lagret prosedyre.
// 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;
*/Hente abonnementsgrenser ved kjøring
Abonnementsgrenser må hentes fra databasen (eller en rask hurtigbuffer) ved kjøring — de må aldri hardkodes i applikasjonslogikken. Da kan du endre abonnementsgrensene uten en ny utrulling.
Hurtigbufre abonnementsdata aggressivt: Grenser endres sjelden, så en kort hurtigbuffer i minnet med TTL (eller Next.js sin innebygde unstable_cache) unngår en tur til databasen ved hver forespørsel.
// 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
);Koble det hele sammen i en Server Action
Kombiner nå vakten, planoppslaget og økningen av bruken i én enkelt Server Action. Handlingen kjøres utelukkende på serveren; klienten håndterer aldri faktureringslogikk.
Mønsteret består alltid av de samme tre trinnene:
- 1. Autentiser — finn tenant-konteksten
- 2. Kontroller — bekreft aktivt abonnement, hent grenser og kontroller bruk
- 3. Utfør — utfør den faktiske forretningslogikken
// 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();
}Håndheving på middleware-nivå for API-ruter
Server Actions egner seg godt for skjemadrevne arbeidsflyter, men tenanter bruker også kvoten gjennom offentlige API-ruter (for eksempel /api/v1/data). Håndhev målingen her med en gjenbrukbar middleware-wrapper i stedet for å kopiere kontrollkoden inn i hver Route Handler.
Dette wrapper-mønsteret kalles noen ganger en API-middlewarekjede eller en handler-fabrikk. Det lar hver Route Handler konsentrere seg om forretningslogikken.
// 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);
// });Synliggjøring av bruk for tenanten: bruksdashbordet
Tenanter trenger oversikt over bruken sin for å kunne ta informerte beslutninger om oppgradering. Et API-endepunkt for bruk returnerer forbruket i gjeldende periode sammen med plangrensene.
Når både used og limit returneres, kan frontend vise en fremdriftslinje uten å måtte kjenne plandetaljene separat. Returner et percentage-felt som er forhåndsberegnet på serveren, slik at klienten blir enklere.
// 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,
},
});
}Tilbakestilling av bruk ved fornyelse av faktureringsperioden
Tellere for bruk må tilbakestilles når en faktureringsperiode fornyes. Den ryddigste tilnærmingen er en Stripe-webhook-handler som lytter etter invoice.paid-hendelser og tilbakestiller tellerne for den tenanten.
Stol aldri på en cron-jobb som kontrollerer datoer — den kan forskyves, kjøre to ganger eller gå glipp av en fornyelse. Stripe-hendelser er det autoritative signalet på at en ny faktureringsperiode har startet.
- Bekreft webhook-signaturen med
stripe.webhooks.constructEvent - Bruk en
upserttil å opprette eller tilbakestille radentenant_usagefor den nye perioden - Lagre
stripe_subscription_idpå tenanten, slik at du kan slå den opp fra hendelsen
// 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 });
}Funksjonsstyring etter plannivå
I tillegg til mengdebegrensninger (hvor mange API-kall) begrenser SaaS-produkter også funksjoner etter plannivå. En tenant på Starter skal ikke kunne aktivere SSO eller få tilgang til revisjonsloggen, uavhengig av hvor mye de har brukt.
Modeller funksjonsflagg som et statisk kart med plannivået som nøkkel. Kontroller funksjonen i Server Action eller Route Handler før du fortsetter. Da holdes funksjonsdefinisjonene samlet på ett sted, og endringer i plannivåene kan gjøres utelukkende i koden.
// 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'
// );
// }Kunnskapstest: atomisk håndheving av bruk
Et team utvikler en multi-tenant SaaS-app. De implementerer måling av API-kall slik:
- Les
api_calls_usedfra databasen - Fortsett hvis
used < limit - Øk
api_calls_usedmed 1 etter at operasjonen er fullført
Hva er hovedproblemet med denne tilnærmingen?
Oppsummering: måling av bruk og håndheving av abonnement
Du har nå et komplett, produksjonsklart system for måling av bruk i en multi-tenant SaaS-app med Next.js 15. Her er en oppsummering av de viktigste mønstrene som er gjennomgått:
- Typemodell for data: Skill
subscription_plans(grenser) fratenant_usage(tellere), slik at lesing går raskt og grensene kan konfigureres uten nye utrullinger. - Resolver for tenant-kontekst: Én enkelt
requireTenantContext()-funksjon som brukes av alle Server Actions og Route Handlers. - Kontroll av abonnementsstatus: Kontroller alltid statusen
activeellertrialingfør du kontrollerer grenser — en konto med statusenpast_dueblokkeres uavhengig av bruk. - Atomisk økning: Bruk en Postgres-funksjon med
FOR UPDATEfor å kontrollere og øke i én setning, slik at kappløpssituasjoner elimineres. - Bufrede plangrenser: Bruk
unstable_cachemed kort TTL, slik at plandata ikke hentes ved hver forespørsel. - Gjenbrukbar wrapper:
withMetering()pakker inn Route Handlers på en ryddig måte og holder forretningslogikken atskilt fra faktureringslogikken. - Tilbakestilling via Stripe-webhook: Lytt etter
invoice.paidfor å tilbakestille brukstellerne — stol aldri på cron-jobber for grenser mellom faktureringsperioder. - Funksjonsstyring: Et statisk
FEATURE_MAPper plannivå styrer tilgang til funksjoner uavhengig av mengdebegrensninger.
Når disse mønstrene kombineres, får hver tenant en rettferdig, håndhevbar og transparent tjenesteopplevelse, samtidig som infrastrukturen beskyttes mot overforbruk.
Lær deg TypeScript med en AI-veileder – gratis
Skriv og kjør ekte kode i nettleseren, få umiddelbar hjelp fra en AI-veileder som er tilgjengelig døgnet rundt, og fortsett der du slapp – på nettet eller i appen.
- Kurs
- 22
- Leksjoner
- 88
Ofte stilte spørsmål
Er leksjonen «Forbruksmåling og håndheving av abonnementer» gratis?
Ja – du kan lese valgfritt 3 av leksjonene i læringsstien Next.js 15 fullstack (App Router + Server Actions), inkludert «Forbruksmåling og håndheving av abonnementer», gratis i sin helhet her på nettet. Deretter låser CoddyKit PRO opp alle leksjoner, samt interaktiv øving med en innebygd kodeeditor og en AI-veileder som er tilgjengelig døgnet rundt. Kurset i Next.js 15 fullstack (App Router + Server Actions) inneholder totalt 4 leksjoner.
Hva lærer jeg i «Forbruksmåling og håndheving av abonnementer»?
Følg forbruket per leietaker, og begrens tilgangen basert på plangrenser og faktureringsstatus. Du øver på Next.js 15 fullstack (App Router + Server Actions) med praktisk kode som du kjører direkte i nettleseren, mens en AI-veileder som er tilgjengelig døgnet rundt, svarer på spørsmålene dine mens du jobber deg gjennom leksjonen.
Trenger jeg erfaring for å begynne med Next.js 15 fullstack (App Router + Server Actions)?
Ingen tidligere erfaring er nødvendig. Next.js 15 fullstack (App Router + Server Actions) på CoddyKit er lagt opp for både nybegynnere og viderekomne, så De kan begynne her eller helt fra start og lære i Deres eget tempo. Dette er leksjon 4 av 4.
Hvor lang tid tar leksjonen «Forbruksmåling og håndheving av abonnementer»?
De fleste CoddyKit-leksjoner tar omtrent 5–10 minutter. Hver leksjon er kort og interaktiv, slik at De gjør jevne fremskritt og kan fortsette akkurat der De slapp – både på nettet og i appen.
Kan jeg skrive og kjøre kode i denne Next.js 15 fullstack (App Router + Server Actions)-leksjonen?
Ja. Alle Next.js 15 fullstack (App Router + Server Actions)-leksjoner har en innebygd kodeeditor, slik at De kan skrive og kjøre ekte kode direkte i nettleseren og få umiddelbar tilbakemelding fra AI – uten lokal konfigurering.
Alle leksjonene i dette kurset
- Oppslag av leietakere basert på subdomene og sti
- Mønstre for isolering av leietakerdata på radnivå
- Tematisering og feature flags per leietaker
- Forbruksmåling og håndheving av abonnementer