Usage-Based Billing Patterns

Implement metered billing with Stripe for pay-as-you-go pricing.

Overview#

Usage-based billing charges customers based on consumption. This pattern covers:

  • Recording usage with Stripe
  • Usage tracking in your database
  • Usage limits and enforcement
  • Usage dashboard components
  • Billing cycle management

Prerequisites#

npm install stripe

Record Usage#

Send usage records to Stripe for metered billing.

1// lib/usage.ts 2import { stripe } from '@/lib/stripe' 3import { prisma } from '@/lib/db' 4 5export async function recordUsage( 6 userId: string, 7 metric: string, 8 quantity: number 9) { 10 // Get subscription with metered price 11 const subscription = await prisma.subscription.findUnique({ 12 where: { userId }, 13 include: { user: true } 14 }) 15 16 if (!subscription?.stripeSubscriptionId) { 17 throw new Error('No active subscription') 18 } 19 20 // Find the metered subscription item 21 const stripeSubscription = await stripe.subscriptions.retrieve( 22 subscription.stripeSubscriptionId 23 ) 24 25 const meteredItem = stripeSubscription.items.data.find( 26 item => item.price.recurring?.usage_type === 'metered' 27 ) 28 29 if (!meteredItem) { 30 throw new Error('No metered subscription item found') 31 } 32 33 // Record usage with Stripe 34 await stripe.subscriptionItems.createUsageRecord(meteredItem.id, { 35 quantity, 36 timestamp: Math.floor(Date.now() / 1000), 37 action: 'increment' 38 }) 39 40 // Also store locally for quick access 41 await prisma.usageRecord.create({ 42 data: { 43 userId, 44 metric, 45 quantity, 46 timestamp: new Date() 47 } 48 }) 49}

Usage Tracking Middleware#

Track API usage automatically.

1// lib/usage-middleware.ts 2import { prisma } from '@/lib/db' 3 4export async function trackApiUsage(userId: string, endpoint: string) { 5 // Buffer usage records for batch processing 6 await prisma.usageBuffer.create({ 7 data: { 8 userId, 9 metric: 'api_calls', 10 endpoint, 11 timestamp: new Date() 12 } 13 }) 14} 15 16// Background job to flush usage to Stripe (run every minute) 17export async function flushUsageToStripe() { 18 // Group buffered records by user and metric 19 const bufferRecords = await prisma.usageBuffer.groupBy({ 20 by: ['userId', 'metric'], 21 _count: { id: true }, 22 where: { flushedAt: null } 23 }) 24 25 for (const record of bufferRecords) { 26 try { 27 await recordUsage(record.userId, record.metric, record._count.id) 28 29 // Mark as flushed 30 await prisma.usageBuffer.updateMany({ 31 where: { 32 userId: record.userId, 33 metric: record.metric, 34 flushedAt: null 35 }, 36 data: { flushedAt: new Date() } 37 }) 38 } catch (error) { 39 console.error(`Failed to flush usage for ${record.userId}:`, error) 40 } 41 } 42}

Usage Summary#

Get usage summary for the current billing period.

1// lib/usage.ts 2import { startOfMonth, endOfMonth } from 'date-fns' 3 4export async function getUsageSummary( 5 userId: string, 6 period?: { start: Date; end: Date } 7) { 8 const startDate = period?.start ?? startOfMonth(new Date()) 9 const endDate = period?.end ?? new Date() 10 11 const usage = await prisma.usageRecord.groupBy({ 12 by: ['metric'], 13 where: { 14 userId, 15 timestamp: { 16 gte: startDate, 17 lte: endDate 18 } 19 }, 20 _sum: { quantity: true } 21 }) 22 23 return usage.reduce((acc, record) => { 24 acc[record.metric] = record._sum.quantity ?? 0 25 return acc 26 }, {} as Record<string, number>) 27} 28 29export async function getUsageHistory( 30 userId: string, 31 metric: string, 32 days: number = 30 33) { 34 const startDate = new Date() 35 startDate.setDate(startDate.getDate() - days) 36 37 const records = await prisma.usageRecord.findMany({ 38 where: { 39 userId, 40 metric, 41 timestamp: { gte: startDate } 42 }, 43 orderBy: { timestamp: 'asc' } 44 }) 45 46 // Group by day 47 const byDay = records.reduce((acc, record) => { 48 const day = record.timestamp.toISOString().split('T')[0] 49 acc[day] = (acc[day] ?? 0) + record.quantity 50 return acc 51 }, {} as Record<string, number>) 52 53 return byDay 54}

Usage Limits#

Define and enforce usage limits per plan.

1// lib/usage-limits.ts 2import { PLANS, PlanId } from './plans' 3import { getUserPlan } from './billing' 4import { getUsageSummary } from './usage' 5 6export const USAGE_LIMITS: Record<PlanId, Record<string, number>> = { 7 free: { 8 api_calls: 1000, 9 ai_tokens: 10000, 10 storage_mb: 100 11 }, 12 pro: { 13 api_calls: 50000, 14 ai_tokens: 500000, 15 storage_mb: 5000 16 }, 17 enterprise: { 18 api_calls: -1, // unlimited 19 ai_tokens: -1, 20 storage_mb: -1 21 } 22} 23 24export async function checkUsageLimit( 25 userId: string, 26 metric: string 27): Promise<{ allowed: boolean; current: number; limit: number }> { 28 const planId = await getUserPlan(userId) 29 const limit = USAGE_LIMITS[planId][metric] 30 31 // Unlimited 32 if (limit === -1) { 33 return { allowed: true, current: 0, limit: -1 } 34 } 35 36 const usage = await getUsageSummary(userId) 37 const current = usage[metric] ?? 0 38 39 return { 40 allowed: current < limit, 41 current, 42 limit 43 } 44} 45 46export async function enforceUsageLimit(userId: string, metric: string) { 47 const { allowed, current, limit } = await checkUsageLimit(userId, metric) 48 49 if (!allowed) { 50 throw new UsageLimitExceededError(metric, current, limit) 51 } 52} 53 54export class UsageLimitExceededError extends Error { 55 constructor( 56 public metric: string, 57 public current: number, 58 public limit: number 59 ) { 60 super(`Usage limit exceeded for ${metric}: ${current}/${limit}`) 61 this.name = 'UsageLimitExceededError' 62 } 63}

Usage Dashboard Component#

Display usage metrics to users.

1// components/usage/UsageDashboard.tsx 2import { getUsageSummary } from '@/lib/usage' 3import { USAGE_LIMITS } from '@/lib/usage-limits' 4import { getUserPlan } from '@/lib/billing' 5 6interface Props { 7 userId: string 8} 9 10export async function UsageDashboard({ userId }: Props) { 11 const planId = await getUserPlan(userId) 12 const usage = await getUsageSummary(userId) 13 const limits = USAGE_LIMITS[planId] 14 15 const metrics = [ 16 { key: 'api_calls', label: 'API Calls', format: (n: number) => n.toLocaleString() }, 17 { key: 'ai_tokens', label: 'AI Tokens', format: (n: number) => n.toLocaleString() }, 18 { key: 'storage_mb', label: 'Storage', format: (n: number) => `${n} MB` } 19 ] 20 21 return ( 22 <div className="grid gap-4 md:grid-cols-3"> 23 {metrics.map(metric => { 24 const current = usage[metric.key] ?? 0 25 const limit = limits[metric.key] 26 const percentage = limit === -1 ? 0 : (current / limit) * 100 27 28 return ( 29 <div key={metric.key} className="rounded-lg border p-4"> 30 <h3 className="text-sm font-medium text-gray-600">{metric.label}</h3> 31 <p className="mt-1 text-2xl font-bold"> 32 {metric.format(current)} 33 {limit !== -1 && ( 34 <span className="text-sm font-normal text-gray-500"> 35 {' / '}{metric.format(limit)} 36 </span> 37 )} 38 {limit === -1 && ( 39 <span className="text-sm font-normal text-gray-500"> 40 {' / Unlimited'} 41 </span> 42 )} 43 </p> 44 45 {limit !== -1 && ( 46 <div className="mt-3"> 47 <div className="h-2 rounded-full bg-gray-200"> 48 <div 49 className={`h-2 rounded-full transition-all ${ 50 percentage > 90 ? 'bg-red-500' : 51 percentage > 75 ? 'bg-amber-500' : 'bg-green-500' 52 }`} 53 style={{ width: `${Math.min(100, percentage)}%` }} 54 /> 55 </div> 56 <p className="mt-1 text-xs text-gray-500"> 57 {percentage.toFixed(1)}% used 58 </p> 59 </div> 60 )} 61 </div> 62 ) 63 })} 64 </div> 65 ) 66}

Usage API Middleware#

Enforce limits on API routes.

1// middleware/usage.ts 2import { NextResponse } from 'next/server' 3import { auth } from '@/auth' 4import { checkUsageLimit, trackApiUsage } from '@/lib/usage' 5 6export async function withUsageLimit( 7 request: Request, 8 metric: string, 9 handler: () => Promise<Response> 10) { 11 const session = await auth() 12 13 if (!session?.user) { 14 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 15 } 16 17 const { allowed, current, limit } = await checkUsageLimit( 18 session.user.id, 19 metric 20 ) 21 22 if (!allowed) { 23 return NextResponse.json( 24 { 25 error: 'Usage limit exceeded', 26 current, 27 limit, 28 upgradeUrl: '/pricing' 29 }, 30 { status: 429 } 31 ) 32 } 33 34 // Track usage 35 await trackApiUsage(session.user.id, request.url) 36 37 return handler() 38} 39 40// Usage in API route 41export async function POST(request: Request) { 42 return withUsageLimit(request, 'api_calls', async () => { 43 // Handle request 44 return NextResponse.json({ success: true }) 45 }) 46}

Best Practices#

  1. Buffer usage records - Batch writes to reduce API calls
  2. Show real-time usage - Keep users informed of consumption
  3. Warn before limits - Send notifications at 80% usage
  4. Graceful degradation - Handle limit exceeded gracefully
  5. Provide upgrade path - Make it easy to increase limits