Next.js 15 fullstack (App Router + Server Actions) · Lektion

Användningsmätning och prenumerationsbegränsningar

Följ användningen per tenant och begränsa åtkomsten utifrån plangränser och faktureringsstatus.

Lektion 4 av 413 steg

Användningsmätning och prenumerationsbegränsningar är en gratis lektion i Next.js 15 fullstack (App Router + Server Actions) på CoddyKit. Detta är lektion 4 av 4. Du kan läsa vilka 3 lektioner som helst i den här lärvägen kostnadsfritt i sin helhet – därefter låser CoddyKit PRO upp alla lektioner, plus praktisk övning med en inbyggd kodredigerare och en AI-lärare dygnet runt. Den ingår i lärvägen för Next.js 15 fullstack (App Router + Server Actions), och Era framsteg synkroniseras mellan webben och CoddyKit-appen. Kursen i Next.js 15 fullstack (App Router + Server Actions) innehåller totalt 4 lektioner.

Varför användningsmätning är viktig i SaaS

I en SaaS-applikation med flera tenants betalar olika kunder för olika nivåer. Ett Starter-abonnemang kanske tillåter 1 000 API-anrop per månad, medan ett Enterprise-abonnemang tillåter obegränsad åtkomst. Utan användningsmätning får alla tenants samma upplevelse, oavsett vad de betalar för.

Användningsmätning har två syften:

  • Upprätthållande: blockera eller begränsa tjänsten när gränser nås
  • Underlag för fakturering: skicka korrekta förbrukningsdata till er faktureringstjänst (till exempel Stripe)

I Next.js 15 med App Router passar mätningen naturligt in i Server Actions och Route Handlers — de två platser där det faktiska arbetet sker på servern.

Datamodell: tenants, abonnemang och användning

Börja med ett schema som beskriver relationen mellan en tenant, den aktiva abonnemangsplanen och de aktuella användningsräknarna. Ett minimalt Postgres-schema kan se ut så här:

  • tenants — en rad per organisation
  • subscription_plans — gränser per funktion och nivå
  • tenant_usage — löpande räknare som återställs vid varje faktureringsperiod

Genom att lagra användningen i en separat tabell (i stället för att summera händelseloggar vid varje begäran) blir kontrollen av gränser en enda indexerad läsning, vilket är avgörande för Server Actions med låg fördröjning.

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

Fastställa aktuell tenant i App Router

Varje kontroll måste veta vilken tenant som utför åtgärden. I App Router kodas tenant-identiteten vanligtvis i sessions-JWT:n eller härleds från subdomänen. En gemensam hjälpfunktion löser detta en gång och kastar ett fel om användaren inte är autentiserad.

Genom att placera detta i en lib/tenant.ts-modul hålls Server Actions och Route Handlers DRY — båda anropar samma resolver i stället för att tolka sessionen var för sig.

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

Kontrollera abonnemangsstatus före varje åtgärd

Innan ni kontrollerar användningsgränser ska ni alltid verifiera att tenantens abonnemang är aktivt och i gott skick. Ett konto med status past_due eller canceled bör blockeras även om det ännu inte har nått sina användningsgränser.

Samla den här logiken i en guard-funktion som både Server Actions och Route Handlers kan anropa. Kasta ett typat fel så att anroparna kan visa rätt gränssnitt (till exempel en banderoll med texten "Återaktivera abonnemang").

// 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 ökning av användning med gränskontroll

Den mest kritiska delen av mätningen är den atomiska kontrollen och ökningen. Ett naivt mönster — läs aktuell användning, jämför med gränsen och skriv sedan — har ett race condition: två samtidiga begäranden kan båda klara kontrollen innan någon av dem hinner öka räknaren.

Det korrekta tillvägagångssättet använder en enda SQL-sats som kontrollerar och ökar atomiskt och returnerar om åtgärden lyckades. I Postgres är detta en villkorad UPDATE ... RETURNING eller en lagrad procedur.

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

Hämta abonnemangsgränser vid körning

Abonnemangsgränser måste hämtas från databasen (eller en snabb cache) vid körning — hårdkoda dem aldrig i applikationslogiken. Då kan ni ändra abonnemangsgränser utan att distribuera en ny version.

Cacha abonnemangsdata aggressivt: gränser ändras sällan, så en kort minnescache med TTL (eller Next.js inbyggda unstable_cache) undviker en databasanrop vid varje begäran.

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

Koppla ihop allt i en Server Action

Kombinera nu guard, plansökning och inkrementering av användningen i en enda Server Action. Åtgärden körs helt på servern; klienten hanterar aldrig faktureringslogik.

Mönstret består alltid av samma tre steg:

  • 1. Autentisera — fastställ tenant-kontexten
  • 2. Kontrollera — verifiera aktiv prenumeration, hämta gränser och kontrollera användningen
  • 3. Kör — utför den faktiska affärslogiken
// 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();
}

Validering på middleware-nivå för API-routes

Server Actions passar utmärkt för formulärstyrda flöden, men tenants använder också kvoten via publika API-routes (t.ex. /api/v1/data). Tillämpa användningsmätning även här med en återanvändbar middleware-wrapper i stället för att kopiera guard-koden till varje Route Handler.

Det här wrapper-mönstret kallas ibland för en API-middlewarekedja eller en handler-fabrik. Det gör att varje Route Handler kan fokusera på affärslogiken.

// 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öra användningen för tenanten: användningspanelen

Tenants behöver insyn i sin användning för att kunna fatta välgrundade beslut om uppgradering. En API-endpoint för användning returnerar förbrukningen för den aktuella perioden tillsammans med planens gränser.

Genom att returnera både used och limit kan frontend rendera en förloppsindikator utan att behöva känna till plandetaljer separat. Returnera ett fält för percentage som beräknats på servern för att förenkla klienten.

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

Återställa användningen när faktureringsperioden förnyas

Användningsräknare måste återställas när en faktureringsperiod förnyas. Det renaste tillvägagångssättet är en Stripe-webhookhanterare som lyssnar efter händelser av typen invoice.paid och återställer räknarna för den tenanten.

Förlita er aldrig på ett cron-jobb som kontrollerar datum — det kan hamna ur fas, köras två gånger eller missa en förnyelse. Stripe-händelser är den auktoritativa signalen på att en ny faktureringsperiod har börjat.

  • Verifiera webhook-signaturen med stripe.webhooks.constructEvent
  • Använd en upsert för att skapa eller återställa raden tenant_usage för den nya perioden
  • Lagra stripe_subscription_id på tenanten så att ni kan slå upp den utifrån 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 plannivå

Utöver kvantitetsgränser (hur många API-anrop) begränsar SaaS-produkter också funktioner utifrån plannivå. En tenant med Starter ska inte kunna aktivera SSO eller få åtkomst till granskningsloggen, oavsett hur mycket av sin kvot de har använt.

Modellera funktionsflaggor som en statisk karta med nycklar efter plannivå. Kontrollera funktionen i Server Action eller Route Handler innan ni fortsätter. Då finns funktionsdefinitionerna samlade på ett ställe och ändringar av plannivåer kräver endast en koduppdatering.

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

Kunskapskontroll: atomisk användningsvalidering

Ett team bygger en multi-tenant SaaS-app. De implementerar användningsmätning för API-anrop på följande sätt:

  1. Läs api_calls_used från databasen
  2. Fortsätt om used < limit
  3. Inkrementera api_calls_used med 1 efter att åtgärden har slutförts

Vad är det huvudsakliga problemet med detta tillvägagångssätt?

Sammanfattning: användningsmätning och prenumerationsvalidering

Ni har nu ett komplett system för användningsmätning, redo för produktion, för en multi-tenant SaaS-app med Next.js 15. Här är en sammanfattning av de viktigaste mönstren:

  • Typad datamodell: Separera subscription_plans (gränser) från tenant_usage (räknare) för att göra läsningar snabba och gränser konfigurerbara utan nya deployer.
  • Resolver för tenant-kontext: En enda funktion, requireTenantContext(), som används av alla Server Actions och Route Handlers.
  • Guard för prenumerationsstatus: Kontrollera alltid statusen active eller trialing innan ni kontrollerar gränser — ett konto med statusen past_due blockeras oavsett användning.
  • Atomisk inkrementering: Använd en Postgres-funktion med FOR UPDATE för att kontrollera och inkrementera i samma sats, vilket eliminerar race conditions.
  • Cachelagrade plangränser: Använd unstable_cache med en kort TTL så att plandata inte hämtas vid varje begäran.
  • Återanvändbar wrapper: withMetering() kapslar in Route Handlers på ett tydligt sätt och håller affärslogik åtskild från faktureringslogik.
  • Återställning via Stripe-webhook: Lyssna efter invoice.paid för att återställa användningsräknare — förlita er aldrig på cron-jobb för gränser mellan faktureringsperioder.
  • Funktionsbegränsning: En statisk FEATURE_MAP per plannivå styr åtkomsten till funktioner oberoende av kvantitetsgränser.

Genom att kombinera dessa mönster får varje tenant en rättvis, kontrollerbar och transparent tjänsteupplevelse, samtidigt som infrastrukturen skyddas mot överförbrukning.

Gratis att börja

Lär dig TypeScript med en AI-lärare – gratis

Skriv och kör riktig kod i webbläsaren, få omedelbar hjälp av en AI-lärare dygnet runt och fortsätt där du slutade – på webben eller i appen.

Kurser
22
Lektioner
88

Vanliga frågor

Är lektionen ”Användningsmätning och prenumerationsbegränsningar” gratis?

Ja – du kan läsa vilka 3 lektioner som helst i lärvägen Next.js 15 fullstack (App Router + Server Actions), inklusive ”Användningsmätning och prenumerationsbegränsningar”, kostnadsfritt i sin helhet här på webben. Därefter låser CoddyKit PRO upp alla lektioner, plus interaktiv övning med en inbyggd kodredigerare och en AI-lärare dygnet runt. Kursen i Next.js 15 fullstack (App Router + Server Actions) innehåller totalt 4 lektioner.

Vad lär jag mig i ”Användningsmätning och prenumerationsbegränsningar”?

Följ användningen per tenant och begränsa åtkomsten utifrån plangränser och faktureringsstatus. Ni övar på Next.js 15 fullstack (App Router + Server Actions) med praktisk kod som körs direkt i webbläsaren, medan en AI-handledare som är tillgänglig dygnet runt svarar på Era frågor under lektionen.

Behöver jag någon erfarenhet för att börja lära mig Next.js 15 fullstack (App Router + Server Actions)?

Du behöver inga förkunskaper. Utbildningen i Next.js 15 fullstack (App Router + Server Actions) på CoddyKit är upplagd för allt från nybörjare till avancerade elever, så att du kan börja här eller från början och gå fram i din egen takt. Detta är lektion 4 av 4.

Hur lång tid tar lektionen ”Användningsmätning och prenumerationsbegränsningar”?

De flesta CoddyKit-lektioner tar cirka 5–10 minuter. Varje lektion är kort och interaktiv, så att du gör stadiga framsteg och kan fortsätta precis där du slutade – på webben eller i appen.

Kan jag skriva och köra kod i den här Next.js 15 fullstack (App Router + Server Actions)-lektionen?

Ja. Varje Next.js 15 fullstack (App Router + Server Actions)-lektion innehåller en inbyggd kodredigerare, så att du kan skriva och köra riktig kod direkt i webbläsaren och få omedelbar AI-feedback – utan lokal installation.

Alla lektioner i den här kursen

  1. Tenantidentifiering baserad på subdomän och sökväg
  2. Mönster för isolering av tenantdata på radnivå
  3. Teman och feature flags per tenant
  4. Användningsmätning och prenumerationsbegränsningar
← Tillbaka till Next.js 15 fullstack (App Router + Server Actions)