Hey! 👋 I recently shipped ailineart.com, an AI line art generator that turns photos and text prompts into clean line art (sketch, pencil, woodcut, watercolor styles). It's a solo-built, bootstrapped SaaS with a free tier and paid subscriptions.
Plenty of posts cover "I picked Next.js and it was great." I want to cover the three things that actually decided whether this product would work or fall over:
- Running long AI jobs without blocking web requests
- Credit accounting that can't be cheated or double-charged
- Stripe webhooks that survive retries and outages
If you're building any AI SaaS where a generation takes 10–60 seconds and users pay per generation, this is the plumbing you'll need. Let's go. 🚀
The Stack at a Glance
| Layer | Choice | Why |
|---|---|---|
| Frontend + API | Next.js (App Router) | SSR for SEO (this is a tool site — search traffic is the business), one repo, one deploy |
| Background jobs | Dedicated worker process + PostgreSQL queue | AI calls take tens of seconds; they must never live inside a request |
| Database | PostgreSQL | Jobs, users, credit ledger — one source of truth |
| Payments | Stripe (subscriptions + one-off credits) | Webhook-driven, idempotent credit grants |
| AI generation | Third-party image-generation API | Pay per generation instead of paying for idle GPUs |
No GPUs, no Kubernetes, no microservices. One web container, one worker container, one database. That's the whole diagram — and it's deliberate.
Part 1: Never Generate Inside a Request
The naive implementation everyone starts with:
// ❌ What I did NOT do
app.post("/api/generate", async (req, res) => {
const image = await callImageAPI(req.body.prompt); // 30-60s!
res.json({ image });
});
This dies three ways: serverless function timeouts, mobile browsers killing idle connections, and users retrying when the spinner "looks stuck" — which silently double-bills your AI provider.
The architecture I landed on:
┌─────────┐ 1. POST /api/jobs ┌────────────┐
│ Browser │ ─────────────────────────▶ │ Next.js API │ ──▶ INSERT job (queued)
└─────────┘ ◀───────────────────────── └────────────┘ deduct credits (ledger)
2. { jobId } immediately
┌────────┐
3. worker polls + claims job │ Worker │ ──▶ call AI API
┌────────────────────────────────────┐ └────────┘ upload result
│ SELECT ... FOR UPDATE SKIP LOCKED │ │
└────────────────────────────────────┘ ▼
┌─────────┐ 5. GET /api/jobs/:id job → succeeded / failed
│ Browser │ ─────────────────────────▶ 4. write result to DB (+ refund credits if failed)
└─────────┘
The job queue is just a Postgres table — no Redis, no Celery, no extra infra to babysit:
CREATE TABLE jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'queued', -- queued|running|succeeded|failed
params JSONB NOT NULL,
result_url TEXT,
credits_cost INT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
locked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The worker claims jobs atomically, so it's safe to run multiple workers later:
-- Claim one job. SKIP LOCKED means concurrent workers never grab the same row.
UPDATE jobs SET status = 'running', locked_at = now()
WHERE id = (
SELECT id FROM jobs
WHERE status = 'queued'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;
The lesson everyone learns the hard way: if a job fails after credits were deducted, refund them. My failed transition does INSERT INTO credit_ledger (... amount = +credits_cost ...) in the same transaction as the status update. Users forgive failures; users do not forgive losing credits with no trace.
Part 2: Credits Are an Accounting Problem, Not a Counter
My first draft had users.credits INT. That breaks the moment two requests race: a batch generation and a webhook refill both fire, and suddenly users have negative credits or free generations.
The fix: an append-only ledger, and the balance is a sum.
CREATE TABLE credit_ledger (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
delta INT NOT NULL, -- +10 grant, -1 generation, +1 refund
reason TEXT NOT NULL, -- signup_bonus | subscription | generation | refund
ref_id TEXT UNIQUE, -- idempotency! see Part 3
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Every mutation is a row. The ref_id UNIQUE constraint is the unsung hero: it makes every credit grant idempotent. If Stripe calls the webhook twice (it will — retries are a feature, not a bug), the second insert fails on the unique constraint and the user doesn't get double credits.
Balance check before accepting a job is one query, and the deduction + job insert share one transaction, so you can never end up with a job that wasn't paid for:
await db.transaction(async (tx) => {
const balance = await getBalance(tx, userId);
if (balance < cost) throw new InsufficientCreditsError();
await tx.insert(creditLedger).values({ userId, delta: -cost, reason: "generation", refId: jobId });
await tx.insert(jobs).values({ id: jobId, userId, creditsCost: cost, params });
});
Part 3: Stripe Webhooks — Assume Every One Will Arrive Twice
Subscriptions + credits means the money side has exactly one job: when a payment succeeds, credits appear, exactly once. Everything else is negotiable.
The three webhooks that matter:
-
checkout.session.completed→ first purchase or credit pack -
invoice.paid→ monthly renewal, grant the monthly credits -
customer.subscription.deleted→ downgrade to free tier
And the rules that saved me:
export async function POST(req: Request) {
const sig = req.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(await req.text(), sig, WEBHOOK_SECRET);
// Idempotency: ledger.ref_id = event.id makes replay harmless.
// The DB unique constraint IS the dedup layer — no Redis needed.
await grantCredits({
userId: event.data.object.metadata.userId,
delta: monthlyCredits,
reason: "subscription",
refId: event.id, // 👈 duplicate delivery → unique violation → ignored
});
return new Response("ok");
}
-
Verify signatures. An unauthenticated
/webhookendpoint is an open faucet for free credits. - Grant credits only from webhooks, never from the redirect back to your site. Users close tabs; Stripe retries don't.
- Persist the raw event before processing. When (not if) your handler has a bug at 2am, you can replay from your own table instead of begging Stripe support.
Part 4: What It Costs
Real numbers, since every "I built an AI SaaS" post skips them:
| Item | Monthly cost |
|---|---|
| Hosting (web + worker + Postgres) | $[FILL] |
| Image-generation API (per-generation, scales with usage) | ~$[FILL] per 1,000 generations |
| Stripe | 2.9% + $0.30 per transaction |
| Total fixed | $[FILL]/mo |
The per-generation pricing of the AI API is the whole business model in one line: free-tier users cost me $[FILL]/day, and a Basic subscription ($9.99) covers [FILL] generations of API cost. Knowing that ratio is the difference between "a fun project" and "a business."
Biggest Lessons Learned
-
The queue is the product. Users judge AI apps by the waiting experience: instant
jobIdresponse, a real progress state, and a page that survives refresh (because it reads state from the DB, not from memory). - Treat every external call as eventually-failing. The AI API will time out; Stripe will redeliver. Idempotency keys and refunds aren't nice-to-haves.
-
Postgres was enough. I was ready to add Redis, a proper job framework, and a separate auth service. None of it was needed at this scale. One database,
SKIP LOCKED, and an append-only ledger got me to production. - SSR is not optional for tool sites. The majority of signups arrive from search — a client-rendered SPA would have quietly killed half the funnel.
Try It
You can see the whole thing live — upload a photo, watch the queue do its thing, get line art in seconds — at ailineart.com. Free tier includes daily credits, no card required.
Happy to answer questions in the comments about the queue design, Stripe credit grants, or running a lean AI SaaS solo. 👇
Top comments (0)