# OpenAI and Anthropic spend limits per user in Next.js

> Source: <https://dev.to/edtrackai/openai-and-anthropic-spend-limits-per-user-in-nextjs-4jop>
> Published: 2026-10-24 04:07:15+00:00

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<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](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.

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.

``` python
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](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).

A few choices to adjust for your app:

`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.
These limits help reduce the risk of a surprise bill, but they don't remove it. Things to know:

`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`.
*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.

**Sources (checked 17 October 2026):**

20 checks for AI-built Next.js apps: RLS, service keys, webhook signatures. PDF, free.

Free 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).

Spend Caps Kit · $19 plus VAT where applicable

Per-user rate limits and AI spend caps for Next.js + Supabase + Stripe
