<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bruce Lee</title>
    <description>The latest articles on DEV Community by Bruce Lee (@bruce_lee_666).</description>
    <link>https://dev.to/bruce_lee_666</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4019794%2Fc5e76d9b-f54a-4cb6-a144-772a22647c41.jpeg</url>
      <title>DEV Community: Bruce Lee</title>
      <link>https://dev.to/bruce_lee_666</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bruce_lee_666"/>
    <language>en</language>
    <item>
      <title>I built an AI tools directory in 2026. Here's the SEO playbook (the honest version)</title>
      <dc:creator>Bruce Lee</dc:creator>
      <pubDate>Fri, 04 Sep 2026 15:29:47 +0000</pubDate>
      <link>https://dev.to/bruce_lee_666/i-built-an-ai-tools-directory-in-2026-heres-the-seo-playbook-the-honest-version-28cl</link>
      <guid>https://dev.to/bruce_lee_666/i-built-an-ai-tools-directory-in-2026-heres-the-seo-playbook-the-honest-version-28cl</guid>
      <description>&lt;p&gt;There are hundreds of AI tools directories at this point. So the obvious question is why I built another one. Short answer: I run AI products myself, I kept looking for where to launch them, and I noticed most directories are either scraped garbage or empty link farms. That gap is the product. &lt;a href="https://showmebest.ai" rel="noopener noreferrer"&gt;showmebest.ai&lt;/a&gt; is my attempt at a curated one, and this post is the full breakdown of how it's built for search, not just how it looks.&lt;/p&gt;

&lt;p&gt;This is not a "I made $8k last month" post. The site is young and pre-revenue. What it does have is a working SEO setup and an early traffic pattern that I think is more useful than another income screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The niche reality: you will not rank for "best AI tools"
&lt;/h2&gt;

&lt;p&gt;Let's kill the fantasy first. The head term "best AI tools" is owned by sites with a decade of domain authority. A new directory competing there is donating its time.&lt;/p&gt;

&lt;p&gt;What a new directory &lt;em&gt;can&lt;/em&gt; win, in order of difficulty:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"Submit AI tool" and its variants&lt;/strong&gt; — this is the sleeper keyword of the whole niche. Every indie hacker who ships a product searches exactly this. It's low volume, high intent, and the SERP is weak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-tail discovery terms&lt;/strong&gt; — "AI tools for tattoo artists", "AI tools to turn photos into line art", "best free noise reduction AI". Each is small. There are hundreds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branded and alternative searches&lt;/strong&gt; — "toolname review", "toolname alternatives". Programmatic pages cover this automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the entire site architecture is designed bottom-up around #2 and #3, with the submission page optimized for #1. The homepage barely matters for search. That was a deliberate, slightly painful decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One page per tool is the whole game
&lt;/h2&gt;

&lt;p&gt;Every listed tool gets a real page: screenshots, a description that is actually written (not the tool's own homepage copy pasted in), category tags, pricing summary, and the outbound link. That page is the SEO unit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/tools/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getAllTools&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; Review, Pricing &amp;amp; Alternatives (2026)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; in plain words: what it does, who it's for,
      what it costs, and &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alternativesCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; alternatives.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://showmebest.ai/tools/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The title pattern matters more than it looks. "Review, Pricing &amp;amp; Alternatives" stacks three search intents onto one page, and those are queries the tool's own site can never satisfy, because nobody writes honest alternatives of themselves. That's the structural advantage a directory has over every tool's marketing site. Use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Structured data, because directories are exactly what it's for
&lt;/h2&gt;

&lt;p&gt;Schema markup is boring until you realize a tools directory is literally a database of &lt;code&gt;SoftwareApplication&lt;/code&gt; entities. Google has a vocabulary for precisely this. Not using it is leaving free SERP real estate on the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SoftwareApplication"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tool name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"applicationCategory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DesignApplication"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"One honest sentence about what it does"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"offers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Offer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9.99"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"priceCurrency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggregateRating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AggregateRating"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ratingValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4.6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ratingCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"23"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus the unglamorous checklist that moves the needle for a page-heavy site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sitemap split by type&lt;/strong&gt; (tools / categories / static) so indexation is observable per section in Search Console. When a section stalls, you know where to look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Category pages as real content&lt;/strong&gt;, not empty grids. Each category page gets a written intro answering "which AI tools belong here and why", then the tool grid. An empty grid page is thin content with extra steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal links with descriptive anchors&lt;/strong&gt; from related tools ("alternatives") and from categories to tools. The links &lt;em&gt;are&lt;/em&gt; the directory's content graph.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. The curation bar is the moat
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable truth about the niche: most directories will index anything with an AI logo, because volume feels like growth. In 2026 that's exactly what Google's spam systems are tuned to catch, and sites full of auto-generated tool pages are getting deindexed weekly.&lt;/p&gt;

&lt;p&gt;So the rule on showmebest is: every listing gets its own screenshots, a description I write from actually poking at the tool, and pricing pulled from the tool's real pricing page. It's slower. That's the point. The moat against the other 500 directories isn't more tools, it's pages that are worth a visit.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The growth loop that actually works for directories
&lt;/h2&gt;

&lt;p&gt;Directories have a cheat code that normal SaaS products don't: &lt;strong&gt;your users are founders who also want traffic.&lt;/strong&gt; That makes the loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Founder searches "submit AI tool", finds the site, submits theirs (free).&lt;/li&gt;
&lt;li&gt;I review and list it, notify them, they share the listing → social traffic + a backlink to the directory.&lt;/li&gt;
&lt;li&gt;Their listing ranks for "toolname review / alternatives" within weeks → real search users arrive.&lt;/li&gt;
&lt;li&gt;Those users find &lt;em&gt;other&lt;/em&gt; tools → founders of those tools notice the referral traffic in their analytics → they come submit too.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Now&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tools listed&lt;/td&gt;
&lt;td&gt;[X]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Organic clicks / mo&lt;/td&gt;
&lt;td&gt;[X]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top long-tail query&lt;/td&gt;
&lt;td&gt;[X]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Founder submissions via "submit" page&lt;/td&gt;
&lt;td&gt;[X]&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One example from the other side of the loop: my own product &lt;a href="https://ailineart.com" rel="noopener noreferrer"&gt;AI Line Art&lt;/a&gt; was one of the first tools listed, and watching which listing pages actually pulled search traffic versus which flopped is where most of the playbook above came from. Running both sides, the directory and the product, is the fastest SEO education I've had.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Monetization, honestly
&lt;/h2&gt;

&lt;p&gt;The site is pre-revenue and I'm saying that on purpose. The obvious path is featured placements for founders who want the visibility, and the listing traffic pattern above is the pitch: you're not buying a link, you're buying a page that ranks for your product name's review and alternative searches.&lt;/p&gt;

&lt;p&gt;But I'm not going to write a monetization section with invented numbers. When there's real data, it goes in a follow-up post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons so far
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Win the bottom of the SERP first.&lt;/strong&gt; Long-tail and branded queries are where a young directory gets its first few hundred monthly visitors. Head terms are a year-long project, not a launch goal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Submit your AI tool" is the best keyword in the niche.&lt;/strong&gt; It converts founders, who create content for you, who attract searchers, who attract founders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curation is compounding, volume is a liability.&lt;/strong&gt; Unique descriptions and real screenshots are slow today and safe next year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Directories are schema markup made flesh.&lt;/strong&gt; If your site is a database of things, use the vocabulary Google already built for it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're building an AI tool and want a listing (free, reviewed by a human), the submission page is at &lt;a href="https://showmebest.ai" rel="noopener noreferrer"&gt;showmebest.ai&lt;/a&gt;. And if you're building a directory yourself, I'm happy to answer questions in the comments about the page structure or the submission loop. 👇&lt;/p&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>seo</category>
      <category>startup</category>
    </item>
    <item>
      <title>How I built an AI Line Art SaaS with Next.js, a worker queue, and Stripe credits</title>
      <dc:creator>Bruce Lee</dc:creator>
      <pubDate>Fri, 04 Sep 2026 15:06:53 +0000</pubDate>
      <link>https://dev.to/bruce_lee_666/how-i-built-an-ai-line-art-saas-with-nextjs-a-worker-queue-and-stripe-credits-1o35</link>
      <guid>https://dev.to/bruce_lee_666/how-i-built-an-ai-line-art-saas-with-nextjs-a-worker-queue-and-stripe-credits-1o35</guid>
      <description>&lt;p&gt;Hey! 👋 I recently shipped &lt;a href="https://ailineart.com" rel="noopener noreferrer"&gt;ailineart.com&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Running long AI jobs without blocking web requests&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Credit accounting that can't be cheated or double-charged&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stripe webhooks that survive retries and outages&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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. 🚀&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frontend + API&lt;/td&gt;
&lt;td&gt;Next.js (App Router)&lt;/td&gt;
&lt;td&gt;SSR for SEO (this is a tool site — search traffic is the business), one repo, one deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background jobs&lt;/td&gt;
&lt;td&gt;Dedicated worker process + PostgreSQL queue&lt;/td&gt;
&lt;td&gt;AI calls take tens of seconds; they must never live inside a request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;Jobs, users, credit ledger — one source of truth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments&lt;/td&gt;
&lt;td&gt;Stripe (subscriptions + one-off credits)&lt;/td&gt;
&lt;td&gt;Webhook-driven, idempotent credit grants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI generation&lt;/td&gt;
&lt;td&gt;Third-party image-generation API&lt;/td&gt;
&lt;td&gt;Pay per generation instead of paying for idle GPUs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No GPUs, no Kubernetes, no microservices. One web container, one worker container, one database. That's the whole diagram — and it's deliberate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: Never Generate Inside a Request
&lt;/h2&gt;

&lt;p&gt;The naive implementation everyone starts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ What I did NOT do&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/generate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;callImageAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 30-60s!&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The architecture I landed on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────┐     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)
└─────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job queue is just a Postgres table — no Redis, no Celery, no extra infra to babysit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;       &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'queued'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- queued|running|succeeded|failed&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt;        &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;result_url&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;credits_cost&lt;/span&gt;  &lt;span class="nb"&gt;INT&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;attempts&lt;/span&gt;      &lt;span class="nb"&gt;INT&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;locked_at&lt;/span&gt;     &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worker claims jobs atomically, so it's safe to run multiple workers later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Claim one job. SKIP LOCKED means concurrent workers never grab the same row.&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;locked_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'queued'&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The lesson everyone learns the hard way:&lt;/strong&gt; if a job fails &lt;em&gt;after&lt;/em&gt; credits were deducted, refund them. My &lt;code&gt;failed&lt;/code&gt; transition does &lt;code&gt;INSERT INTO credit_ledger (... amount = +credits_cost ...)&lt;/code&gt; in the same transaction as the status update. Users forgive failures; users do not forgive losing credits with no trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: Credits Are an Accounting Problem, Not a Counter
&lt;/h2&gt;

&lt;p&gt;My first draft had &lt;code&gt;users.credits INT&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;an append-only ledger, and the balance is a sum.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;credit_ledger&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;    &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;delta&lt;/span&gt;      &lt;span class="nb"&gt;INT&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- +10 grant, -1 generation, +1 refund&lt;/span&gt;
  &lt;span class="n"&gt;reason&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- signup_bonus | subscription | generation | refund&lt;/span&gt;
  &lt;span class="n"&gt;ref_id&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- idempotency! see Part 3&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every mutation is a row. The &lt;code&gt;ref_id UNIQUE&lt;/code&gt; constraint is the unsung hero: it makes every credit grant &lt;strong&gt;idempotent&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getBalance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InsufficientCreditsError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;creditLedger&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;generation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;creditsCost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Part 3: Stripe Webhooks — Assume Every One Will Arrive Twice
&lt;/h2&gt;

&lt;p&gt;Subscriptions + credits means the money side has exactly one job: &lt;strong&gt;when a payment succeeds, credits appear, exactly once.&lt;/strong&gt; Everything else is negotiable.&lt;/p&gt;

&lt;p&gt;The three webhooks that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;checkout.session.completed&lt;/code&gt; → first purchase or credit pack&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;invoice.paid&lt;/code&gt; → monthly renewal, grant the monthly credits&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;customer.subscription.deleted&lt;/code&gt; → downgrade to free tier&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the rules that saved me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Idempotency: ledger.ref_id = event.id makes replay harmless.&lt;/span&gt;
  &lt;span class="c1"&gt;// The DB unique constraint IS the dedup layer — no Redis needed.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;grantCredits&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;monthlyCredits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscription&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 👈 duplicate delivery → unique violation → ignored&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify signatures.&lt;/strong&gt; An unauthenticated &lt;code&gt;/webhook&lt;/code&gt; endpoint is an open faucet for free credits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant credits only from webhooks, never from the redirect back to your site.&lt;/strong&gt; Users close tabs; Stripe retries don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persist the raw event before processing.&lt;/strong&gt; When (not if) your handler has a bug at 2am, you can replay from your own table instead of begging Stripe support.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Part 4: What It Costs
&lt;/h2&gt;

&lt;p&gt;Real numbers, since every "I built an AI SaaS" post skips them: &lt;br&gt;
| Item | Monthly cost |&lt;br&gt;
|---|---|&lt;br&gt;
| Hosting (web + worker + Postgres) | $[FILL] |&lt;br&gt;
| Image-generation API (per-generation, scales with usage) | ~$[FILL] per 1,000 generations |&lt;br&gt;
| Stripe | 2.9% + $0.30 per transaction |&lt;br&gt;
| &lt;strong&gt;Total fixed&lt;/strong&gt; | &lt;strong&gt;$[FILL]/mo&lt;/strong&gt; |&lt;/p&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;h2&gt;
  
  
  Biggest Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The queue is the product.&lt;/strong&gt; Users judge AI apps by the waiting experience: instant &lt;code&gt;jobId&lt;/code&gt; response, a real progress state, and a page that survives refresh (because it reads state from the DB, not from memory).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat every external call as eventually-failing.&lt;/strong&gt; The AI API &lt;em&gt;will&lt;/em&gt; time out; Stripe &lt;em&gt;will&lt;/em&gt; redeliver. Idempotency keys and refunds aren't nice-to-haves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres was enough.&lt;/strong&gt; 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, &lt;code&gt;SKIP LOCKED&lt;/code&gt;, and an append-only ledger got me to production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSR is not optional for tool sites.&lt;/strong&gt; The majority of signups arrive from search — a client-rendered SPA would have quietly killed half the funnel.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;You can see the whole thing live — upload a photo, watch the queue do its thing, get line art in seconds — at &lt;a href="https://ailineart.com" rel="noopener noreferrer"&gt;ailineart.com&lt;/a&gt;. Free tier includes daily credits, no card required.&lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments about the queue design, Stripe credit grants, or running a lean AI SaaS solo. 👇&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>javascript</category>
      <category>architecture</category>
    </item>
    <item>
      <title>From One Line of Text to a Complete Song</title>
      <dc:creator>Bruce Lee</dc:creator>
      <pubDate>Sun, 12 Jul 2026 14:24:57 +0000</pubDate>
      <link>https://dev.to/bruce_lee_666/from-one-line-of-text-to-a-complete-song-71c</link>
      <guid>https://dev.to/bruce_lee_666/from-one-line-of-text-to-a-complete-song-71c</guid>
      <description>&lt;p&gt;Most side projects begin with a vague idea.&lt;/p&gt;

&lt;p&gt;Music is not very different. You may know the atmosphere you want, but not the chords, melody, arrangement, or even the right words to describe it.&lt;/p&gt;

&lt;p&gt;I wanted to see how far a single sentence could go:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An electronic pop song about driving through an empty city at 2 a.m.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is a mood in that sentence, but not much else. No tempo, song structure, instruments, or lyrics. Traditionally, turning it into a complete track would require several separate decisions and probably a few hours inside music production software.&lt;/p&gt;

&lt;p&gt;Instead, I used it as the starting point for a song on &lt;a href="https://deepsong.ai" rel="noopener noreferrer"&gt;DeepSong.ai&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first attempt
&lt;/h2&gt;

&lt;p&gt;DeepSong offers a simple mode where a song can begin as a plain-language description. I entered the original sentence without adding any technical music terms.&lt;/p&gt;

&lt;p&gt;The result captured the broad idea, but it also exposed everything the prompt had failed to explain.&lt;/p&gt;

&lt;p&gt;“Electronic pop” could mean a bright dance track, a slow synth ballad, or something darker and more cinematic. An “empty city” could feel peaceful, lonely, or threatening. The first generation had to make those choices on its own.&lt;/p&gt;

&lt;p&gt;That was useful. Hearing one interpretation made it much easier to describe the version I actually wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  A better prompt
&lt;/h2&gt;

&lt;p&gt;For the second attempt, I rewrote the prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A restrained electronic pop song about driving alone through an empty city at 2 a.m. Start with soft analog synths and a distant drum machine. Keep the verses quiet and slightly tense, then open into a wide, melodic chorus. Male vocal, medium tempo, reflective rather than sad.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This version contains several kinds of direction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A genre: electronic pop&lt;/li&gt;
&lt;li&gt;A setting: an empty city at night&lt;/li&gt;
&lt;li&gt;An emotional boundary: reflective, not sad&lt;/li&gt;
&lt;li&gt;An arrangement: quiet verses followed by a larger chorus&lt;/li&gt;
&lt;li&gt;Instrument ideas: analog synths and a drum machine&lt;/li&gt;
&lt;li&gt;Vocal and tempo preferences&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The prompt is still readable as normal prose. It does not require a list of production parameters or knowledge of music theory.&lt;/p&gt;

&lt;p&gt;That is one of the more practical things about generating music from language: the interface between the idea and the software is ordinary writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving into custom mode
&lt;/h2&gt;

&lt;p&gt;A description is enough for exploration, but lyrics need more deliberate control.&lt;/p&gt;

&lt;p&gt;DeepSong’s custom mode separates the title, lyrics, musical style, and other choices. I gave the track a working title, &lt;strong&gt;Streetlights After Two&lt;/strong&gt;, and sketched a short structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Verse]
Traffic lights change for nobody
Storefronts sleep behind the glass
Every road looks unfamiliar
Even ones I've driven past

[Chorus]
Streetlights after two
Turning red to green
The whole city disappears
Somewhere in between
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Structure tags such as &lt;code&gt;[Verse]&lt;/code&gt; and &lt;code&gt;[Chorus]&lt;/code&gt; help communicate how the words should be arranged. They also make revision easier. If the chorus feels too crowded, it can be shortened without rewriting the entire prompt.&lt;/p&gt;

&lt;p&gt;This is where the process started to feel less like requesting a finished asset and more like working through a draft.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generation is most useful as a loop
&lt;/h2&gt;

&lt;p&gt;The first custom version was not the final one.&lt;/p&gt;

&lt;p&gt;One line had too many syllables. The transition into the chorus felt abrupt. The vocal delivery was stronger than the quiet mood needed.&lt;/p&gt;

&lt;p&gt;Those were not failures so much as feedback. A written lyric can look fine on a screen and still feel awkward once it is sung.&lt;/p&gt;

&lt;p&gt;The workflow became:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Describe the song.&lt;/li&gt;
&lt;li&gt;Generate a version.&lt;/li&gt;
&lt;li&gt;Listen for one specific problem.&lt;/li&gt;
&lt;li&gt;Change the prompt or lyrics.&lt;/li&gt;
&lt;li&gt;Generate again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Short feedback loops matter in creative work. When producing another draft takes hours, people tend to protect weak ideas because they have already invested time in them. When a draft is quick to test, it becomes easier to discard a line, change direction, or try something strange.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this becomes useful
&lt;/h2&gt;

&lt;p&gt;The obvious application is songwriting, but complete songs are only one part of it.&lt;/p&gt;

&lt;p&gt;A developer building a game can test different musical directions for a scene before the soundtrack is finalized. A YouTube creator can explore background music shaped around the pace and tone of a video. A podcaster can prototype an intro. A designer can give a product demo its own temporary soundtrack.&lt;/p&gt;

&lt;p&gt;It can also help with communication.&lt;/p&gt;

&lt;p&gt;“Make it more cinematic” means different things to different people. Generating a quick reference track gives collaborators something concrete to discuss. They can point to the rhythm, instrumentation, or energy instead of debating adjectives.&lt;/p&gt;

&lt;p&gt;For early prototypes, that can be more valuable than obtaining a perfect track.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping track of the results
&lt;/h2&gt;

&lt;p&gt;Experiments create files quickly, and filenames such as &lt;code&gt;final-version-3-really-final.mp3&lt;/code&gt; are not much of a system.&lt;/p&gt;

&lt;p&gt;DeepSong keeps generated tracks in a personal song library, so versions can be replayed and managed after the initial session. Tracks can also be created privately, while public creations can be shared with the wider community.&lt;/p&gt;

&lt;p&gt;That distinction matters. Some generations are finished pieces; others are sketches, references, or ideas that should remain part of a private creative process.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned from the experiment
&lt;/h2&gt;

&lt;p&gt;The main lesson was not that one sentence can magically become a perfect song.&lt;/p&gt;

&lt;p&gt;It was that one sentence can become something you can react to.&lt;/p&gt;

&lt;p&gt;Before hearing the first generation, “electronic pop in an empty city” seemed specific enough. After listening, it became clear that the idea was missing an emotional direction, arrangement, vocal character, and sense of movement.&lt;/p&gt;

&lt;p&gt;The generated track made the gaps visible.&lt;/p&gt;

&lt;p&gt;That is where tools like DeepSong are most interesting. They shorten the distance between imagining and evaluating. The human part of the process remains: choosing the idea, recognizing what feels wrong, rewriting it, and deciding when the result finally says what it was meant to say.&lt;/p&gt;

&lt;p&gt;The blank page is still there. It just makes sound now.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>music</category>
    </item>
  </channel>
</rss>
