How to meter Claude API costs per user in a Next.js SaaS (with code)
Originally published on the Agentwright blog . If you charge users for an AI feature, you need to know what each of them costs you. Not in aggregate, on the Anthropic invoice at the end of the month, but per user, per request, in real time, so you can cap free accounts, show a usage page, and find

Posted on Nov 3 • Originally published at agentwright.app
Originally published on the Agentwright blog.
If you charge users for an AI feature, you need to know what each of them costs you. Not in aggregate, on the Anthropic invoice at the end of the month, but per user, per request, in real time, so you can cap free accounts, show a usage page, and find out which 2% of users are 60% of your bill.
This post walks through the metering layer we use in Agentwright Starter: a Next.js 15 app with Drizzle + Postgres and the official @anthropic-ai/sdk. Everything below is plain TypeScript you can drop into an existing project. The design is small on purpose: one table, one pure pricing module, two functions.
Every response from the Messages API carries a usage object. For a streaming request, the SDK's finalMessage() returns the assembled message with the same fields:
const stream = client. messages. stream ({ model, max_tokens, system, tools, messages }); const final = await stream. finalMessage (); final. usage. input_tokens; // uncached input, billed at the base input price final. usage. output_tokens; // billed at the output price final. usage. cache_read_input_tokens; // served from the prompt cache (cheap) final. usage. cache_creation_input_tokens; // written to the prompt cache (slightly more than base) Enter fullscreen mode Exit fullscreen mode Two things trip people up here.
First, the four numbers are billed at four different rates. If you only store input_tokens + output_tokens, your cost estimate will be wrong as soon as you enable prompt caching, which you should, because a cached system prompt plus tool definitions is the single biggest saving in an agent loop.
Second, an agent turn is not one request. A loop that calls a tool three times makes four API requests, and each one has its own usage. Meter per request, not per user message.
These are the Claude API list prices from the official pricing page, in USD per million tokens. Verify them yourself before copying; pricing pages change, and this table will not update itself.
Keep prices in a module with no database import, so your eval runner, a cost calculator in the browser, and the server can all use the same numbers. Store cost as an integer number of micro-dollars (1e-6 USD). Floating-point dollars in a SUM() look fine for a week and then drift.
// lib/billing/pricing.ts export type ModelPrice = { in: number; out: number; cacheRead: number; cacheWrite: number }; /** USD per million tokens. Source: platform.claude.com/docs/en/about-claude/pricing */ export const PRICE_PER_MTOK: Record < string, ModelPrice > = { " claude-opus-5-5 ": { in: 4, out: 20, cacheRead: 0.2, cacheWrite: 5 }, " claude-sonnet-5-5 ": { in: 2, out: 10, cacheRead: 0.1, cacheWrite: 2.5 }, // Prompts over 100K tokens are billed at the higher Haiku tier (see haikuPrice below). " claude-haiku-5-5 ": { in: 0.1, out: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, }; const HAIKU_LONG: ModelPrice = { in: 0.5, out: 2.5, cacheRead: 0.05, cacheWrite: 0.625 }; export type Usage = { input: number; output: number; cacheRead?: number; cacheWrite?: number }; function priceFor ( model: string, u: Usage ): ModelPrice { const p = PRICE_PER_MTOK [ model ]; if (! p ) throw new Error ( `Unknown model pricing: ${ model } ` ); if ( model === " claude-haiku-5-5 " ) { const promptTokens = u. input + ( u. cacheRead??
0 ) + ( u. cacheWrite?? 0 ); if ( promptTokens > 100 _000 ) return HAIKU_LONG; } return p; } /** Cost in micro-dollars (1e-6 USD) so it can be stored as a bigint. */ export function costMicroUsd ( model: string, u: Usage ): number { const p = priceFor ( model, u ); const usd = ( u. input * p. in + u. output * p. out + ( u. cacheRead?? 0 ) * p. cacheRead + ( u. cacheWrite??
0 ) * p. cacheWrite ) / 1 e6; return Math. round ( usd * 1 e6 ); } export function formatUsd ( microUsd: number, digits = 4 ): string { return `$ ${( microUsd / 1 e6 ). toFixed ( digits )} `; } Enter fullscreen mode Exit fullscreen mode A quick sanity check: one Sonnet 5.5 request with 3,000 uncached input tokens, 8,000 cache-read tokens and 600 output tokens costs 3,000 × $2 + 8,000 × $0.10 + 600 × $10 = $6,000 + $800 + $6,000 per million, which is $0.0128. With costMicroUsd that is 12800. Without caching the same prompt would have been 11,000 × $2 + 600 × $10 = $0.028, so the cache cut this request's cost by more than half.
That is why you must meter cache reads separately: if you bill them as full input you will overcharge users and under-sell your own margins.
Throwing on an unknown model is deliberate. A silent?? 0 means a new model ships, someone sets AGENT_MODEL, and every request is recorded as free.
// lib/db/schema.ts (excerpt) export const usageEvents = pgTable ( " usage_events ", { id: uuid ( " id " ). primaryKey (). defaultRandom (), userId: uuid ( " user_id " ). notNull (). references (() => users. id, { onDelete: " cascade " }), model: text ( " model " ). notNull (), inputTokens: integer ( " input_tokens " ). notNull (), outputTokens: integer ( " output_tokens " ). notNull (), cacheReadTokens: integer ( " cache_read_tokens " ). notNull (). default ( 0 ), cacheWriteTokens: integer ( " cache_write_tokens " ). notNull (). default ( 0 ), costMicroUsd: bigint ( " cost_micro_usd ", { mode: " number " }). notNull (), createdAt: timestamp ( " created_at " ). defaultNow (). notNull (), }, ( t ) => [ index ( " usage_events_user_created_idx " ). on ( t. userId, t. createdAt )], ); export const plans = pgTable ( " plans ", { id: text ( " id " ). primaryKey (), // free | pro | team monthlyTokenCap: bigint ( " monthly_token_cap ", { mode: " number " }). notNull (), overageAllowed: integer ( " overage_allowed " ). notNull (). default ( 0 ), }); Enter fullscreen mode Exit fullscreen mode One row per API request. Store the raw token counts and the computed cost. The raw counts let you re-price history if you discover a bug or Anthropic changes a price; the stored cost makes the usage page a single SUM().
The composite index on (user_id, created_at) is the only one you need. Every query below is "this user, this month".
// lib/billing/metering.ts import { db } from " @/lib/db "; import { usageEvents, users, plans } from " @/lib/db/schema "; import { and, eq, gte, sql } from " drizzle-orm "; import { costMicroUsd, type Usage } from "./pricing "; export async function recordUsage ( userId: string, model: string, u: Usage ) { await db. insert ( usageEvents ). values ({ userId, model, inputTokens: u. input, outputTokens: u. output, cacheReadTokens: u. cacheRead?? 0, cacheWriteTokens: u. cacheWrite?? 0, costMicroUsd: costMicroUsd ( model, u ), }); } export async function assertWithinCap ( userId: string ): Promise < void > { const monthStart = new Date (); monthStart. setUTCDate ( 1 ); monthStart. setUTCHours ( 0, 0, 0, 0 ); const [ user ] = await db. select (). from ( users ). where ( eq ( users. id, userId )); const [ plan ] = await db. select (). from ( plans ). where ( eq ( plans. id, user. plan )); const [{ used }] = await db. select ({ used: sql < number > `coalesce(sum( ${ usageEvents. inputTokens } + ${ usageEvents. outputTokens } ), 0)`, }). from ( usageEvents ). where ( and ( eq ( usageEvents. userId, userId ), gte ( usageEvents. createdAt, monthStart ))); if ( Number ( used ) >= plan. monthlyTokenCap &&! plan. overageAllowed ) { const err = new Error ( " Monthly token cap reached.
Upgrade your plan to continue. " ); ( err as any ). code = " CAP_REACHED "; throw err; } } Enter fullscreen mode Exit fullscreen mode The cap here is in tokens, not dollars, because that is what users can reason about on a pricing page ("2 million tokens a month"). If you prefer dollar caps, sum cost_micro_usd instead; the table supports both.
One honest limitation: this check reads before the request and writes after it, so a user can exceed the cap by one request. For a chat product that is fine. If you need a hard ceiling, reserve an estimate before the call (count input tokens with client.messages.countTokens and assume max_tokens output), then reconcile after.
Metering goes around each request in the loop, not around the whole conversation:
// lib/agent/loop.ts (excerpt) for ( let turn = 0; turn < MAX_TURNS; turn ++ ) { if ( opts. userId ) await assertWithinCap ( opts. userId ); const stream = client. messages. stream ({ model, max_tokens: opts. maxTokens?? 8192, system: [{ type: " text ", text: opts. system, cache_control: { type: " ephemeral " } }], tools, messages, }); stream. on ( " text ", ( t ) => opts. onText?.( t )); const final = await stream. finalMessage (); if ( opts. userId ) { await recordUsage ( opts. userId, model, { input: final. usage. input_tokens, output: final. usage. output_tokens, cacheRead: final. usage. cache_read_input_tokens?? 0, cacheWrite: final. usage. cache_creation_input_tokens??
0, }); } messages. push ({ role: " assistant ", content: final. content }); if ( final. stop_reason!== " tool_use " ) return messages; //...execute tool calls, push results, continue } Enter fullscreen mode Exit fullscreen mode Making userId optional is a small thing that pays off: your eval runner and smoke tests can call the same loop without a database, and production always passes a user.
In the API route, catch CAP_REACHED and return something the client can act on. We stream server-sent events, so the route emits { event: "error", data: { code: "CAP_REACHED", message } } and the chat UI shows an upgrade link instead of a generic failure.
With cost already in the table, the per-user page is three aggregate queries. The one users look at most is cost by model this month:
const byModel = await db. select ({ model: usageEvents. model, requests: sql < number > `count(*)`, tokens: sql < number > `coalesce(sum( ${ usageEvents. inputTokens } + ${ usageEvents. outputTokens } ), 0)`, costMicroUsd: sql < number > `coalesce(sum( ${ usageEvents. costMicroUsd } ), 0)`, }). from ( usageEvents ). where ( and ( eq ( usageEvents. userId, userId ), gte ( usageEvents. createdAt, monthStart ))). groupBy ( usageEvents. model ). orderBy ( desc ( sql `sum( ${ usageEvents. costMicroUsd } )` )); Enter fullscreen mode Exit fullscreen mode Postgres returns sum() as a string for bigint columns, so wrap results in Number() before sending them to the client. Add a daily series for the last 30 days ( date_trunc('day', created_at) ) and the 20 most recent requests, and you have everything a user needs to understand their bill. The same queries, without the userId filter, are your admin view.
Rate limiting (requests per minute per plan) is a separate concern and lives in its own table. Neither does this post cover overage billing: overageAllowed lets a plan continue past the cap, and a monthly job can sum cost_micro_usd and create a usage-based charge with your payment provider. Both are in the starter.
Agentwright Starter is a Next.js 15 starter kit for Claude-powered SaaS: GitHub and magic-link auth, the streaming agent loop above with tool calls and MCP, per-user metering and plan caps, Lemon Squeezy billing, an eval runner and a usage dashboard, already wired together. It launches in December 2026 at $99. Details and a launch-list signup are at agentwright.app.