Ship Data Apps with AI

🚀 Ship it live·Lesson 14 of 26·~20 min·Beginner

📣 Launch, honestly

Listed, unlisted or private; a front door that answers questions in order; trust pages and real proof; a hub card; and where to post.

You'll leave with

  • A landing page in the proven top-to-bottom order, with only true proof
  • A hub card with a real preview, plus an og:image for shared links
  • A launch plan: unlisted while it matures, then one honest post where its people are

A demo on localhost, your own computer, isn't shipped; a live address someone else can open is, and you have one. But an app nobody opens isn't much better. Here's the honest launch: no growth hacks, no invented numbers. I'm a data person, not a marketer.

Three ways to be live

Every app sits in one of three buckets; the first two differ by one line of HTML, noindex.

What are noindex and a sitemap?

noindex is a meta tag, a line in your page's head (the part of the HTML the browser reads but doesn't show), asking search engines to leave the page out of their results. A sitemap (sitemap.xml) lists the pages you want found; Get found on Google builds one.

  • Listed: linked from your home page and sitemap; search engines may show it.
  • Unlisted: it works, but nothing links to it, and it carries noindex.
  • Private: behind a password.

Shipping isn't promoting. Several of my apps went live on day one and stayed off my home page until polished; I once pulled a live one back off because it wasn't ready for strangers.

To go unlisted, ask Claude Code, the AI assistant in VS Code, your code editor: Make myapp unlisted: a robots noindex meta tag in every page's head, no sitemap entry, and "Launch day: delete noindex" in NOTES.md, my notes file. Deploy, then show me the tag on the live page.

What's happening underneath: one line, copied up like any update. Check it in Git Bash or a Mac terminal, with your app's address: curl fetches the page, grep keeps lines containing a word. (PowerShell: curl.exe, then Select-String noindex.)

curl -s https://myapp.yourdomain.com/ | grep -i noindex
<meta name="robots" content="noindex">

⚠️ Don't also block the app in robots.txt (the file telling crawlers what to skip): search engines must fetch the page to see the tag. And delete the tag on launch day, or the app stays invisible to Google forever.

Unlisted isn't secret, though. Your subdomain's HTTPS certificate goes into public Certificate Transparency logs, which bots read: type your domain into crt.sh to see every name you've given a padlock. Truly private needs a password, which your front door, Caddy, adds with basic_auth (see Keep the server boring).

A front door that answers questions in order

What are a landing page and a call to action?

A landing page is the page strangers arrive on, written to get them to try your app. A call to action is the one thing you want them to do next, usually a big button.

Grease, Handstand and this course's front page use the landing-page order popularized by indie maker Marc Lou. Each part answers a stranger's next question:

  1. Hero (the first screen): a bold headline saying what it is and for whom, plus the main button. What is it?
  2. Problem vs solution (❌ the usual way, ✅ yours). Is it for me?
  3. Three simple steps. How does it work?
  4. Benefits. What do I get?
  5. Proof: real screenshots, live links. Can I trust it?
  6. Pricing, or a plain "free" card. What does it cost?
  7. FAQ: the real doubts.
  8. Final call to action with a short founder note, then a footer linking your other work.

Use one call to action: the same words on every main button.

Recolor a proven page

📍 Handstand is Grease in teal: same page and setup, the orange accent (brand color) swapped for teal plus two tints; only the training logic was new. Its effort slider still runs green to orange, easy to hard: colors that carry meaning don't change with the brand.

Ask Claude: Copy myapp-one to myapp-two, rename it, swap my accent color everywhere (icons and manifest too, warning colors excepted), then list any leftover old name or color. Underneath, it's a copy, a find-and-replace and a search; a missed manifest, the file naming an installed app, shows the old name on phones.

Paint the main button yourself

What are Tailwind, DaisyUI and a CDN theme?

Ready-made styling, each loaded with one line from a CDN (a public server for open-source files). DaisyUI's theme, not you, picks the colors of classes like btn-primary.

A theme button can come out grey or off-brand, and DaisyUI's hover rule repaints anything with its btn class. Traqqit Max's main buttons skip the theme: their own CSS class (a named style) spells out color and hover.

.cta { display: inline-flex; align-items: center; padding: .9rem 1.8rem; border-radius: 9999px;
       background: #7c3aed; color: #fff; font-weight: 800; }
.cta:hover { background: #6d28d9; }

Use class="cta" on every main button, with your colors, and check light and dark mode.

Trust before traffic

Even an app without logins handles data. Write a short privacy.html before strangers arrive: what you collect, when, where it goes, every outside service, how long you keep it, and a contact address.

📍 Habooby, my Phoenix dust map, has no accounts, yet its privacy page says plenty: location is used only when you tap, after you allow it; it goes from your device to the weather and geocoding (location-to-address) services it names, a ZIP code via the app's own server; neither is stored. When I got Habooby ready for Google Play, its data-safety answers came from that page.

Never invent testimonials, user counts, star ratings or revenue: it's deceptive, illegal in many places, and trust never comes back. Show what's real: screenshots, live links, a note in your own voice. Label mockups (made-up pictures of how something would look) "Example", like every chat in this course. Delete leftover TODO notes and empty sections: even an honest "[PLACEHOLDER]" testimonial looks unfinished.

Decide what never goes online, such as your home address, phone and personal email (a business address and email go on the page instead), put that list in your prompts, and read every line Claude writes: AI copies details from source documents. My personal site was built from my resume: the phone number stayed off, the contact button got a business email, and an entry whose dates I couldn't verify lost them.

Before every launch, scan your app's folder:

grep -rnE 'TODO|PLACEHOLDER|Lorem ipsum' --include='*.html' .
grep -rnoE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\(?[0-9]{3}\)?[-. ][0-9]{3}[-. ][0-9]{4}|tel:[^"]*' --include='*.html' .

The first should print nothing; the second lists every email address, phone-like number and tel: link, each one you meant to publish.

A hub page, once you have two apps

One app doesn't need a hub. From the second, give each a card on your own hub page at yourdomain.com and link every app's footer back to it, so each launch lifts the others. Mine is traqqit.com. It deploys like any app; the bare domain's A record is named @ and replaces any placeholder record from your registrar (where you bought the domain).

A card holds a 1280×800 screenshot (taken by a headless browser, a real Chrome or Edge with no window, so all cards match), the linked name, a one-line pitch, a tech line and a live badge. Each app's own page also gets a 1200×630 og:image for pasted links; not all of mine have one yet.

What's og:image?

An Open Graph tag: a line in a page's head that chat apps and social sites read to draw a pasted link's preview. It names the picture.

📍 Making the preview is a test, too: on Acento's launch day, taking its screenshot caught a load bug that would have broken the app.

Save previews as WebP (much smaller than PNG) at 1280 and 720 wide; srcset sends phones the small one. Show real status, not invented stats: each card pings (sends a tiny request to) its app from the visitor's browser.

What are a request, GET, HEAD and a 405?

A request is one ask from a browser to a server: GET asks for a file, HEAD only for its headers (the short labels sent first). 405 means "I don't accept that kind of request".

<article class="card">
  <img src="/preview-myapp.webp" srcset="/preview-myapp-720.webp 720w, /preview-myapp.webp 1280w"
       sizes="(max-width: 700px) 100vw, 50vw" width="1280" height="800" loading="lazy"
       alt="My App: a map of WHAT, colored by WHAT">
  <h3><a href="https://myapp.yourdomain.com">My App</a> <span class="status">checking…</span></h3>
  <p>ONE-LINE PITCH: what it does, for whom.</p>
  <p class="tech">Static · Leaflet · open data</p>
</article>
<script>
document.querySelectorAll('.card').forEach(card => {
  const a = card.querySelector('h3 a'), badge = card.querySelector('.status');
  fetch(a.dataset.ping || a.href, { method: a.dataset.ping ? 'GET' : 'HEAD', mode: 'no-cors',
                                    cache: 'no-store', signal: AbortSignal.timeout(8000) })
    .then(() => { badge.textContent = 'live'; }, () => { badge.textContent = 'down'; });
});
</script>

Replace the capitals, names and addresses. With mode: 'no-cors' the page can't read another site's reply, only whether any reply came back, so live means reachable, not working. My price tool runs on FastAPI, a Python web toolkit that answers HEAD with 405 even when the app is fine, so its card fetches a small file the app serves, with GET: for an app like that, add data-ping="https://myapi.yourdomain.com/robots.txt" to its link. Test once: a card aimed at a made-up subdomain should say down.

⚠️ Keep cards current. When Acento's accent mode launched, its card still said "no backend", though it had accounts by then, and the new mode had no marketing copy. Updating the card is part of every feature launch.

Timing, and where to post

Let the app mature live but unlisted, with analytics from Know if it's working building a baseline (numbers to compare against). Before announcing, open the default view as a stranger would, with today's data; if a quiet season makes it look dead, sort the best available items first. Launch when people need it.

📍 My river-flow tracker, a side project, is live and unlinked. An off-season count found most runs dry or low, but runnable ones coast to coast, sorted best first, so it didn't look dead. Its launch waits for the weeks its users enter permit lotteries.

Launch with one honest post where the app's people already are:

  • Local: your city's subreddit (a Reddit topic forum) or neighborhood groups.
  • Hobby or niche: its forum, after reading the self-promotion rules.
  • Parents, patients, learners: the groups they ask in, as a member, not an ad.
  • Builders: "Show HN" on Hacker News (a programmers' news site, for things people can try), Product Hunt (a daily list of new products), X or Bluesky.
I made a free [what it is] for [who] in [where].
It lets you [the one thing it does], using [data source], updated [how often].
[link]
It's a side project, so tell me what's wrong or missing.

Write it in their spelling. Baby Signs, for American parents, came out saying nappy, mum, practise, favourite and £, and got one wholesale American pass; page-by-page fixes leave a page British. Ask Claude: Convert every British spelling, word and £ on every page to US English in one pass; add "US spelling" under Decisions in NOTES.md.

A free resource that genuinely helps is a soft funnel, leading people to your other work without a pitch. This course is free, points to the apps it teaches from, and every main button on its front page leads to the same place.

🦥 Skipped for now: a mailing list (Build an email list), search-engine work (Get found on Google) and paid ads. One honest post comes first.

Give people something to share

A link for every view. Build static-first put the view in the address's hash (after #); a copy button makes it sendable, and the copied link should open the same view in a new tab. Ticket Heat's links carry the category, year, day, hour and map position; Who Owns Phoenix's go to an owner, a ZIP code or a block.

<button id="copy-link">Copy link to this view</button>
<script>
const copyBtn = document.getElementById('copy-link');
copyBtn.onclick = async () => {
  await navigator.clipboard.writeText(location.href);
  copyBtn.textContent = 'Copied';
};
</script>

Outbound links in one place. Build links into other sites in one function, with any referral code (credit for visitors you send) in one constant, disclosed on the page. Test them in a real browser, phone included: on a private dashboard of mine, a partner site kept an option selected only if the id kept its exact capitals, and answered curl with 429, "too many requests".

What's a canvas?

An HTML element your JavaScript can draw shapes, text and colors on, then save as a PNG image.

A picture worth sending. Habooby's location report has a download button that draws a branded PNG from the reading; First Bites replays its feeding log into milestones and draws a 1080×1080 card for the phone's Share menu. Draw only your own shapes and text: another site's image taints the canvas and blocks the export. Ask Claude: Add a Share button that draws a 1080×1080 PNG of my data, my domain at the bottom; use the Share menu where possible, else download; ignore AbortError (menu closed). On your phone, it should offer a picture with your numbers.

A reason to tell a friend. A tool that might save money gets passed around. Who Owns Phoenix's property-tax appeal check compares your assessment with similar nearby homes, flags a high one and points to the official appeal steps, as general information, not tax or legal advice. Its first version used years-old sales, flagging ordinary price growth; now it uses recent ones.

Watch what happens next. On a private build of my price tool, staff found a price, then opened a maps app to send a patient there, so it gained a referral finder: tap-to-call, directions, copy, print. Ask early users what they do right after.

Do it with Claude: the prompts

Open Claude Code in your app's folder; three prompts do most of the work.

1

Write the front door

Collect what's true first: two or three real screenshots in shots/, and a few sentences about you, in your voice.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouWrite landing.html for myapp in this order: hero, problem vs solution, 3 steps, benefits, proof, FAQ, final call to action. Use only my real screenshots in shots/ and this founder note: [paste it]. One button text everywhere, class cta. Never invent quotes, numbers or ratings; leave out any section with nothing true to say, and tell me.
ClaudeWrote landing.html: 7 sections in your order, your 3 screenshots, your note word for word. All 3 buttons say "Open the map". No pricing (it's free) or testimonials (you have none). Read it at http://localhost:3000/landing.html.↳ Read · NOTES.md, shots/↳ Write · landing.html↳ Bash · grep -o 'class="cta"[^<]*' landing.html

What's happening underneath: Claude builds one page from your files. AIs like padding proof sections with plausible quotes and round numbers; the "never invent" line and your read stop it. The address works while npx serve, your local preview server from Build static-first, runs.

Check it yourself: read every claim aloud ("could a stranger check this?") and run the two scans. Then have Claude commit (your undo point), make it your index.html, move the app to /app/ as on Baby Signs and fix every path it loads. Deploy and open both addresses on your phone: the app should still load its data.

2

Take the pictures

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouScreenshot the live app at 1280x800 for the hub card and 1200x630 for og:image. Save the preview as preview-myapp.webp (1280 wide) and preview-myapp-720.webp (720 wide). Add a meta description, og:title, og:description, og:image (full https address) and twitter:card to index.html's head.
ClaudeSaved preview-myapp.png (1280×800), og.png (1200×630), preview-myapp.webp and preview-myapp-720.webp. I opened both PNGs: the page had fully loaded. The head has the five tags; og:image is https://myapp.yourdomain.com/og.png.↳ Bash · msedge --headless=new --screenshot ×2↳ Read · preview-myapp.png, og.png↳ Bash · convert to WebP ×2↳ Edit · index.html

What's happening underneath: a headless browser opens the live site at an exact size and saves what it sees. By hand:

& "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --headless=new --hide-scrollbars --user-data-dir="$env:TEMP\shot-profile" --window-size=1280,800 --screenshot="$PWD\preview-myapp.png" https://myapp.yourdomain.com
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --hide-scrollbars --user-data-dir=/tmp/shot-profile --window-size=1280,800 --screenshot="$PWD/preview-myapp.png" https://myapp.yourdomain.com

Replace the address with your app's. Seconds later it prints a line ending in bytes written to file, often among harmless ERROR lines; repeat with 1200,630 and og.png. A WebGL map (drawn by the graphics chip) often comes out black, so capture it in Chrome or Edge: F12 (Mac: Cmd+Option+I) opens DevTools, the browser's inspector, and Ctrl+Shift+M (Mac: Cmd+Shift+M) its device toolbar; type 1280 and 800 in its size boxes and choose Capture screenshot from its ⋮ menu. Safari and phones can't.

<meta name="description" content="A free map of WHAT for WHO, updated daily.">
<meta property="og:title" content="My App: WHAT IT DOES">
<meta property="og:description" content="A free map of WHAT for WHO, updated daily.">
<meta property="og:image" content="https://myapp.yourdomain.com/og.png">
<meta name="twitter:card" content="summary_large_image">

Replace the capitals and the address. The description and og:image are what search engines and chat apps show for a pasted link (twitter:card asks X for the large picture), and noindex is one meta tag like these. Upload og.png beside the page and the WebP files to your hub. Check: curl -sI https://myapp.yourdomain.com/og.png (PowerShell: curl.exe) shows HTTP/2 200 and content-type: image/png, and your link pasted into a chat with yourself shows the picture (if an old preview sticks, add ?v=2).

3

Write the privacy page

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouDraft privacy.html from what the app actually collects and every outside service it calls. First list every request to another domain, everything saved in localStorage, and the analytics snippet. Then write it (what, when, where it goes, how long it's kept, no accounts, contact support@yourdomain.com) and link it from the footer.
ClaudeThe list: 3 outside services (Open-Meteo for weather, OpenStreetMap's geocoder, your analytics at stats.yourdomain.com), 2 localStorage keys (myapp.v1, theme), no accounts. privacy.html covers each, dated; the footer links it.↳ Grep · fetch(|localStorage|<script src↳ Write · privacy.html↳ Edit · index.html

What's happening underneath: Claude searches your code for requests and for what's saved in localStorage, the browser's storage for your app. A text search can miss a call, so check in the browser: open the live app, then DevTools (F12; Mac: Cmd+Option+I) and its Network tab. Reload, use the app for a minute, and type -domain:myapp.yourdomain.com into the Filter box: every row left is another site, and each must be named on the page.

Your turn

  • Each app's bucket is in NOTES.md; for an unlisted one, the curl check prints the noindex tag and "Launch day: delete noindex" is listed.
  • Your landing page is live in the order above (pricing only if you charge), with one button text, and both scans print nothing you didn't mean to publish.
  • privacy.html is linked from your footer and names every other site the Network tab showed.
  • curl -sI on your og.png answers 200, and your link pasted into a chat shows the picture.
  • Your card is ready: the 1280×800 WebP preview, its 720-wide copy, a pitch and a tech line; with two apps, it's on your hub page, badge saying live.
  • NOTES.md holds your launch post, in your audience's spelling, plus where and when you'll post it, by their calendar.

Next, Add a backend only when forced: the few things a browser can't do, and the smallest server piece for each.