Thursday, January 8th 2026 ยท 2 min read
How to Implement Metered Billing with Stripe in Next.js
Learn how to implement usage-based metered billing with Stripe in your Next.js SaaS. Covers metered subscriptions, usage reporting, and real-time tracking.
Metered billing (also called usage-based billing) charges customers based on how much they use your service rather than a flat subscription fee. It's perfect for APIs, AI services, cloud storage, or any product where usage varies significantly between customers.
In this guide, we'll implement metered billing with Stripe in a Next.js application.
What is Metered Billing?
Unlike traditional subscriptions where customers pay a fixed monthly fee, metered billing charges based on actual usage:
| Billing Model | Example | Best For |
|---|---|---|
| Flat Rate | $29/month | Predictable services |
| Tiered | $29 for 1000 units, $49 for 5000 | Growing usage |
| Metered | $0.01 per API call | Variable usage |
| Hybrid | $29/month + $0.001 per call | Base + overages |
Setting Up Stripe for Metered Billing
Step 1: Create a Metered Price in Stripe
First, create a product with a metered price in Stripe Dashboard or via API:
// scripts/create-metered-price.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function createMeteredProduct() {
// Create the product
const product = await stripe.products.create({
name: 'API Usage',
description: 'Pay-per-use API access'
});
// Create a metered price
const price = await stripe.prices.create({
product: product.id,
currency: 'usd',
recurring: {
interval: 'month',
usage_type: 'metered', // Key setting!
aggregate_usage: 'sum' // Sum all usage in billing period
},
billing_scheme: 'per_unit',
unit_amount: 1 // $0.01 per unit (in cents)
// Or use tiered pricing:
// billing_scheme: 'tiered',
// tiers_mode: 'graduated',
// tiers: [
// { up_to: 1000, unit_amount: 0 }, // First 1000 free
// { up_to: 10000, unit_amount: 1 }, // $0.01 each
// { up_to: 'inf', unit_amount: 0.5 } // $0.005 each after
// ]
});
console.log('Product ID:', product.id);
console.log('Price ID:', price.id);
}
createMeteredProduct();Step 2: Subscribe Customer to Metered Plan
When a customer subscribes, create a subscription with the metered price:
// actions/billing.ts
'use server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
export async function createMeteredSubscription() {
const session = await auth();
if (!session?.user?.id) {
throw new Error('Unauthorized');
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true }
});
if (!user?.stripeCustomerId) {
throw new Error('No Stripe customer');
}
// Create subscription with metered price
const subscription = await stripe.subscriptions.create({
customer: user.stripeCustomerId,
items: [
{
price: process.env.STRIPE_METERED_PRICE_ID!
// No quantity needed for metered prices
}
]
// Optionally add a base fee
// add_invoice_items: [{
// price: 'price_base_monthly_fee'
// }]
});
// Store subscription info
await db.user.update({
where: { id: session.user.id },
data: {
stripeSubscriptionId: subscription.id,
// Store the subscription item ID - needed for usage reporting
stripeSubscriptionItemId: subscription.items.data[0].id
}
});
return { subscriptionId: subscription.id };
}Reporting Usage to Stripe
The key to metered billing is reporting usage to Stripe. You have two approaches:
Approach 1: Report Usage in Real-Time
Report each API call as it happens:
// lib/usage.ts
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
export async function reportUsage(
userId: string,
quantity: number,
action: string
) {
const user = await db.user.findUnique({
where: { id: userId },
select: { stripeSubscriptionItemId: true }
});
if (!user?.stripeSubscriptionItemId) {
throw new Error('No active subscription');
}
// Report usage to Stripe
const usageRecord = await stripe.subscriptionItems.createUsageRecord(
user.stripeSubscriptionItemId,
{
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment' // Add to existing usage
}
);
// Also log locally for your own analytics
await db.usageLog.create({
data: {
userId,
quantity,
action,
stripeUsageRecordId: usageRecord.id
}
});
return usageRecord;
}Use it in your API routes:
// app/api/ai/generate/route.ts
import { NextResponse } from 'next/server';
import { auth } from '@/auth';
import { reportUsage } from '@/lib/usage';
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
// Process the AI generation
const result = await generateWithAI(request);
// Report 1 unit of usage
await reportUsage(session.user.id, 1, 'ai_generation');
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({ error: 'Failed' }, { status: 500 });
}
}Approach 2: Batch Usage Reporting
For high-volume APIs, batch usage reports:
// lib/usage-batch.ts
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
// Track usage in memory or Redis
const usageBuffer = new Map<string, number>();
export function trackUsage(subscriptionItemId: string, quantity: number) {
const current = usageBuffer.get(subscriptionItemId) || 0;
usageBuffer.set(subscriptionItemId, current + quantity);
}
// Flush to Stripe periodically (e.g., every minute via cron)
export async function flushUsageToStripe() {
const entries = Array.from(usageBuffer.entries());
usageBuffer.clear();
const results = await Promise.allSettled(
entries.map(([subscriptionItemId, quantity]) =>
stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment'
})
)
);
// Log any failures
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(
`Failed to report usage for ${entries[index][0]}:`,
result.reason
);
// Re-add to buffer for retry
const [id, qty] = entries[index];
trackUsage(id, qty);
}
});
}Set up a cron job to flush usage:
// app/api/cron/flush-usage/route.ts
import { NextResponse } from 'next/server';
import { flushUsageToStripe } from '@/lib/usage-batch';
export async function GET(request: Request) {
// Verify cron secret
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await flushUsageToStripe();
return NextResponse.json({ success: true });
}Displaying Usage to Customers
Show customers their current usage:
// lib/usage.ts
export async function getCurrentUsage(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: { stripeSubscriptionItemId: true, stripeSubscriptionId: true }
});
if (!user?.stripeSubscriptionId) {
return null;
}
// Get current billing period usage from Stripe
const subscription = await stripe.subscriptions.retrieve(
user.stripeSubscriptionId
);
const usageSummary = await stripe.subscriptionItems.listUsageRecordSummaries(
user.stripeSubscriptionItemId!,
{
limit: 1
}
);
const currentPeriodUsage = usageSummary.data[0]?.total_usage || 0;
return {
usage: currentPeriodUsage,
periodStart: new Date(subscription.current_period_start * 1000),
periodEnd: new Date(subscription.current_period_end * 1000),
estimatedCost: currentPeriodUsage * 0.01 // $0.01 per unit
};
}Create a usage dashboard component:
// components/usage-dashboard.tsx
import { getCurrentUsage } from '@/lib/usage';
import { formatCurrency, formatDate } from '@/lib/utils';
export async function UsageDashboard({ userId }: { userId: string }) {
const usage = await getCurrentUsage(userId);
if (!usage) {
return <p>No active subscription</p>;
}
return (
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Current Usage</p>
<p className="text-3xl font-bold">{usage.usage.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">API calls</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Estimated Cost</p>
<p className="text-3xl font-bold">
{formatCurrency(usage.estimatedCost)}
</p>
<p className="text-sm text-muted-foreground">this period</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Billing Period</p>
<p className="text-sm">
{formatDate(usage.periodStart)} - {formatDate(usage.periodEnd)}
</p>
</div>
</div>
);
}Setting Usage Limits
Prevent surprise bills by implementing usage limits:
// lib/usage-limits.ts
import { db } from '@/lib/db';
const USAGE_LIMITS = {
free: 100,
starter: 10000,
pro: 100000,
enterprise: Infinity
};
export async function checkUsageLimit(userId: string): Promise<{
allowed: boolean;
current: number;
limit: number;
remaining: number;
}> {
const user = await db.user.findUnique({
where: { id: userId },
select: { plan: true, stripeSubscriptionItemId: true }
});
const limit = USAGE_LIMITS[user?.plan || 'free'];
const usage = await getCurrentUsage(userId);
const current = usage?.usage || 0;
return {
allowed: current < limit,
current,
limit,
remaining: Math.max(0, limit - current)
};
}
// Use in API routes
export async function POST(request: Request) {
const session = await auth();
const { allowed, remaining } = await checkUsageLimit(session.user.id);
if (!allowed) {
return NextResponse.json(
{ error: 'Usage limit exceeded', remaining },
{ status: 429 }
);
}
// Process request...
}Handling Webhooks for Metered Billing
Handle invoice events for metered subscriptions:
// app/api/webhooks/stripe/route.ts
case 'invoice.created': {
const invoice = event.data.object as Stripe.Invoice;
// For metered billing, the invoice is created at period end
// with calculated usage charges
console.log('Invoice created:', invoice.id);
console.log('Total:', invoice.total);
break;
}
case 'invoice.finalized': {
const invoice = event.data.object as Stripe.Invoice;
// Send usage summary email to customer
await sendUsageSummaryEmail(invoice);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
// Handle failed payment (maybe pause API access)
await handleFailedPayment(invoice);
break;
}Best Practices
1. Always Set Usage Alerts
Let customers know when they're approaching limits:
// Check usage and send alerts
export async function checkUsageAlerts(userId: string) {
const { current, limit } = await checkUsageLimit(userId);
const percentage = (current / limit) * 100;
if (percentage >= 90 && !(await hasAlertBeenSent(userId, 90))) {
await sendUsageAlert(userId, '90% of usage limit reached');
} else if (percentage >= 75 && !(await hasAlertBeenSent(userId, 75))) {
await sendUsageAlert(userId, '75% of usage limit reached');
}
}2. Provide Usage Estimates
Help customers predict costs:
export function estimateMonthlyCost(dailyUsage: number, pricePerUnit: number) {
const estimatedMonthlyUsage = dailyUsage * 30;
return estimatedMonthlyUsage * pricePerUnit;
}3. Offer Committed Use Discounts
Reward customers who commit to minimum usage:
const VOLUME_DISCOUNTS = [
{ threshold: 100000, discount: 0.1 }, // 10% off above 100k
{ threshold: 500000, discount: 0.2 }, // 20% off above 500k
{ threshold: 1000000, discount: 0.3 } // 30% off above 1M
];Conclusion
Metered billing with Stripe involves:
- Creating metered prices with
usage_type: 'metered' - Reporting usage via
createUsageRecord - Displaying usage to customers in real-time
- Setting limits to prevent surprise bills
- Handling webhooks for invoice events
This model works great for APIs, AI services, and any product where usage varies significantly between customers.
Want metered billing without building it yourself? Achromatic includes pre-built metered billing with usage tracking, limits, and customer dashboards out of the box.
Related Articles
Billing Overhaul
The billing system got completely overhauled, now supporting diverse plans, including lifetime and metered options, with flexible pricing models.
New Achromatic Starter Kits Are Here
Next.js 16, React 19, Better Auth, tRPC with Prisma or Drizzle ORM. Ship your SaaS faster than ever.
React DoS & Source Code Exposure - Starter Kits Updated
Two new React Server Components vulnerabilities discovered. All Achromatic starter kits updated to patched versions.