# How to build a credit system for a Next.js AI app (Stripe + Supabase)

> Source: <https://dev.to/youngalgy/how-to-build-a-credit-system-for-a-nextjs-ai-app-stripe-supabase-heb>
> Published: 2026-06-06 00:19:11+00:00

If you're building an AI app (image generation, transcription, an agent, anything that calls a model) you've probably realized a flat "$10/month" doesn't work. Every action costs *you* real money in GPU/API spend, so a single power user can torch your margins. The answer is **usage credits**: users buy a balance, each action spends some.

Credits sound trivial. They are not. I've shipped about 10 small AI/SaaS apps, and the credit layer is where I got burned every single time. It took three patterns to fix it for good. Here they are, with copy-pasteable code for Next.js + Supabase + Stripe. Get these right and your billing won't oversell, double-charge, or strand a user's money.

Let's kill all three.

The mistake is doing the check in your app code:

```
// DON'T: read-then-write has a race condition
const { balance } = await getBalance(userId);
if (balance < cost) throw new Error("insufficient");
await setBalance(userId, balance - cost); // two concurrent requests both pass the check
```

Do it in the database, in one statement, with the guard in the `WHERE`

clause:

```
-- balances: one row per user
create table credit_balances (
  user_id    uuid primary key references auth.users(id) on delete cascade,
  balance    integer not null default 0 check (balance >= 0),
  updated_at timestamptz not null default now()
);

-- append-only ledger = audit log + idempotency guard (see Part 2)
create table credit_ledger (
  id              bigint generated always as identity primary key,
  user_id         uuid not null references auth.users(id),
  delta           integer not null,
  reason          text not null,
  idempotency_key text unique,            -- the secret weapon
  created_at      timestamptz not null default now()
);

create or replace function spend_credits(p_user uuid, p_amount int, p_key text)
returns integer language plpgsql security definer set search_path = public as $$
declare new_balance int;
begin
  update credit_balances
    set balance = balance - p_amount, updated_at = now()
    where user_id = p_user and balance >= p_amount   -- the guard
    returning balance into new_balance;
  if new_balance is null then raise exception 'insufficient_credits'; end if;
  insert into credit_ledger (user_id, delta, reason, idempotency_key)
    values (p_user, -p_amount, 'spend', p_key);
  return new_balance;
end $$;
```

`where ... and balance >= p_amount`

is the whole trick. Under concurrent requests Postgres takes a row lock, so the second `update`

waits for the first to commit and *re-evaluates* the condition against the new balance. The balance can never go negative. No application-level locking, no Redis, no race.

Stripe delivers webhooks at-least-once. Your handler must be safe to run twice with the same event. Key every grant on the **Stripe event id** and let the ledger's `unique`

constraint enforce it:

```
// app/api/stripe/webhook/route.ts
export async function POST(req: Request) {
  const body = await req.text(); // raw body, required for signature check
  const event = stripe.webhooks.constructEvent(
    body, req.headers.get("stripe-signature")!, process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (event.type === "checkout.session.completed") {
    const s = event.data.object as Stripe.Checkout.Session;
    const userId = s.metadata?.userId;
    const credits = creditsForSession(s); // map your Price to credits
    // idempotencyKey = event.id, so a retry is a no-op
    await grantCredits({ userId, amount: credits, idempotencyKey: event.id });
  }
  return Response.json({ received: true });
}
create or replace function grant_credits(p_user uuid, p_amount int, p_key text)
returns integer language plpgsql security definer set search_path = public as $$
declare new_balance int;
begin
  -- already applied this event? return current balance unchanged.
  if exists (select 1 from credit_ledger where idempotency_key = p_key) then
    select balance into new_balance from credit_balances where user_id = p_user;
    return coalesce(new_balance, 0);
  end if;
  insert into credit_balances (user_id, balance) values (p_user, p_amount)
    on conflict (user_id) do update set balance = credit_balances.balance + p_amount
    returning balance into new_balance;
  insert into credit_ledger (user_id, delta, reason, idempotency_key)
    values (p_user, p_amount, 'purchase', p_key);
  return new_balance;
end $$;
```

The `exists`

check is a fast path. The real guarantee is `idempotency_key unique`

. If two retries race past the check, the second `insert`

into the ledger violates the constraint and the whole function (it's atomic) rolls back, including the balance bump. Applied exactly once.

A bonus: now you can safely `return 500`

on any transient error and let Stripe retry, because retries are free.

Don't run a slow model call inside the request. It'll time out. Spend the credits, queue a job, process it in a worker, and **refund if it fails**:

``` js
// app/api/jobs/route.ts: spend first, then enqueue
const jobId = crypto.randomUUID();
try {
  await spendCredits({ userId, amount: 5, idempotencyKey: jobId }); // keyed on jobId
} catch (e) {
  return Response.json({ error: "insufficient_credits" }, { status: 402 });
}
await db.from("jobs").insert({ id: jobId, user_id: userId, type, status: "queued", credits_cost: 5 });
// worker.ts: claim, process, refund on failure
const { data: job } = await db.rpc("claim_next_job"); // FOR UPDATE SKIP LOCKED
try {
  const output = await runYourModel(job);
  await db.from("jobs").update({ status: "done", output }).eq("id", job.id);
} catch (err) {
  await db.from("jobs").update({ status: "failed", error: String(err) }).eq("id", job.id);
  await refundCredits({ userId: job.user_id, amount: job.credits_cost,
                        idempotencyKey: `refund:${job.id}` }); // distinct key, never double-refunds
}
```

Two details that matter. Claim jobs with `FOR UPDATE SKIP LOCKED`

so you can run N workers without two of them grabbing the same job. And key the refund `refund:<jobId>`

, distinct from the spend's `<jobId>`

, so a retried failure handler can't refund twice.

`WHERE`

clause and you oversell.Three functions (`spend`

, `grant`

, `refund`

), one ledger table doing double duty as audit log and idempotency guard, and a worker. It won't oversell, double-charge, or strand money. It took me a few shipped apps to get here.

If you'd rather not rebuild it, I packaged all of this into ** CreditKit**, a Next.js + Supabase + Stripe starter for AI apps. That's these patterns plus passwordless auth, Stripe checkout and portal, the worker, and a dashboard. Either way, now you know the traps.

*Algy ( youngalgy.com). I build and ship small AI/SaaS products. CreditKit is the billing layer extracted from about 10 of them.*
