0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · Ders

Kullanım Ölçümü ve Abonelik Uygulaması

Kiracı başına kullanımı izleyin ve erişimi plan sınırları ile faturalandırma durumuna göre kısıtlayın.

Kullanım Ölçümü ve Abonelik Uygulaması, CoddyKit'te ücretsiz bir Next.js 15 Fullstack (App Router + Server Actions) dersidir. Bu, 4 dersinin 4. dersidir. Aşağıdan dersin tamamını ücretsiz okuyabilir, sonra tarayıcıda yerleşik kod editörü ve 7/24 yapay zeka koçu ile uygulamalı olarak pratik yapabilirsin. Bu, Next.js 15 Fullstack (App Router + Server Actions) öğrenme yolunun bir parçasıdır ve ilerlemeniz web ve CoddyKit uygulaması arasında senkronize olur. Next.js 15 Fullstack (App Router + Server Actions) kursu toplamda 4 dersten oluşur.

Bu dersin bazı bölümleri henüz çevrilmemiş olup İngilizce olarak gösterilmektedir.

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 organisation
  • subscription_plans — limits per feature per tier
  • tenant_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 upsert to create or reset the tenant_usage row for the new period
  • Store the stripe_subscription_id on 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:

  1. Read api_calls_used from the database
  2. If used < limit, proceed
  3. After the operation completes, increment api_calls_used by 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) from tenant_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 active or trialing status before checking limits — a past_due account is blocked regardless of usage.
  • Atomic increment: Use a Postgres function with FOR UPDATE to check and increment in one statement, eliminating race conditions.
  • Cached plan limits: Use unstable_cache with 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.paid to reset usage counters — never rely on cron jobs for billing period boundaries.
  • Feature gating: A static FEATURE_MAP per 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.

Sıkça Sorulan Sorular

“Kullanım Ölçümü ve Abonelik Uygulaması” dersi ücretsiz mi?

Evet — “Kullanım Ölçümü ve Abonelik Uygulaması” dersin tüm metni burada web'de ücretsiz olarak okunabilir. Etkileşimli olarak pratik yapmak (yerleşik kod editörü ve 7/24 yapay zeka koçu) ve Next.js 15 Fullstack (App Router + Server Actions) kursunun geri kalanını açmak için CoddyKit PRO'ya yükselt. Next.js 15 Fullstack (App Router + Server Actions) kursu toplamda 4 dersten oluşur.

“Kullanım Ölçümü ve Abonelik Uygulaması” dersinde ne öğreneceğim?

Kiracı başına kullanımı izleyin ve erişimi plan sınırları ile faturalandırma durumuna göre kısıtlayın. Next.js 15 Fullstack (App Router + Server Actions) ile uygulamalı kodu tarayıcıda doğrudan çalıştırarak pratik yaparsın ve 7/24 yapay zeka koçu dersi çalışırken sorularını yanıtlar.

Next.js 15 Fullstack (App Router + Server Actions) öğrenmeye başlamak için deneyim gerekli mi?

Önceden deneyim gerekmez. CoddyKit'te Next.js 15 Fullstack (App Router + Server Actions), başlangıçtan ileri seviyeye kadar yapılandırıldığı için buradan başlayabilir veya başından başlayıp kendi hızında ilerleme yapabilirsin. Bu, 4 dersinin 4. dersidir.

“Kullanım Ölçümü ve Abonelik Uygulaması” dersi ne kadar sürer?

Çoğu CoddyKit dersi yaklaşık 5–10 dakika sürer. Her biri kısa ve etkileşimli olduğu için sabit ilerleme yaparsın ve web ile uygulama arasında tam olarak bıraktığın yerden devam edebilirsin.

Bu Next.js 15 Fullstack (App Router + Server Actions) dersinde kod yazıp çalıştırabilir miyim?

Evet. Her Next.js 15 Fullstack (App Router + Server Actions) dersi yerleşik bir kod editörü içerir, bu sayede tarayıcıda gerçek kod yazıp çalıştırabilir ve anlık yapay zeka geri bildirimi alırsın — yerel kurulum gerekli değildir.

Bu kursun tüm dersleri

  1. Alt Alan Adı ve Yol Tabanlı Kiracı Çözümleme
  2. Satır Düzeyinde Kiracı Verisi Yalıtımı Desenleri
  3. Kiracı Başına Tema ve Özellik Bayrakları
  4. Kullanım Ölçümü ve Abonelik Uygulaması
← Next.js 15 Fullstack (App Router + Server Actions) Sayfasına Dön