{"slug": "openai-and-anthropic-spend-limits-per-user-in-next-js", "title": "OpenAI and Anthropic spend limits per user in Next.js", "summary": "A developer published a guide showing how to enforce per-user AI spend caps in a Next.js App Router app backed by Supabase, combining an Upstash sliding-window rate limit keyed on user id to stop bursts with a per-user monthly dollar cap to stop slow overuse. The author notes that provider-level monthly spend limits are only an app-wide backstop, since neither OpenAI nor Anthropic knows about an app's individual users. The SQL was tested on PostgreSQL 17 with 40 parallel requests and the TypeScript type-checks under strict mode against Next.js 16.4, but the full route was not run against live Supabase, Upstash, OpenAI or Anthropic accounts.", "body_md": "Disclosure: I make the Spend Caps Kit (Stack & Ship, $19 plus VAT where applicable, one-off). This post was first published at [https://edtrackai.github.io/blog/openai-anthropic-spend-limit-per-user-nextjs?utm_source=devto&utm_medium=crosspost&utm_campaign=sprint1&utm_content=spend-limit](https://edtrackai.github.io/blog/openai-anthropic-spend-limit-per-user-nextjs?utm_source=devto&utm_medium=crosspost&utm_campaign=sprint1&utm_content=spend-limit). The approach below works without buying anything.\n\nNext.js · AI costs · 2026-10-17\n\nYou add an AI feature to your Next.js app, and the route works. Then one account, or one script, calls it a few thousand times overnight, and your OpenAI or Anthropic bill shows it the next morning. Most people's first fix is the spend limit in the provider dashboard. That limit helps, but it isn't per user.\n\nThis guide pairs with [Spend Caps Kit](https://edtrackai.github.io/spend-caps-kit?utm_source=devto&utm_medium=crosspost&utm_campaign=sprint1&utm_content=spend-limit), and if you want the next step, read [Prompt caching not working? Check it in OpenAI and Anthropic (Next.js)](https://edtrackai.github.io/blog/prompt-caching-not-working-openai-anthropic-nextjs).\n\nThis post shows how to cap AI spend per user in a Next.js App Router app backed by Supabase. There are two layers: a per-user rate limit, which stops bursts, and a per-user monthly dollar cap, which stops slow, steady overuse. The SQL was run on PostgreSQL 17, including a test with 40 parallel requests. The TypeScript type-checks with `tsc --strict` against Next.js 16.4, openai 7.36, @anthropic-ai/sdk 0.135, @upstash/ratelimit 2.2 and supabase-js 2.118. We haven't run the full route against live Supabase, Upstash, OpenAI or Anthropic accounts. We aren't affiliated with OpenAI, Anthropic, Supabase, Vercel or Upstash.\n\nBoth providers let you set a monthly spend limit. Neither one knows about the users of your app.\n\n`project_spend_limit_exceeded`. The `enforced_spend_limit_reached` and no `retry-after` header.\nSo the provider limit is a backstop for the whole app. If a single user uses up the project's budget, everyone else gets errors until the month resets or you raise the limit. To stop one user before that happens, the check has to run in your own server code, keyed on that user's id.\n\nBefore adding caps, find out which routes call an AI API with nothing in front of them. A route like this is the usual starting point:\n\n``` python\nimport OpenAI from \"openai\";\n\nconst openai = new OpenAI();\n\nexport async function POST(req: Request) {\n  const { message } = await req.json();\n  const r = await openai.responses.create({ model: \"gpt-5-mini\", input: message });\n  return Response.json({ text: r.output_text });\n}\n```\n\nIt has no sign-in check and no limit, and it passes whatever the client sends straight to the model. Anyone who finds the URL can call it in a loop, and you pay for each call.\n\nOur free [unthrottled-route-check](https://github.com/edtrackai/ship-ready-skills/tree/main/skills/unthrottled-route-check) skill looks for this pattern. It's a Claude Code skill with a standalone Node 20+ script that has no dependencies. Run against the route above, the script reports:\n\n``` bash\n$ node scripts/scan-unthrottled.mjs .\nunthrottled-route-check: scanned 1 route file(s) in .\n  [high] U1-unthrottled-ai-route app/api/chat/route.ts:7  AI API call with no rate limiter in this route\n1 finding(s).\n```\n\nIt exits with code 1, so you can run it in CI. It's a text scan, so a clean result means \"nothing found\", not \"protected\". It can miss limiters it doesn't recognise, and it doesn't check whether the limit runs before the AI call.\n\nThe rate limit handles bursts. This uses Upstash's sliding window, keyed on the user id (the setting the free rule suggests):\n\n``` js\n// lib/rate-limit.ts\nimport { Ratelimit } from \"@upstash/ratelimit\";\nimport { Redis } from \"@upstash/redis\";\n\n// 10 AI requests per user per 60 seconds, sliding window.\nexport const aiLimiter = new Ratelimit({\n  redis: Redis.fromEnv(),\n  limiter: Ratelimit.slidingWindow(10, \"60 s\"),\n  prefix: \"rl:ai\",\n});\n```\n\nKey it on the user id, not the IP address. Limiting by IP lets one account spread its requests across several IPs, and it can block a whole office that shares one IP. Over the limit, return 429 with a `Retry-After` header. The route in step 5 works this out from the limiter's `reset` timestamp.\n\nA rate limit alone doesn't cap spend. Ten requests a minute is still more than 400,000 requests a month, and with a large model and long outputs, that adds up.\n\nThe cap needs a counter that two requests can't both read as \"under the limit\" at the same moment. Doing it in one SQL statement means Postgres handles the race:\n\n```\ncreate table public.ai_spend (\n  user_id   uuid not null references auth.users (id) on delete cascade,\n  month     date not null,\n  spent_usd numeric(12,6) not null default 0 check (spent_usd >= 0),\n  primary key (user_id, month)\n);\nalter table public.ai_spend enable row level security;\nrevoke all on public.ai_spend from public, anon, authenticated;\n```\n\nRLS is on, and there are no policies and no grants for `anon` or `authenticated`, so browsers can't read or change the counter. Only your server can, through two functions:\n\n```\n-- Reserve an estimate before the AI call. Returns the month it was\n-- booked to, or null if it would take the user over the cap.\ncreate function public.ai_spend_reserve(\n  p_user uuid, p_cap_usd numeric, p_estimate_usd numeric\n) returns date\nlanguage plpgsql security definer set search_path = ''\nas $$\ndeclare\n  v_month date := date_trunc('month', now() at time zone 'utc')::date;\nbegin\n  if p_user is null or p_cap_usd is null or p_estimate_usd is null\n     or p_estimate_usd = 'NaN' or p_cap_usd = 'NaN'\n     or p_estimate_usd <= 0 or p_cap_usd <= 0 then\n    raise exception 'invalid arguments';\n  end if;\n  insert into public.ai_spend (user_id, month)\n  values (p_user, v_month)\n  on conflict (user_id, month) do nothing;\n  update public.ai_spend\n     set spent_usd = spent_usd + p_estimate_usd\n   where user_id = p_user and month = v_month\n     and spent_usd + p_estimate_usd <= p_cap_usd;\n  if found then return v_month; end if;\n  return null;\nend;\n$$;\n\n-- After the call: swap the estimate for the real cost.\ncreate function public.ai_spend_settle(\n  p_user uuid, p_month date, p_estimate_usd numeric, p_actual_usd numeric\n) returns numeric\nlanguage plpgsql security definer set search_path = ''\nas $$\ndeclare v_total numeric;\nbegin\n  if p_actual_usd is null or p_actual_usd = 'NaN' or p_actual_usd < 0\n     or p_estimate_usd is null or p_estimate_usd = 'NaN' then\n    raise exception 'invalid arguments';\n  end if;\n  update public.ai_spend\n     set spent_usd = greatest(spent_usd - p_estimate_usd + p_actual_usd, 0)\n   where user_id = p_user and month = p_month\n  returning spent_usd into v_total;\n  return v_total;\nend;\n$$;\n\nrevoke all on function public.ai_spend_reserve(uuid, numeric, numeric)\n  from public, anon, authenticated;\nrevoke all on function public.ai_spend_settle(uuid, date, numeric, numeric)\n  from public, anon, authenticated;\ngrant execute on function public.ai_spend_reserve(uuid, numeric, numeric)\n  to service_role;\ngrant execute on function public.ai_spend_settle(uuid, date, numeric, numeric)\n  to service_role;\n```\n\nHow it works:\n\n`ai_spend_reserve` books a worst-case estimate before the AI call. The `update` adds the estimate only `where` the new total stays at or under the cap, in a single statement. When two requests for the same user arrive together, the second waits for the first's row lock and then checks the condition again against the new total. If the estimate would take the user over the cap, nothing is booked and the function returns null.`ai_spend_settle` runs after the call and swaps the estimate for the real cost.`security definer` with an empty `search_path`, and only `service_role` can execute them. Supabase's `anon`, which is why the `revoke` lines matter.\nWe tested this on PostgreSQL 17 with Supabase-style `anon`, `authenticated` and `service_role` roles. With a $1.00 cap, two $0.40 reservations were booked and a third was refused. After one was settled at $0.05, a new $0.40 reservation fit again. NaN, zero and negative amounts were rejected. `anon` and `authenticated` got \"permission denied\" on both the functions and the table. Then we fired 40 parallel $0.10 reservations at a fresh user with a $1.00 cap: exactly 10 were booked, 30 were refused, and the total ended at $1.000000.\n\nBoth APIs return token counts with each response, but they count cached tokens differently, so it's easy to miss one:\n\n`input_tokens` already includes cached tokens and cache writes. The `cached_tokens` and `cache_write_tokens` to get the ordinary input.`cache_read_input_tokens` and `cache_creation_input_tokens`, and the \n\n```\n// USD per 1M tokens. Copied from the OpenAI and Anthropic pricing pages\n// on 17 Oct 2026 (standard tier). Check them before you rely on these.\ntype Price = { input: number; cachedInput: number; cacheWrite: number; output: number };\n\nconst PRICES: Record<string, Price> = {\n  \"gpt-5-mini\": { input: 0.25, cachedInput: 0.025, cacheWrite: 0.25, output: 2 },\n  \"claude-haiku-4-5\": { input: 1, cachedInput: 0.1, cacheWrite: 1.25, output: 5 },\n};\n\nfunction priceFor(model: string): Price {\n  const p = PRICES[model];\n  // Refuse unknown models instead of counting them as free.\n  if (!p) throw new Error(`No price for model ${model}`);\n  return p;\n}\n\nconst perM = (tokens: number, usdPerM: number) => (tokens * usdPerM) / 1_000_000;\n\n// OpenAI Responses API: input_tokens includes cached and cache-write tokens.\nexport function openaiCost(\n  model: string,\n  u: { input_tokens: number; output_tokens: number;\n       input_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number } },\n): number {\n  const p = priceFor(model);\n  const cached = u.input_tokens_details?.cached_tokens ?? 0;\n  const written = u.input_tokens_details?.cache_write_tokens ?? 0;\n  const plain = Math.max(u.input_tokens - cached - written, 0);\n  return perM(plain, p.input) + perM(cached, p.cachedInput)\n       + perM(written, p.cacheWrite) + perM(u.output_tokens, p.output);\n}\n\n// Anthropic Messages API: input_tokens excludes cache reads and writes.\nexport function anthropicCost(\n  model: string,\n  u: { input_tokens: number; output_tokens: number;\n       cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null },\n): number {\n  const p = priceFor(model);\n  return perM(u.input_tokens, p.input)\n       + perM(u.cache_read_input_tokens ?? 0, p.cachedInput)\n       + perM(u.cache_creation_input_tokens ?? 0, p.cacheWrite)\n       + perM(u.output_tokens, p.output);\n}\n\n// Worst case before the call: one token per input character (deliberately\n// high) and the full output budget. Settling swaps in the real cost.\nexport function estimateCost(model: string, inputChars: number, maxOutputTokens: number): number {\n  const p = priceFor(model);\n  return perM(inputChars, p.input) + perM(maxOutputTokens, p.output);\n}\n```\n\nThe prices are per million tokens, copied from the [OpenAI](https://developers.openai.com/api/docs/pricing) and [Anthropic](https://platform.claude.com/docs/en/about-claude/pricing) pricing pages on 17 October 2026 (standard tier). Prices change, so check them before you rely on these. An unknown model throws an error instead of being counted as $0, so a model swap can't quietly skip the cap. Six unit tests cover the maths, including cached tokens for both providers.\n\nHere is the route from step 1 with all three layers in place: who the user is, how often they can call, and how much they can spend.\n\n``` python\nimport OpenAI from \"openai\";\nimport { createClient as createAdminClient } from \"@supabase/supabase-js\";\nimport { createClient } from \"@/lib/supabase/server\";\nimport { aiLimiter } from \"@/lib/rate-limit\";\nimport { estimateCost, openaiCost } from \"@/lib/ai-cost\";\n\nconst openai = new OpenAI(); // reads OPENAI_API_KEY on the server\n// Service-role client: server only, never in a NEXT_PUBLIC_ variable.\nconst admin = createAdminClient(\n  process.env.NEXT_PUBLIC_SUPABASE_URL!,\n  process.env.SUPABASE_SERVICE_ROLE_KEY!,\n  { auth: { persistSession: false } },\n);\n\nconst MODEL = \"gpt-5-mini\";\nconst MONTHLY_CAP_USD = 2;\nconst MAX_OUTPUT_TOKENS = 1_000;\n\nexport async function POST(req: Request) {\n  // 1. Who is this? getClaims() verifies the JWT.\n  const supabase = await createClient();\n  const { data: auth } = await supabase.auth.getClaims();\n  const userId = auth?.claims.sub;\n  if (!userId) return Response.json({ error: \"Sign in first\" }, { status: 401 });\n\n  // 2. Rate limit per user id, not per IP.\n  const rl = await aiLimiter.limit(userId);\n  if (!rl.success) {\n    const retryAfter = Math.max(1, Math.ceil((rl.reset - Date.now()) / 1000));\n    return Response.json(\n      { error: \"Too many requests\" },\n      { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n    );\n  }\n\n  const body = (await req.json().catch(() => null)) as { message?: unknown } | null;\n  const message = typeof body?.message === \"string\" ? body.message.slice(0, 4_000) : \"\";\n  if (!message) return Response.json({ error: \"message is required\" }, { status: 400 });\n\n  // 3. Reserve the worst-case cost against this month's cap.\n  const estimate = estimateCost(MODEL, message.length, MAX_OUTPUT_TOKENS);\n  const { data: month, error } = await admin.rpc(\"ai_spend_reserve\", {\n    p_user: userId, p_cap_usd: MONTHLY_CAP_USD, p_estimate_usd: estimate,\n  });\n  if (error) return Response.json({ error: \"Try again later\" }, { status: 503 });\n  if (!month) return Response.json({ error: \"Monthly AI limit reached\" }, { status: 402 });\n\n  // 4. Call the model, then swap the estimate for the real cost.\n  let actual = estimate; // if the call fails, keep the estimate (errs high)\n  try {\n    const r = await openai.responses.create({\n      model: MODEL, input: message, max_output_tokens: MAX_OUTPUT_TOKENS,\n    });\n    if (r.usage) actual = openaiCost(MODEL, r.usage);\n    return Response.json({ text: r.output_text });\n  } catch {\n    return Response.json({ error: \"AI call failed\" }, { status: 502 });\n  } finally {\n    await admin.rpc(\"ai_spend_settle\", {\n      p_user: userId, p_month: month, p_estimate_usd: estimate, p_actual_usd: actual,\n    });\n  }\n}\n```\n\nThe Supabase server client comes from `@supabase/ssr`, set up as in Supabase's [Next.js server-side auth guide](https://supabase.com/docs/guides/auth/server-side/nextjs). For why the route uses `getClaims()` and not `getSession()`, see [Supabase getSession vs getUser vs getClaims](https://edtrackai.github.io/blog/supabase-getsession-vs-getuser-vs-getclaims).\n\nA few choices to adjust for your app:\n\n`max_output_tokens`, which includes reasoning tokens. A high estimate briefly reserves more than the call will cost, but it keeps parallel requests from overshooting the cap.`p_cap_usd` value.\nThese limits help reduce the risk of a surprise bill, but they don't remove it. Things to know:\n\n`stream: true`, the final usage arrives at the end of the stream, and a stream that's cut off may never report it. Settle when the stream finishes, and keep the estimate if it doesn't.`limit()` returns success with `reason: \"timeout\"`, so the rate limit fails open, though the dollar cap still applies. Check `rl.reason` if you want it to fail closed.`Retry-After`.\n*Disclosure: this is our own paid product.* [Spend Caps Kit](https://edtrackai.github.io/spend-caps-kit?utm_source=devto&utm_medium=crosspost&utm_campaign=sprint1&utm_content=spend-limit) ($19 plus VAT where applicable) is copy-paste TypeScript and one Supabase migration for Next.js. It includes per-user rate-limit presets on Upstash or a Postgres counter, a monthly dollar cap per user with a price table for 17 OpenAI and Anthropic models, handling for streams that are cut off, a Stripe checkout refresh-loop guard with webhook dedupe, and a provider-side backstop checklist. It reduces risk but doesn't replace provider-side limits.\n\n**Sources (checked 17 October 2026):**\n\n20 checks for AI-built Next.js apps: RLS, service keys, webhook signatures. PDF, free.\n\nFree download by email. No marketing emails unless you [follow us on Gumroad](https://luxurydrive0.gumroad.com/subscribe?utm_source=site&utm_medium=web&utm_campaign=list&utm_content=blog-end).\n\nSpend Caps Kit · $19 plus VAT where applicable\n\nPer-user rate limits and AI spend caps for Next.js + Supabase + Stripe", "url": "https://wpnews.pro/news/openai-and-anthropic-spend-limits-per-user-in-next-js", "canonical_source": "https://dev.to/edtrackai/openai-and-anthropic-spend-limits-per-user-in-nextjs-4jop", "published_at": "2026-10-24 04:07:15+00:00", "updated_at": "2026-10-24 04:11:24.842184+00:00", "lang": "en", "topics": ["ai-tools", "ai-infrastructure", "developer-tools", "ai-products"], "entities": ["OpenAI", "Anthropic", "Next.js", "Supabase", "Upstash", "Vercel", "Spend Caps Kit"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/openai-and-anthropic-spend-limits-per-user-in-next-js", "markdown": "https://wpnews.pro/news/openai-and-anthropic-spend-limits-per-user-in-next-js.md", "text": "https://wpnews.pro/news/openai-and-anthropic-spend-limits-per-user-in-next-js.txt", "jsonld": "https://wpnews.pro/news/openai-and-anthropic-spend-limits-per-user-in-next-js.jsonld"}}