{"slug": "how-to-build-a-credit-system-for-a-next-js-ai-app-stripe-supabase", "title": "How to build a credit system for a Next.js AI app (Stripe + Supabase)", "summary": "A developer has created a credit-based billing system for Next.js AI applications using Stripe and Supabase, solving the problem of flat-rate pricing that can lead to margin erosion from heavy users. The system uses PostgreSQL database-level atomic operations with row locking to prevent race conditions in credit spending, and implements idempotency keys in the credit ledger to handle Stripe's at-least-once webhook delivery without double-charging users. The solution includes a `spend_credits` function that checks balance in the `WHERE` clause of an update statement, ensuring concurrent requests cannot overspend credits.", "body_md": "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.\n\nCredits 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.\n\nLet's kill all three.\n\nThe mistake is doing the check in your app code:\n\n```\n// DON'T: read-then-write has a race condition\nconst { balance } = await getBalance(userId);\nif (balance < cost) throw new Error(\"insufficient\");\nawait setBalance(userId, balance - cost); // two concurrent requests both pass the check\n```\n\nDo it in the database, in one statement, with the guard in the `WHERE`\n\nclause:\n\n```\n-- balances: one row per user\ncreate table credit_balances (\n  user_id    uuid primary key references auth.users(id) on delete cascade,\n  balance    integer not null default 0 check (balance >= 0),\n  updated_at timestamptz not null default now()\n);\n\n-- append-only ledger = audit log + idempotency guard (see Part 2)\ncreate table credit_ledger (\n  id              bigint generated always as identity primary key,\n  user_id         uuid not null references auth.users(id),\n  delta           integer not null,\n  reason          text not null,\n  idempotency_key text unique,            -- the secret weapon\n  created_at      timestamptz not null default now()\n);\n\ncreate or replace function spend_credits(p_user uuid, p_amount int, p_key text)\nreturns integer language plpgsql security definer set search_path = public as $$\ndeclare new_balance int;\nbegin\n  update credit_balances\n    set balance = balance - p_amount, updated_at = now()\n    where user_id = p_user and balance >= p_amount   -- the guard\n    returning balance into new_balance;\n  if new_balance is null then raise exception 'insufficient_credits'; end if;\n  insert into credit_ledger (user_id, delta, reason, idempotency_key)\n    values (p_user, -p_amount, 'spend', p_key);\n  return new_balance;\nend $$;\n```\n\n`where ... and balance >= p_amount`\n\nis the whole trick. Under concurrent requests Postgres takes a row lock, so the second `update`\n\nwaits 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.\n\nStripe 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`\n\nconstraint enforce it:\n\n```\n// app/api/stripe/webhook/route.ts\nexport async function POST(req: Request) {\n  const body = await req.text(); // raw body, required for signature check\n  const event = stripe.webhooks.constructEvent(\n    body, req.headers.get(\"stripe-signature\")!, process.env.STRIPE_WEBHOOK_SECRET!\n  );\n\n  if (event.type === \"checkout.session.completed\") {\n    const s = event.data.object as Stripe.Checkout.Session;\n    const userId = s.metadata?.userId;\n    const credits = creditsForSession(s); // map your Price to credits\n    // idempotencyKey = event.id, so a retry is a no-op\n    await grantCredits({ userId, amount: credits, idempotencyKey: event.id });\n  }\n  return Response.json({ received: true });\n}\ncreate or replace function grant_credits(p_user uuid, p_amount int, p_key text)\nreturns integer language plpgsql security definer set search_path = public as $$\ndeclare new_balance int;\nbegin\n  -- already applied this event? return current balance unchanged.\n  if exists (select 1 from credit_ledger where idempotency_key = p_key) then\n    select balance into new_balance from credit_balances where user_id = p_user;\n    return coalesce(new_balance, 0);\n  end if;\n  insert into credit_balances (user_id, balance) values (p_user, p_amount)\n    on conflict (user_id) do update set balance = credit_balances.balance + p_amount\n    returning balance into new_balance;\n  insert into credit_ledger (user_id, delta, reason, idempotency_key)\n    values (p_user, p_amount, 'purchase', p_key);\n  return new_balance;\nend $$;\n```\n\nThe `exists`\n\ncheck is a fast path. The real guarantee is `idempotency_key unique`\n\n. If two retries race past the check, the second `insert`\n\ninto the ledger violates the constraint and the whole function (it's atomic) rolls back, including the balance bump. Applied exactly once.\n\nA bonus: now you can safely `return 500`\n\non any transient error and let Stripe retry, because retries are free.\n\nDon'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**:\n\n``` js\n// app/api/jobs/route.ts: spend first, then enqueue\nconst jobId = crypto.randomUUID();\ntry {\n  await spendCredits({ userId, amount: 5, idempotencyKey: jobId }); // keyed on jobId\n} catch (e) {\n  return Response.json({ error: \"insufficient_credits\" }, { status: 402 });\n}\nawait db.from(\"jobs\").insert({ id: jobId, user_id: userId, type, status: \"queued\", credits_cost: 5 });\n// worker.ts: claim, process, refund on failure\nconst { data: job } = await db.rpc(\"claim_next_job\"); // FOR UPDATE SKIP LOCKED\ntry {\n  const output = await runYourModel(job);\n  await db.from(\"jobs\").update({ status: \"done\", output }).eq(\"id\", job.id);\n} catch (err) {\n  await db.from(\"jobs\").update({ status: \"failed\", error: String(err) }).eq(\"id\", job.id);\n  await refundCredits({ userId: job.user_id, amount: job.credits_cost,\n                        idempotencyKey: `refund:${job.id}` }); // distinct key, never double-refunds\n}\n```\n\nTwo details that matter. Claim jobs with `FOR UPDATE SKIP LOCKED`\n\nso you can run N workers without two of them grabbing the same job. And key the refund `refund:<jobId>`\n\n, distinct from the spend's `<jobId>`\n\n, so a retried failure handler can't refund twice.\n\n`WHERE`\n\nclause and you oversell.Three functions (`spend`\n\n, `grant`\n\n, `refund`\n\n), 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.\n\nIf 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.\n\n*Algy ( youngalgy.com). I build and ship small AI/SaaS products. CreditKit is the billing layer extracted from about 10 of them.*", "url": "https://wpnews.pro/news/how-to-build-a-credit-system-for-a-next-js-ai-app-stripe-supabase", "canonical_source": "https://dev.to/youngalgy/how-to-build-a-credit-system-for-a-nextjs-ai-app-stripe-supabase-heb", "published_at": "2026-06-06 00:19:11+00:00", "updated_at": "2026-06-06 00:41:22.629474+00:00", "lang": "en", "topics": ["ai-startups", "ai-products", "ai-tools", "ai-infrastructure", "mlops"], "entities": ["Stripe", "Supabase", "Next.js", "GPU"], "alternates": {"html": "https://wpnews.pro/news/how-to-build-a-credit-system-for-a-next-js-ai-app-stripe-supabase", "markdown": "https://wpnews.pro/news/how-to-build-a-credit-system-for-a-next-js-ai-app-stripe-supabase.md", "text": "https://wpnews.pro/news/how-to-build-a-credit-system-for-a-next-js-ai-app-stripe-supabase.txt", "jsonld": "https://wpnews.pro/news/how-to-build-a-credit-system-for-a-next-js-ai-app-stripe-supabase.jsonld"}}