Fullstackontwikkeling met Next.js 15 (App Router + Server Actions) · Les

Gebruiksmeting en abonnementscontrole

Volg gebruik per tenant en beperk toegang op basis van plangrenzen en factureringsstatus.

Les 4 van 413 stappen

Gebruiksmeting en abonnementscontrole is een gratis Fullstackontwikkeling met Next.js 15 (App Router + Server Actions)-les op CoddyKit. Dit is les 4 van 4. Je kunt 3 lessen uit dit leerpad gratis volledig lezen — daarna ontgrendelt CoddyKit PRO alle lessen, plus praktische oefeningen met een ingebouwde code-editor en een AI-tutor die 24/7 beschikbaar is. Deze les maakt deel uit van het leertraject Fullstackontwikkeling met Next.js 15 (App Router + Server Actions). Je voortgang wordt gesynchroniseerd op het web en in de CoddyKit-app. De cursus Fullstackontwikkeling met Next.js 15 (App Router + Server Actions) bevat in totaal 4 lessen.

Waarom gebruiksmeting belangrijk is in SaaS

In een multi-tenant SaaS-toepassing betalen verschillende klanten voor verschillende abonnementstypen. Met een Starter-abonnement zijn bijvoorbeeld 1.000 API-aanroepen per maand toegestaan, terwijl een Enterprise-abonnement onbeperkte toegang biedt. Zonder gebruiksmeting krijgt elke tenant dezelfde ervaring, ongeacht waarvoor die betaalt.

Gebruiksmeting heeft twee doelen:

  • Afdwinging: blokkeer de dienstverlening of beperk deze wanneer limieten zijn bereikt
  • Signalen voor facturering: lever nauwkeurige verbruiksgegevens aan je factureringsprovider (bijvoorbeeld Stripe)

In Next.js 15 met de App Router past gebruiksmeting natuurlijk in Server Actions en Route Handlers — de twee plekken waar het echte werk op de server gebeurt.

Datamodel: tenants, abonnementen en gebruik

Begin met een schema dat de relatie vastlegt tussen een tenant, het actieve abonnementstype en de huidige gebruikstellers. Een minimaal Postgres-schema ziet er als volgt uit:

  • tenants — één rij per organisatie
  • subscription_plans — limieten per functie en per abonnementstype
  • tenant_usage — doorlopende tellers die aan het begin van een factureringscyclus worden gereset

Door gebruik in een aparte tabel bij te houden (in plaats van bij elk verzoek gebeurtenislogboeken op te tellen), worden limietcontroles één geïndexeerde leesbewerking. Dat is cruciaal voor Server Actions met een lage latentie.

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

De huidige tenant bepalen in de App Router

Bij elke controle moet bekend zijn welke tenant de actie uitvoert. In de App Router wordt de tenantidentiteit meestal vastgelegd in de sessie-JWT of afgeleid uit het subdomein. Een gedeelde hulpfunctie bepaalt deze identiteit één keer en geeft een fout als de gebruiker niet is geauthenticeerd.

Door dit in een module lib/tenant.ts te plaatsen, houd je Server Actions en Route Handlers volgens het DRY-principe — beide gebruiken dezelfde bepaler in plaats van de sessie afzonderlijk te parseren.

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

De abonnementsstatus controleren vóór elke actie

Controleer altijd eerst of het abonnement van de tenant actief is voordat je gebruikslimieten controleert. Een account met de status past_due of canceled moet worden geblokkeerd, ook als de gebruikslimieten nog niet zijn bereikt.

Centraliseer deze logica in een bewakingsfunctie die zowel Server Actions als Route Handlers kunnen aanroepen. Werp een getypeerde fout op, zodat aanroepers de juiste gebruikersinterface kunnen weergeven (bijvoorbeeld een banner met de tekst "Abonnement opnieuw activeren").

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

Gebruik atomair verhogen met limietcontrole

Het belangrijkste onderdeel van gebruiksmeting is de atomische controle-en-verhoging. Een naïef patroon — het huidige gebruik lezen, dit met de limiet vergelijken en daarna schrijven — bevat een raceconditie: twee gelijktijdige verzoeken kunnen de controle allebei doorstaan voordat een van beide de teller verhoogt.

De juiste aanpak gebruikt één SQL-instructie die de teller atomair controleert en verhoogt en teruggeeft of de bewerking is geslaagd. In Postgres is dit een voorwaardelijke UPDATE ... RETURNING of een opgeslagen 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;
*/

Abonnementslimieten tijdens runtime ophalen

Abonnementslimieten moeten tijdens runtime uit de database (of een snelle cache) worden opgehaald — hardcode ze nooit in de applicatielogica. Zo kun je limieten wijzigen zonder een deployment.

Cache abonnementsgegevens agressief: limieten veranderen zelden, dus een korte in-memory-cache met een TTL (of Next.js' ingebouwde unstable_cache) voorkomt een databaseaanvraag bij elk verzoek.

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

Alles samenbrengen in een Server Action

Combineer nu de guard, het opzoeken van het plan en de gebruiksverhoging in één Server Action. De actie wordt volledig op de server uitgevoerd; de client komt nooit in aanraking met factureringslogica.

Het patroon bestaat altijd uit dezelfde drie stappen:

  • 1. Authenticeren — de tenantcontext bepalen
  • 2. Guard — actief abonnement bevestigen, limieten ophalen en gebruik controleren
  • 3. Uitvoeren — de echte bedrijfslogica uitvoeren
// 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();
}

Handhaving op middleware-niveau voor API-routes

Server Actions zijn ideaal voor door formulieren gestuurde processen, maar tenants verbruiken ook gebruik via openbare API-routes (bijvoorbeeld /api/v1/data). Meet het gebruik hier met een herbruikbare middleware-wrapper in plaats van guard-code naar elke Route Handler te kopiëren.

Dit wrapperpatroon wordt soms een API-middlewareketen of handlerfabriek genoemd. Zo kan elke Route Handler zich richten op de bedrijfslogica.

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

Gebruik zichtbaar maken voor de tenant: het gebruiksdashboard

Tenants moeten inzicht hebben in hun gebruik, zodat ze weloverwogen beslissingen kunnen nemen over een upgrade. Een API-eindpunt voor gebruik retourneert het verbruik van de huidige periode samen met de planlimieten.

Door zowel used als limit te retourneren, kan de frontend een voortgangsbalk weergeven zonder de plandetails afzonderlijk te kennen. Retourneer een veld percentage dat vooraf op de server is berekend, zodat de client eenvoudiger blijft.

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

Gebruik opnieuw instellen bij verlenging van de factureringscyclus

Gebruiktellers moeten opnieuw worden ingesteld wanneer een factureringsperiode wordt verlengd. De duidelijkste aanpak is een Stripe-webhookhandler die luistert naar gebeurtenissen van het type invoice.paid en de tellers voor die tenant opnieuw instelt.

Vertrouw nooit op een cron-taak die datums controleert — die kan verschuiven, twee keer worden uitgevoerd of een verlenging missen. Stripe-gebeurtenissen zijn het gezaghebbende signaal dat een nieuwe factureringsperiode is begonnen.

  • Verifieer de handtekening van de webhook met stripe.webhooks.constructEvent
  • Gebruik upsert om de rij tenant_usage voor de nieuwe periode te maken of opnieuw in te stellen
  • Sla stripe_subscription_id op bij de tenant, zodat je deze vanuit de gebeurtenis kunt opzoeken
// 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 });
}

Functies beperken per planniveau

Naast kwantitatieve limieten (hoeveel API-aanroepen) beperken SaaS-producten ook functies per planniveau. Een Starter-tenant mag SSO niet kunnen inschakelen en het auditlogboek niet kunnen openen, ongeacht het aantal gebruikte eenheden.

Modelleer functievlaggen als een statische kaart met het planniveau als sleutel. Controleer de functie in de Server Action of Route Handler voordat je doorgaat. Zo staan functiedefinities op één plek en is een wijziging van een planniveau alleen een codewijziging.

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

Kennischeck: atomische handhaving van gebruikslimieten

Een team bouwt een multi-tenant SaaS-app. Het meet het gebruik van API-aanroepen als volgt:

  1. Lees api_calls_used uit de database
  2. Ga door als used < limit
  3. Verhoog api_calls_used met 1 nadat de bewerking is voltooid

Wat is het belangrijkste probleem met deze aanpak?

Samenvatting: gebruiksmeting en handhaving van abonnementen

Je beschikt nu over een compleet, productieklaar systeem voor gebruiksmeting in een multi-tenant Next.js 15 SaaS-app. Hieronder staat een samenvatting van de belangrijkste behandelde patronen:

  • Getypeerd datamodel: Splits subscription_plans (limieten) en tenant_usage (tellers) op, zodat reads snel zijn en limieten zonder nieuwe deploys kunnen worden geconfigureerd.
  • Resolver voor tenantcontext: Eén functie requireTenantContext() die door alle Server Actions en Route Handlers wordt gebruikt.
  • Guard voor abonnementsstatus: Controleer altijd de status active of trialing voordat je limieten controleert — een account met status past_due wordt ongeacht het gebruik geblokkeerd.
  • Atomische verhoging: Gebruik een Postgres-functie met FOR UPDATE om in één statement te controleren en te verhogen, zodat raceomstandigheden worden voorkomen.
  • Gecachte planlimieten: Gebruik unstable_cache met een korte TTL, zodat plangegevens niet bij elk verzoek opnieuw worden opgehaald.
  • Herbruikbare wrapper: withMetering() wikkelt Route Handlers netjes in, zodat bedrijfslogica gescheiden blijft van factureringslogica.
  • Resetten via Stripe-webhooks: Luister naar invoice.paid om gebruikstellers opnieuw in te stellen — vertrouw nooit op cron-taken voor grenzen van factureringsperioden.
  • Functiebeperkingen: Een statische FEATURE_MAP per planniveau bepaalt onafhankelijk van kwantitatieve limieten tot welke mogelijkheden toegang is.

Door deze patronen te combineren, krijgt elke tenant een eerlijke, afdwingbare en transparante service-ervaring en bescherm je je infrastructuur tegen overmatig gebruik.

Gratis beginnen

Leer TypeScript met een AI-tutor — gratis

Schrijf echte code en voer die uit in je browser, krijg direct hulp van een AI-tutor die 24/7 beschikbaar is en ga verder waar je gebleven bent op het web of in de app.

Cursussen
22
Lessen
88

Veelgestelde vragen

Is de les “Gebruiksmeting en abonnementscontrole” gratis?

Ja — je kunt hier op het web alle 3 lessen van het leerpad Fullstackontwikkeling met Next.js 15 (App Router + Server Actions), waaronder “Gebruiksmeting en abonnementscontrole”, gratis volledig lezen. Daarna ontgrendelt CoddyKit PRO alle lessen, plus interactieve oefeningen met een ingebouwde code-editor en een AI-tutor die 24/7 beschikbaar is. De cursus Fullstackontwikkeling met Next.js 15 (App Router + Server Actions) bevat in totaal 4 lessen.

Wat leer ik in “Gebruiksmeting en abonnementscontrole”?

Volg gebruik per tenant en beperk toegang op basis van plangrenzen en factureringsstatus. Je oefent met Fullstackontwikkeling met Next.js 15 (App Router + Server Actions) door code rechtstreeks in de browser uit te voeren. Een AI-begeleider die 24/7 beschikbaar is beantwoordt je vragen terwijl je de les doorwerkt.

Heb ik ervaring nodig om met Fullstackontwikkeling met Next.js 15 (App Router + Server Actions) te beginnen?

Ervaring vooraf is niet nodig. Fullstackontwikkeling met Next.js 15 (App Router + Server Actions) op CoddyKit is opgebouwd voor beginners tot gevorderden, zodat je hier of bij het begin kunt starten en in je eigen tempo kunt leren. Dit is les 4 van 4.

Hoe lang duurt de les “Gebruiksmeting en abonnementscontrole”?

De meeste lessen van CoddyKit duren ongeveer 5–10 minuten. Elke les is kort en interactief, zodat je gestaag vooruitgaat en op het web en in de app precies verdergaat waar je was gebleven.

Kan ik code schrijven en uitvoeren in deze les over Fullstackontwikkeling met Next.js 15 (App Router + Server Actions)?

Ja. Elke les over Fullstackontwikkeling met Next.js 15 (App Router + Server Actions) bevat een ingebouwde code-editor, zodat je rechtstreeks in je browser echte code kunt schrijven en uitvoeren en direct feedback van AI krijgt — lokale installatie is niet nodig.

Alle lessen in deze cursus

  1. Tenantresolutie op basis van subdomein en pad
  2. Patronen voor tenantisolatie op rijniveau
  3. Thema’s en feature flags per tenant
  4. Gebruiksmeting en abonnementscontrole
← Terug naar Fullstackontwikkeling met Next.js 15 (App Router + Server Actions)