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. ๐Ÿ‘‡