How I built an AI Line Art SaaS with Next.js, a worker queue, and Stripe credits
Bruce LeeDEV Community
1 views
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 /webhook endpoint 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 jobId response, 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. ๐
For a few weeks I ran a proper multi-agent orchestrator. The concept was right, and I still think the people building those tools are pointed the correct way. But the bill was absurd, and it took me a while to work out why.
It wasn't the coding. It was the talking about the coding.
Every turn, age
You sit down Friday with one messy GitHub issue. You want a usable plan before Monday morning. You paste the text into a chat agent.
The reply looks polished, complete, and very sure. It adds Kafka, Redis, and a new auth service. The issue never named those systems.
You do not have a brownfield bu
Il problema: SQL non e uno standard unico
Sulla carta, SQL e uno standard. Nella pratica, ogni database parla il suo dialetto. MySQL usa i backtick per quotare gli identificatori, PostgreSQL le virgolette doppie, SQLite le accetta entrambe ma preferisce le virgolette. MySQL ha AUTO_INCREMENT, Post