How to Add a Credit System to a Next.js AI App (App Router)
Build a Next.js App Router route handler that deducts credits per AI generation with an idempotency key, refunds failed runs, and keeps the Cred API key server-side.
By cred.diy Team

You'll build a Next.js App Router route handler, app/api/generate/route.ts, that charges a signed-in user a fixed number of credits for each AI generation and refuses the request when their balance is too low. The Cred API key stays in a server-only environment variable, every deduction carries an idempotency key so a retry never charges twice, and a failed generation gives the credits back.
Prerequisites
- A Next.js app using the App Router (
app/directory), with an auth library that can tell server code who is signed in: Auth.js, Clerk, Supabase Auth or similar. You need the user's stable ID and email. - A cred.diy project with one credit type (for example "Credits"). Copy its ID from Dashboard → Credit Types → Credit Type ID.
- An API key from Dashboard → API Keys. Keys start with
sk_live_. - Optional, for step 6: a pricing tier and a connected Stripe account so users can buy more credits.
How the request flows
- The browser posts a prompt and a one-time
requestIdto/api/generate. - The route handler identifies the user from the session and deducts credits from Cred. A deduct can't take a balance below zero; it fails instead.
- If the deduct succeeded, the handler calls your model.
- If the model call fails, the handler refunds that deduction.
Only your server holds the key.
Step 1: Keep the API key on the server
Add the key and credit type ID to .env.local, and to your host's environment settings for production:
CRED_API_KEY=sk_live_your_api_key
CRED_CREDIT_TYPE_ID=your_credit_type_id
Do not prefix these with NEXT_PUBLIC_. Per the Next.js environment variables guide, Next.js inlines NEXT_PUBLIC_ variables into the JavaScript it ships to browsers. Without the prefix, a variable is readable only by server code such as route handlers and server components.
Step 2: Write a small server-only Cred helper
// lib/cred.ts
import "server-only"; // npm install server-only: build fails if a client component imports this
const CRED_API = "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api";
export async function cred(path: string, init: RequestInit = {}) {
const res = await fetch(`${CRED_API}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.CRED_API_KEY!,
...init.headers,
},
cache: "no-store", // balances must never come from the Next.js data cache
});
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
Every request authenticates with the x-api-key header. Each API call also costs your account 1 platform credit (CRD). That's one reason to read the balance off a deduct response rather than polling /balance.
Step 3: Create the Cred user and grant starter credits
POST /deduct only works on a user that already exists. If it doesn't, Cred returns 404 with code USER_NOT_FOUND. The easiest fix is to issue starter credits when someone signs up, because POST /issue creates the user if needed:
// lib/credits.ts
import { cred } from "./cred";
export async function grantSignupCredits(user: { id: string; email: string; name?: string }) {
return cred("/issue", {
method: "POST",
headers: { "Idempotency-Key": `signup-grant:${user.id}` },
body: JSON.stringify({
user_id: user.id, // stored as the Cred external_id
user_email: user.email.toLowerCase(), // same email = same wallet across your apps
user_name: user.name,
credit_type_id: process.env.CRED_CREDIT_TYPE_ID,
amount: 50,
reason: "Signup bonus",
}),
});
}
Each user gets one fixed key, so this call is safe to make from a sign-in callback that may fire more than once. Every call after the first returns the original transaction with idempotent_replay: true and adds nothing. If you don't want a free grant, create the user with POST /users and { external_id, email, name } instead.
Step 4: Charge per generation in the route handler
// app/api/generate/route.ts
import { NextResponse } from "next/server";
import { cred } from "@/lib/cred";
import { getCurrentUser } from "@/lib/auth"; // your auth helper
import { generateImage } from "@/lib/model"; // your model call
const COST = 5; // credits per generation, decided on the server
export async function POST(req: Request) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Sign in first" }, { status: 401 });
const { prompt, requestId } = await req.json();
if (typeof requestId !== "string" || !requestId) {
return NextResponse.json({ error: "requestId is required" }, { status: 400 });
}
// 1. Charge first. The deduct is the balance check: it fails rather than going negative.
const charge = await cred("/deduct", {
method: "POST",
headers: { "Idempotency-Key": `gen:${user.id}:${requestId}` },
body: JSON.stringify({
user_id: user.id,
user_email: user.email.toLowerCase(),
credit_type_id: process.env.CRED_CREDIT_TYPE_ID,
amount: COST,
reason: "Image generation",
reference_id: requestId,
}),
});
if (charge.body.code === "INSUFFICIENT_CREDITS" || charge.body.code === "USER_NOT_FOUND") {
return NextResponse.json({ error: "Not enough credits", cost: COST }, { status: 402 });
}
if (charge.status !== 200) {
return NextResponse.json({ error: "Billing is unavailable, try again" }, { status: 503 });
}
// A retry of a request we already charged: don't run (and give away) the work again.
if (charge.body.idempotent_replay) {
return NextResponse.json({ error: "This request was already processed" }, { status: 409 });
}
// 2. Do the work.
try {
const image = await generateImage(prompt);
return NextResponse.json({ image, balance: charge.body.new_balance });
} catch {
// 3. The user paid for nothing: return the credits.
await cred("/refund", {
method: "POST",
body: JSON.stringify({
transaction_id: charge.body.transaction_id,
reason: "Generation failed",
}),
});
return NextResponse.json({ error: "Generation failed; credits returned" }, { status: 500 });
}
}
The order matters. The handler deducts first, and the deduct is atomic, so two simultaneous requests can't both spend the same last 5 credits. A successful deduct returns transaction_id and new_balance. A failed one returns HTTP 400 with code: "INSUFFICIENT_CREDITS". Refunding a deduct by transaction_id puts the credits back, and each transaction can be refunded only once (a second attempt gets 409).
If you would rather return the original result on a retry than a 409, store each generation's result keyed by requestId in your database and return it when idempotent_replay is true.
Step 5: Call it from the client
Create the requestId once per click, and reuse it only to retry that same click:
"use client";
async function generate(prompt: string) {
const requestId = crypto.randomUUID();
const res = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, requestId }),
});
if (res.status === 402) return showBuyCreditsDialog();
return res.json(); // { image, balance }
}
To show the balance on page load, read it in a server component with GET /balance?email=...&credit_type_id=.... The response has a balances array, and an empty array means zero. After that, update the number from the balance your route returns.
Step 6: Let users buy more credits
Add a second route that creates a hosted Stripe Checkout session through Cred and redirects to it:
// app/api/buy-credits/route.ts
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
export async function POST() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Sign in first" }, { status: 401 });
const res = await fetch("https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/hosted-checkout", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": process.env.CRED_API_KEY! },
body: JSON.stringify({
user_id: user.id,
user_email: user.email.toLowerCase(),
pricing_tier_id: process.env.CRED_PRICING_TIER_ID,
success_url: "https://yourapp.com/credits?status=success",
cancel_url: "https://yourapp.com/credits",
}),
});
const { url, error } = await res.json();
if (!url) return NextResponse.json({ error }, { status: 502 });
return NextResponse.json({ url }); // client does window.location.href = url
}
After the payment succeeds, Cred's Stripe webhook adds the credits. Your success page shouldn't add them itself. Selling credit packs covers tiers, custom amounts and subscriptions.
Common mistakes
- Using
NEXT_PUBLIC_CRED_API_KEY. That ships your secret key to every visitor. Keep the key unprefixed and use it only in route handlers, server actions or server components. - Checking the balance, then deducting. Two requests can both pass the check. Let
/deductbe the check, and readINSUFFICIENT_CREDITSfrom its response. - Creating a new idempotency key on every retry. The key exists so a retry is recognised. Create one per user action and reuse it for retries of that action. Reusing a key for a different request (another amount, user or credit type) returns
409 IDEMPOTENCY_KEY_REUSED. - Running the work on a replay. A deduct with
idempotent_replay: truecharged nothing this time. Running the model again is a free generation. - Letting the client choose the cost. Read the price from a server-side constant or table, never from the request body.
- Inconsistent email casing. Lookups match emails exactly, so lowercase them everywhere you send them.
- Calling Cred from a
"use client"file. Any code in a client file can end up in the browser bundle. Theserver-onlyimport in step 2 makes that a build-time error.
FAQ
Do I need the Cred SDK? No. Everything here uses the REST API with fetch, which works in both the Node.js and Edge runtimes.
What about streaming responses or variable-cost generations? For a fixed price, deduct before the stream starts and refund if it fails before producing output. If the cost isn't known until the end, use an escrow hold: put an estimate on hold first, then capture the actual amount and release the rest.
Can the same user share credits across several of my apps? Yes. Within one Cred project, the same email resolves to the same wallet, whatever user_id each app sends. See End users.
What does it cost to run? Each API call uses 1 CRD from your Cred account. Plans and CRD packages are on the pricing page.
Sources
- Next.js: Route handlers (route.js reference)
- Next.js: Environment variables and the
NEXT_PUBLIC_prefix - Next.js: Server and Client Components,
server-only - Stripe: Checkout
- cred.diy: Authentication, Managing credits, End users, Refunds, Escrow, Hosted checkout, Webhooks, Pricing

How to Add Usage Credits to a Supabase App with an Edge Function
Verify the Supabase Auth user in an Edge Function, map them to a Cred end user by email, and deduct credits per action with the API key stored as a Supabase secret.

Building AI-Powered Apps with Credit Metering
How to implement per-request credit billing for AI features like image generation, chat completions, and embeddings.