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 stripeRecord 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#
- Buffer usage records - Batch writes to reduce API calls
- Show real-time usage - Keep users informed of consumption
- Warn before limits - Send notifications at 80% usage
- Graceful degradation - Handle limit exceeded gracefully
- Provide upgrade path - Make it easy to increase limits
Related Patterns#
- Subscriptions - Subscription management
- Webhooks - Invoice webhooks
- Invoicing - Invoice management