dhtml

LaunchSaaS · a starter written in dhtml

A whole AI product, in one folder

LaunchSaaS is a starter for a small AI product that people want to scroll, sign up for and pay for. Credits, plans, Stripe, two studios, sign-in by link, a control centre for you and a public site around it: all of it is pages, actions and SQL in examples/launchsaas, and all of it is readable.

§ 01 · What is in it

Everything the product needs

  • Credits. A ledger of grants and spends, a balance everywhere, welcome credits, monthly credits per plan, credit packs, and spending that cannot go below zero even when two requests arrive together.
  • Plans you edit. Plans, packs and the price of each tool are rows in the database. The admin changes them at /admin/plans; the pricing page and the checkout follow.
  • Two studios. Text Studio, where Claude writes posts, pages and emails, and Image Studio, where OpenAI draws pictures that are saved on your own server. Each shows its price in credits before you press the button.
  • Sign in with a link or a password. Magic links (one click, 15 minutes, once) next to the usual flow: sign-up, confirmation, forgot and reset.
  • Stripe. Subscriptions for plans and one-time payments for packs, with a signed webhook that grants credits exactly once.
  • A control centre. Overview and credit grants, people (suspend, notes, log), the inbox and its reasons, posts and pages, plans and prices, SEO and the cookie banner, all from /admin.
  • A public site. Landing page with pricing from the database and a FAQ, a blog, documentation with a sidebar, a changelog, a contact form with an inbox, a sitemap and an RSS feed.
  • SEO and consent you set in the admin. Titles, descriptions, canonical links, Open Graph and Twitter tags, JSON-LD, robots.txt; a cookie banner whose text, buttons and analytics choice you edit.
  • Fast pages. Page changes without a reload, with the design in Tailwind 3 compiled to one committed file, so running the app needs no build.

§ 02 · One real action

Spending credits honestly

The Text Studio's action, as it is in the starter. Read it top to bottom: the price comes from the database, the model is asked only if the balance covers it, and the spend is a single guarded INSERT.

launchsaas/server/api/text-generate.dhtmlPOST /api/text-generate
<!-- Text Studio: one action, in the order that keeps the credits honest.
1. The price and the balance are read from the database, never from the form.
2. Only if the balance covers the price is the model asked.
3. The credits are spent in a single guarded INSERT: it adds the row only while the balance still
covers the price, so two requests at once cannot both spend the same credits.
4. The result is kept only if the spend went through. -->
<d:action auth ratelimit="12/minute">
<d:validate field="kind" required max="30" />
<d:validate field="tone" max="20" />
<d:validate field="prompt" required max="1500" />
<d:query as="tool" one>SELECT credits FROM tools WHERE code = 'text' AND active = 1</d:query>
<d:query as="bal" one>SELECT coalesce(sum(delta), 0) AS n FROM credit_ledger WHERE user_id = {{ user.id }}</d:query>
<d:if test="tool">
<d:if test="bal.n >= tool.credits">
<!-- The model sees what was typed in this form and nothing else of the account. -->
<d:ai as="out" max_tokens="1100" fields="title:text, body:text">
<d:system>You are a careful writing assistant. Write exactly what was asked, in plain paragraphs without markdown headings. Give the piece a short title of at most ten words. Do not mention that you are an AI.</d:system>
<d:context>Kind of text: {{ form.kind }}</d:context>
<d:context>Tone of voice: {{ form.tone }}</d:context>
<d:user>{{ form.prompt }}</d:user>
</d:ai>
<d:exec as="spent">
INSERT INTO credit_ledger (user_id, delta, reason, ref)
SELECT {{ user.id }}, 0 - {{ tool.credits }}, 'tool', 'text'
WHERE (SELECT coalesce(sum(delta), 0) FROM credit_ledger WHERE user_id = {{ user.id }}) >= {{ tool.credits }}
</d:exec>
<d:if test="spent.changes == 1">
<d:exec as="made">INSERT INTO generations (user_id, tool, kind, prompt, title, body, credits) VALUES ({{ user.id }}, 'text', {{ form.kind }}, {{ form.prompt }}, {{ out.title }}, {{ out.body }}, {{ tool.credits }})</d:exec>
<d:redirect to="/app/g/{{ made.id }}" flash="Done. {{ tool.credits }} credit(s) used." />
<d:else>
<d:redirect to="/app/billing" flash="You do not have enough credits for that. Nothing was used." />
</d:if>
<d:else>
<d:redirect to="/app/billing" flash="You do not have enough credits for that. Buy a pack or change plan." />
</d:if>
<d:else>
<d:redirect to="/app/text" flash="The Text Studio is switched off for now." />
</d:if>
<d:invalid><d:layout src="app" title="Check the form"><p class="alert-error">{{ errors.kind }}{{ errors.tone }}{{ errors.prompt }}</p><p class="mt-6"><a class="link" href="/app/text">Back to the Text Studio</a></p></d:layout></d:invalid>
</d:action>

§ 03 · Put it online

Download, set keys, upload

Download LaunchSaaS

Make a site on dhtml.net (how) and upload the zip. Without keys the app still runs: the Text Studio answers with sample text, the Image Studio says it is not set up, and checkout shows a message. Nothing is spent.

To go live: set your address in dhtml.config, switch [mail] to SMTP and the AI provider to Anthropic, and put your keys under Secrets on the site's page, named as env:NAME in the config. The README in the folder lists every key, and the few limits worth reading before launch.

MIT-licensed, like the rest of the examples.