Next.js 15 i full stack (App Router + Server Actions) · Lektion

Forbrugsmåling og håndhævelse af abonnementer

Spor forbrug pr. tenant, og begræns adgangen ud fra plangrænser og betalingsstatus.

Lektion 4 af 413 trin

Forbrugsmåling og håndhævelse af abonnementer er en gratis Next.js 15 i full stack (App Router + Server Actions)-lektion på CoddyKit. Dette er lektion 4 af 4. Du kan læse alle 3 lektioner i dette læringsspor gratis i deres fulde længde — derefter låser CoddyKit PRO alle lektioner op samt praktiske øvelser med en indbygget kodeeditor og en AI-underviser døgnet rundt. Den er en del af læringsforløbet i Next.js 15 i full stack (App Router + Server Actions), og dine fremskridt synkroniseres på tværs af nettet og CoddyKit-appen. Next.js 15 i full stack (App Router + Server Actions)-kurset indeholder 4 lektioner i alt.

Hvorfor forbrugsmåling er vigtig i SaaS

I en SaaS-applikation med flere lejere betaler forskellige kunder for forskellige niveauer. Et Starter-abonnement tillader måske 1.000 API-kald om måneden, mens et Enterprise-abonnement giver ubegrænset adgang. Uden forbrugsmåling får alle lejere den samme oplevelse, uanset hvad de betaler for.

Forbrugsmåling tjener to formål:

  • Håndhævelse: Bloker eller forring tjenesten, når grænserne nås
  • Faktureringssignaler: Send nøjagtige forbrugsdata til din faktureringsudbyder (f.eks. Stripe)

I Next.js 15 med App Router passer forbrugsmåling naturligt ind i Server Actions og Route Handlers — de to steder, hvor det faktiske arbejde udføres på serveren.

Datamodel: lejere, abonnementer og forbrug

Begynd med et skema, der beskriver forholdet mellem en lejer, lejerens aktive abonnementsplan og de aktuelle forbrugstællere. Et minimalt Postgres-skema ser sådan ud:

  • tenants — én række pr. organisation
  • subscription_plans — grænser pr. funktion og niveau
  • tenant_usage — løbende tællere, der nulstilles ved hver faktureringsperiode

Hvis du gemmer forbruget i en dedikeret tabel i stedet for at summere hændelseslogge ved hver forespørgsel, bliver grænsekontroller til én indekseret læsning, hvilket er afgørende for Server Actions med lav latenstid.

// 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';
}

Løsning af den aktuelle lejer i App Router

Enhver håndhævelseskontrol skal vide, hvilken lejer der handler. I App Router er lejerens identitet typisk kodet i sessions-JWT'en eller udledt fra underdomænet. En fælles hjælpefunktion finder den én gang og kaster en fejl, hvis brugeren ikke er godkendt.

Hvis du placerer dette i et lib/tenant.ts-modul, holdes Server Actions og Route Handlers DRY — begge kalder den samme løsningsfunktion i stedet for hver især at fortolke sessionen.

// 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,
  };
}

Kontrol af abonnementsstatus før enhver handling

Før du kontrollerer forbrugsgrænser, skal du altid kontrollere, at lejerens abonnement er i orden. En konto med status past_due eller canceled bør blokeres, selv hvis den endnu ikke har nået sine forbrugsgrænser.

Centralisér denne logik i en guard-funktion, som både Server Actions og Route Handlers kan kalde. Kast en typet fejl, så kaldere kan rendere den rigtige brugerflade (f.eks. et banner med teksten "Genaktivér abonnement").

// 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 forbrugsforøgelse med grænsekontrol

Den mest kritiske del af forbrugsmålingen er den atomiske kontrol og forøgelse. Et naivt mønster — læs det aktuelle forbrug, sammenlign med grænsen, og skriv derefter — har en race condition: To samtidige forespørgsler kan begge bestå kontrollen, før nogen af dem når at forøge tælleren.

Den korrekte tilgang bruger en enkelt SQL-sætning, der kontrollerer og forøger atomisk og returnerer, om handlingen lykkedes. I Postgres er det en betinget UPDATE ... RETURNING eller en lagret 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;
*/

Hentning af abonnementsgrænser ved kørsel

Abonnementsgrænser skal hentes fra databasen (eller en hurtig cache) ved kørsel — de må aldrig hardcodes i applikationslogikken. Det giver dig mulighed for at ændre abonnementsgrænser uden en udrulning.

Cache abonnementsdata aggressivt: Grænser ændres sjældent, så en kortvarig cache i hukommelsen med en TTL (eller Next.js' indbyggede unstable_cache) undgår en databaseforespørgsel ved hver anmodning.

// 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
);

Sammenkobling af det hele i en Server Action

Kombinér nu vagten, opslaget af planen og forøgelsen af forbruget i én enkelt serverhandling. Handlingen kører udelukkende på serveren; klienten berører aldrig faktureringslogikken.

Mønstret består altid af de samme tre trin:

  • 1. Godkend — fastslå lejerens kontekst
  • 2. Kontrollér — bekræft et aktivt abonnement, hent grænser, kontrollér forbrug
  • 3. Udfør — udfør den faktiske forretningslogik
// 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åndhævelse på middleware-niveau for API-ruter

Serverhandlinger er velegnede til formularstyrede forløb, men lejere bruger også forbrug gennem offentlige API-ruter (f.eks. /api/v1/data). Håndhæv målingen af forbrug her med en genanvendelig middleware-wrapper i stedet for at kopiere kontrolkoden ind i hver rutehåndtering.

Dette wrapper-mønster kaldes nogle gange en API-middlewarekæde eller en håndteringsfabrik. Det holder hver rutehåndtering fokuseret på 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);
// });

Synliggørelse af forbrug for lejeren: Forbrugsdashboardet

Lejere har brug for indsigt i deres forbrug, så de kan træffe velovervejede beslutninger om opgradering. Et API-slutpunkt for forbrug returnerer forbruget i den aktuelle periode sammen med planens grænser.

Ved at returnere både used og limit kan frontend'en vise en statuslinje uden særskilt kendskab til planens detaljer. Returnér et percentage-felt, der er beregnet på forhånd på serveren, for at gøre klienten 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,
    },
  });
}

Nulstilling af forbrug ved fornyelse af faktureringsperioden

Forbrugstællere skal nulstilles, når en faktureringsperiode fornyes. Den reneste tilgang er en Stripe-webhookhåndtering, der lytter efter hændelser af typen invoice.paid og nulstiller tællerne for den pågældende lejer.

Stol aldrig på et cron-job, der kontrollerer datoer — det kan komme ud af takt, blive kørt to gange eller overse en fornyelse. Stripe-hændelser er det autoritative signal om, at en ny faktureringsperiode er begyndt.

  • Bekræft webhookens signatur med stripe.webhooks.constructEvent
  • Brug en upsert til at oprette eller nulstille rækken tenant_usage for den nye periode
  • Gem stripe_subscription_id på lejeren, så du kan slå den op ud fra hændelsen
// 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 });
}

Funktionsbegrænsning efter plantype

Ud over mængdebegrænsninger (hvor mange API-kald) begrænser SaaS-produkter også funktioner efter plantype. En Starter-lejer bør ikke kunne aktivere SSO eller få adgang til revisionsloggen, uanset deres forbrugstal.

Modellér funktionsflag som et statisk opslag med plantypen som nøgle. Kontrollér funktionen i serverhandlingen eller rutehåndteringen, før du fortsætter. På den måde er funktionsdefinitionerne samlet ét sted, og ændringer af plantyper kræver kun en kodeændring.

// 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'
//   );
// }

Videnscheck: Atomisk håndhævelse af forbrug

Et team udvikler en flerlejer-SaaS-app. De implementerer måling af API-forbrug sådan:

  1. Læs api_calls_used fra databasen
  2. Fortsæt, hvis used < limit
  3. Forøg api_calls_used med 1, efter handlingen er fuldført

Hvad er det primære problem ved denne tilgang?

Opsummering: Måling af forbrug og håndhævelse af abonnementer

Du har nu et komplet målingssystem af produktionskvalitet til en flerlejer-Next.js 15-SaaS. Her er en opsummering af de vigtigste mønstre, der er gennemgået:

  • Typet datamodel: Adskil subscription_plans (grænser) fra tenant_usage (tællere), så opslag bliver hurtige, og grænser kan konfigureres uden implementeringer.
  • Lejerkontekstopløser: Én enkelt funktion, requireTenantContext(), der bruges af alle serverhandlinger og rutehåndteringer.
  • Kontrol af abonnementsstatus: Kontrollér altid status active eller trialing, før du kontrollerer grænser — en konto med status past_due blokeres uanset forbruget.
  • Atomisk forøgelse: Brug en Postgres-funktion med FOR UPDATE til at kontrollere og forøge i én sætning, så kapløbstilstande undgås.
  • Cachede plangrænser: Brug unstable_cache med en kort TTL, så plandata ikke hentes ved hver forespørgsel.
  • Genanvendelig wrapper: withMetering() omslutter rutehåndteringer på en enkel måde og holder forretningslogik adskilt fra faktureringslogik.
  • Nulstilling via Stripe-webhooks: Lyt efter invoice.paid for at nulstille forbrugstællere — stol aldrig på cron-job til grænserne for faktureringsperioder.
  • Funktionsbegrænsning: Et statisk FEATURE_MAP pr. plantype styrer adgangen til funktioner uafhængigt af mængdebegrænsninger.

Ved at kombinere disse mønstre får hver lejer en fair, håndhævelig og gennemsigtig serviceoplevelse, samtidig med at din infrastruktur beskyttes mod overforbrug.

Gratis at komme i gang

Lær TypeScript med en AI-underviser — gratis

Skriv og kør rigtig kode i din browser, få øjeblikkelig hjælp fra en AI-underviser døgnet rundt, og fortsæt, hvor du slap, på web eller i appen.

Kurser
22
Lektioner
88

Ofte stillede spørgsmål

Er lektionen “Forbrugsmåling og håndhævelse af abonnementer” gratis?

Ja — alle 3 lektioner i læringssporet Next.js 15 i full stack (App Router + Server Actions), inklusive “Forbrugsmåling og håndhævelse af abonnementer”, kan læses gratis i deres fulde længde her på webstedet. Derefter låser CoddyKit PRO alle lektioner op samt interaktive øvelser med en indbygget kodeeditor og en AI-underviser døgnet rundt. Next.js 15 i full stack (App Router + Server Actions)-kurset indeholder 4 lektioner i alt.

Hvad lærer jeg i “Forbrugsmåling og håndhævelse af abonnementer”?

Spor forbrug pr. tenant, og begræns adgangen ud fra plangrænser og betalingsstatus. Du øver dig i Next.js 15 i full stack (App Router + Server Actions) med praktisk kode, som du kører direkte i browseren, og en AI-vejleder døgnet rundt besvarer dine spørgsmål, mens du arbejder dig gennem lektionen.

Skal jeg have erfaring for at begynde på Next.js 15 i full stack (App Router + Server Actions)?

Der kræves ingen tidligere erfaring. Next.js 15 i full stack (App Router + Server Actions) på CoddyKit er tilrettelagt for både begyndere og øvede, så du kan starte her eller fra begyndelsen og lære i dit eget tempo. Dette er lektion 4 af 4.

Hvor lang tid tager lektionen “Forbrugsmåling og håndhævelse af abonnementer”?

De fleste CoddyKit-lektioner tager cirka 5–10 minutter. Hver lektion er kort og interaktiv, så du gør løbende fremskridt og kan fortsætte, hvor du slap – på både web og app.

Kan jeg skrive og køre kode i denne Next.js 15 i full stack (App Router + Server Actions)-lektion?

Ja. Alle Next.js 15 i full stack (App Router + Server Actions)-lektioner har en indbygget kodeeditor, så du kan skrive og køre rigtig kode direkte i din browser og få øjeblikkelig feedback fra AI – uden lokal opsætning.

Alle lektioner i dette kursus

  1. Tenant-opslag baseret på subdomæne og sti
  2. Mønstre til tenant-isolering på rækkeniveau
  3. Tematisering og feature flags pr. tenant
  4. Forbrugsmåling og håndhævelse af abonnementer
← Tilbage til Next.js 15 i full stack (App Router + Server Actions)