DEV Community

Bruce Lee
Bruce Lee

Posted on

How I built an AI Line Art SaaS with Next.js, a worker queue, and Stripe credits

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:

  1. Running long AI jobs without blocking web requests
  2. Credit accounting that can't be cheated or double-charged
  3. 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 });
});
Enter fullscreen mode Exit fullscreen mode

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)
└─────────┘
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode

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 *;
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode
  • 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

  1. 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).
  2. 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.
  3. 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.
  4. 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)