Next.js 15 Fullstack (App Router + Server Actions) · レッスン

使用量の計測とサブスクリプションの制御

テナントごとの使用量を追跡し、プラン上限と請求ステータスに基づいてアクセスを制御します。

レッスン 4/413 ステップ

「使用量の計測とサブスクリプションの制御」はCoddyKit上の無料Next.js 15 Fullstack (App Router + Server Actions)レッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはNext.js 15 Fullstack (App Router + Server Actions)学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Next.js 15 Fullstack (App Router + Server Actions)コースには全4レッスンが含まれています。

このレッスンの一部はまだ翻訳されておらず、英語で表示されています。

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.

無料で開始

AI チューターと学ぶ TypeScript — 無料

ブラウザでリアルコードを書いて実行し、24/7 の AI チューターから瞬時にサポートを受け、ウェブまたはアプリで続きから学習できます。

コース
22
レッスン
88

よくある質問

「使用量の計測とサブスクリプションの制御」レッスンは無料ですか?

はい。「使用量の計測とサブスクリプションの制御」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Next.js 15 Fullstack (App Router + Server Actions)コースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Next.js 15 Fullstack (App Router + Server Actions)コースには全4レッスンが含まれています。

「使用量の計測とサブスクリプションの制御」で何を学びますか?

テナントごとの使用量を追跡し、プラン上限と請求ステータスに基づいてアクセスを制御します。 ブラウザで直接実行するハンズオンコードでNext.js 15 Fullstack (App Router + Server Actions)を演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

Next.js 15 Fullstack (App Router + Server Actions)を始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのNext.js 15 Fullstack (App Router + Server Actions)は初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。

「使用量の計測とサブスクリプションの制御」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このNext.js 15 Fullstack (App Router + Server Actions)レッスンでコードを書いて実行できますか?

はい。すべてのNext.js 15 Fullstack (App Router + Server Actions)レッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. サブドメインとパスベースのテナント解決
  2. 行レベルのテナントデータ分離パターン
  3. テナントごとのテーマと機能フラグ
  4. 使用量の計測とサブスクリプションの制御
← Next.js 15 Fullstack (App Router + Server Actions)に戻る