OpenAI and Anthropic spend limits per user in Next.js 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. 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. Next.js · AI costs · 2026-10-17 You 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. This 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 . This 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. Both providers let you set a monthly spend limit. Neither one knows about the users of your app. project spend limit exceeded . The enforced spend limit reached and no retry-after header. So 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. Before 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: python import OpenAI from "openai"; const openai = new OpenAI ; export async function POST req: Request { const { message } = await req.json ; const r = await openai.responses.create { model: "gpt-5-mini", input: message } ; return Response.json { text: r.output text } ; } It 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. Our 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: bash $ node scripts/scan-unthrottled.mjs . unthrottled-route-check: scanned 1 route file s in . high U1-unthrottled-ai-route app/api/chat/route.ts:7 AI API call with no rate limiter in this route 1 finding s . It 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. The rate limit handles bursts. This uses Upstash's sliding window, keyed on the user id the setting the free rule suggests : js // lib/rate-limit.ts import { Ratelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; // 10 AI requests per user per 60 seconds, sliding window. export const aiLimiter = new Ratelimit { redis: Redis.fromEnv , limiter: Ratelimit.slidingWindow 10, "60 s" , prefix: "rl:ai", } ; Key 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. A 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. The 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: create table public.ai spend user id uuid not null references auth.users id on delete cascade, month date not null, spent usd numeric 12,6 not null default 0 check spent usd = 0 , primary key user id, month ; alter table public.ai spend enable row level security; revoke all on public.ai spend from public, anon, authenticated; RLS 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: -- Reserve an estimate before the AI call. Returns the month it was -- booked to, or null if it would take the user over the cap. create function public.ai spend reserve p user uuid, p cap usd numeric, p estimate usd numeric returns date language plpgsql security definer set search path = '' as $$ declare v month date := date trunc 'month', now at time zone 'utc' ::date; begin if p user is null or p cap usd is null or p estimate usd is null or p estimate usd = 'NaN' or p cap usd = 'NaN' or p estimate usd <= 0 or p cap usd <= 0 then raise exception 'invalid arguments'; end if; insert into public.ai spend user id, month values p user, v month on conflict user id, month do nothing; update public.ai spend set spent usd = spent usd + p estimate usd where user id = p user and month = v month and spent usd + p estimate usd <= p cap usd; if found then return v month; end if; return null; end; $$; -- After the call: swap the estimate for the real cost. create function public.ai spend settle p user uuid, p month date, p estimate usd numeric, p actual usd numeric returns numeric language plpgsql security definer set search path = '' as $$ declare v total numeric; begin if p actual usd is null or p actual usd = 'NaN' or p actual usd < 0 or p estimate usd is null or p estimate usd = 'NaN' then raise exception 'invalid arguments'; end if; update public.ai spend set spent usd = greatest spent usd - p estimate usd + p actual usd, 0 where user id = p user and month = p month returning spent usd into v total; return v total; end; $$; revoke all on function public.ai spend reserve uuid, numeric, numeric from public, anon, authenticated; revoke all on function public.ai spend settle uuid, date, numeric, numeric from public, anon, authenticated; grant execute on function public.ai spend reserve uuid, numeric, numeric to service role; grant execute on function public.ai spend settle uuid, date, numeric, numeric to service role; How it works: 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. We 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. Both APIs return token counts with each response, but they count cached tokens differently, so it's easy to miss one: 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 // USD per 1M tokens. Copied from the OpenAI and Anthropic pricing pages // on 17 Oct 2026 standard tier . Check them before you rely on these. type Price = { input: number; cachedInput: number; cacheWrite: number; output: number }; const PRICES: Record