Next.js · AI costs ·

OpenAI and Anthropic spend limits per user in Next.js

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, and if you want the next step, read Prompt caching not working? Check it in OpenAI and Anthropic (Next.js).

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.

What the provider limits do, and what they don't

Both providers let you set a monthly spend limit. Neither one knows about the users of your app.

  • OpenAI lets you set a hard monthly spend limit for the whole organization or for a single project. When the limit is reached, requests fail with a 429 and a code such as project_spend_limit_exceeded. The spend limits guide also says: "Enforcement is not instantaneous, so recorded spend can slightly exceed the configured amount."
  • Anthropic applies a monthly spend cap to each usage tier, and lets you set a lower limit for the organization or for a workspace. According to the rate limits page, when the tier cap is reached, "API usage pauses until 00:00 UTC on the first day of the next month". Those 429 responses carry 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.

Step 1: find routes with no limit at all

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:

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 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:

$ 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.

Step 2: a per-user rate limit

The rate limit handles bursts. This uses Upstash's sliding window, keyed on the user id (the setting the free rule suggests):

// 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.

Step 3: a monthly dollar cap in Supabase Postgres

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.
  • Each month gets its own row, keyed on the first day of the month in UTC, so you don't need a reset job.
  • Both functions are security definer with an empty search_path, and only service_role can execute them. Supabase's database functions guide warns that any role can call a function by default, including 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.

Step 4: work out the real cost from usage

Both APIs return token counts with each response, but they count cached tokens differently, so it's easy to miss one:

  • OpenAI (Responses API): input_tokens already includes cached tokens and cache writes. The prompt caching guide subtracts cached_tokens and cache_write_tokens to get the ordinary input.
  • Anthropic (Messages API): input_tokens only counts tokens after the last cache breakpoint. Cache reads and writes are reported separately in cache_read_input_tokens and cache_creation_input_tokens, and the rate limits page gives total input as the sum of all three.
// 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<string, Price> = {
  "gpt-5-mini": { input: 0.25, cachedInput: 0.025, cacheWrite: 0.25, output: 2 },
  "claude-haiku-4-5": { input: 1, cachedInput: 0.1, cacheWrite: 1.25, output: 5 },
};

function priceFor(model: string): Price {
  const p = PRICES[model];
  // Refuse unknown models instead of counting them as free.
  if (!p) throw new Error(`No price for model ${model}`);
  return p;
}

const perM = (tokens: number, usdPerM: number) => (tokens * usdPerM) / 1_000_000;

// OpenAI Responses API: input_tokens includes cached and cache-write tokens.
export function openaiCost(
  model: string,
  u: { input_tokens: number; output_tokens: number;
       input_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number } },
): number {
  const p = priceFor(model);
  const cached = u.input_tokens_details?.cached_tokens ?? 0;
  const written = u.input_tokens_details?.cache_write_tokens ?? 0;
  const plain = Math.max(u.input_tokens - cached - written, 0);
  return perM(plain, p.input) + perM(cached, p.cachedInput)
       + perM(written, p.cacheWrite) + perM(u.output_tokens, p.output);
}

// Anthropic Messages API: input_tokens excludes cache reads and writes.
export function anthropicCost(
  model: string,
  u: { input_tokens: number; output_tokens: number;
       cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null },
): number {
  const p = priceFor(model);
  return perM(u.input_tokens, p.input)
       + perM(u.cache_read_input_tokens ?? 0, p.cachedInput)
       + perM(u.cache_creation_input_tokens ?? 0, p.cacheWrite)
       + perM(u.output_tokens, p.output);
}

// Worst case before the call: one token per input character (deliberately
// high) and the full output budget. Settling swaps in the real cost.
export function estimateCost(model: string, inputChars: number, maxOutputTokens: number): number {
  const p = priceFor(model);
  return perM(inputChars, p.input) + perM(maxOutputTokens, p.output);
}

The prices are per million tokens, copied from the OpenAI and Anthropic 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.

Step 5: wire it into the route

Here 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.

import OpenAI from "openai";
import { createClient as createAdminClient } from "@supabase/supabase-js";
import { createClient } from "@/lib/supabase/server";
import { aiLimiter } from "@/lib/rate-limit";
import { estimateCost, openaiCost } from "@/lib/ai-cost";

const openai = new OpenAI(); // reads OPENAI_API_KEY on the server
// Service-role client: server only, never in a NEXT_PUBLIC_ variable.
const admin = createAdminClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false } },
);

const MODEL = "gpt-5-mini";
const MONTHLY_CAP_USD = 2;
const MAX_OUTPUT_TOKENS = 1_000;

export async function POST(req: Request) {
  // 1. Who is this? getClaims() verifies the JWT.
  const supabase = await createClient();
  const { data: auth } = await supabase.auth.getClaims();
  const userId = auth?.claims.sub;
  if (!userId) return Response.json({ error: "Sign in first" }, { status: 401 });

  // 2. Rate limit per user id, not per IP.
  const rl = await aiLimiter.limit(userId);
  if (!rl.success) {
    const retryAfter = Math.max(1, Math.ceil((rl.reset - Date.now()) / 1000));
    return Response.json(
      { error: "Too many requests" },
      { status: 429, headers: { "Retry-After": String(retryAfter) } },
    );
  }

  const body = (await req.json().catch(() => null)) as { message?: unknown } | null;
  const message = typeof body?.message === "string" ? body.message.slice(0, 4_000) : "";
  if (!message) return Response.json({ error: "message is required" }, { status: 400 });

  // 3. Reserve the worst-case cost against this month's cap.
  const estimate = estimateCost(MODEL, message.length, MAX_OUTPUT_TOKENS);
  const { data: month, error } = await admin.rpc("ai_spend_reserve", {
    p_user: userId, p_cap_usd: MONTHLY_CAP_USD, p_estimate_usd: estimate,
  });
  if (error) return Response.json({ error: "Try again later" }, { status: 503 });
  if (!month) return Response.json({ error: "Monthly AI limit reached" }, { status: 402 });

  // 4. Call the model, then swap the estimate for the real cost.
  let actual = estimate; // if the call fails, keep the estimate (errs high)
  try {
    const r = await openai.responses.create({
      model: MODEL, input: message, max_output_tokens: MAX_OUTPUT_TOKENS,
    });
    if (r.usage) actual = openaiCost(MODEL, r.usage);
    return Response.json({ text: r.output_text });
  } catch {
    return Response.json({ error: "AI call failed" }, { status: 502 });
  } finally {
    await admin.rpc("ai_spend_settle", {
      p_user: userId, p_month: month, p_estimate_usd: estimate, p_actual_usd: actual,
    });
  }
}

The Supabase server client comes from @supabase/ssr, set up as in Supabase's Next.js server-side auth guide. For why the route uses getClaims() and not getSession(), see Supabase getSession vs getUser vs getClaims.

A few choices to adjust for your app:

  • 402 or 429 for the cap. The route returns 402 when the monthly cap is reached, so your UI can show "upgrade" or "resets on the 1st" rather than "slow down". Use 429 if your client already handles that.
  • Failed calls keep the estimate. If the AI call throws, the user is charged the estimate, because you can't always tell whether the provider billed you. That errs on the high side. Settle at 0 if you'd rather err low.
  • The estimate is deliberately high. It counts one token per input character, plus the full 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.
  • Different caps per plan. Read the user's plan before the reserve call and pass a different p_cap_usd value.

What this doesn't cover

These limits help reduce the risk of a surprise bill, but they don't remove it. Things to know:

  • Streaming. The route above waits for the full response. With 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.
  • Settle can overshoot slightly. If the real cost is higher than the estimate (for example, the price table is out of date), the total can go a little over the cap. The next reserve call is then refused.
  • Upstash or Supabase being down. As written, a Supabase error returns 503 and blocks the AI call, so the cap fails closed. Upstash is different. From our reading of the @upstash/ratelimit 2.2 source (we haven't tested this live), if Redis doesn't answer within the timeout (5 seconds by default), 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.
  • Other routes. Embeddings, image generation, background jobs and Server Actions that call a model need the same reserve and settle calls. Run the scanner from step 1 over the whole repo.
  • Keep the provider limit. Set a project spend limit in OpenAI and a workspace limit in Anthropic as well. OpenAI notes that its hard limit isn't instant, but these limits still protect you if your own code has a bug.

Checklist

  • Every AI route requires a signed-in user before it calls the model.
  • The rate limit is keyed on the user id, and over the limit it returns 429 with Retry-After.
  • A monthly dollar cap per user is reserved before the call and settled after it, in one atomic SQL statement.
  • The spend table has RLS on and no browser grants, and the functions are callable by service_role only.
  • The price table has a date, and unknown models are refused.
  • A provider-side spend limit is set as a backstop.

Related


Disclosure: this is our own paid product. Spend Caps Kit ($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 16 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.

Sources (checked 17 October 2026):