{"slug": "building-launchpad-one-product-brief-42-launch-channels-and-the-bugs-that-almost", "title": "Building LaunchPad: one product brief, 42 launch channels, and the bugs that almost sank it", "summary": "A developer built LaunchPad, a tool that drafts native posts for 42 launch channels from a single product brief, using on-device AI via Chrome's Gemini Nano or the user's own API keys. The project faced bugs including TipTap v3's update events overwriting generated content and race conditions from server responses replacing local state, solved with mutation refs and version counters. Character limit enforcement required corrective retries as small language models cannot reliably count characters.", "body_md": "Launching a product is a translation problem. Hacker News punishes anything that smells like marketing. X gives you 280 characters. The Play Store gives you 30 for a title and 80 for the short description, and the Chrome Web Store will reject a keyword-stuffed name outright. Same product, fifteen dialects.\n\nI kept paying that tax on my own extension launches, so I built ** LaunchPad**: you write one product brief, and it drafts a native post for 42 channels — each in that channel's voice, with its real character limits enforced at generation time. It launched on Product Hunt this weekend. This post is about the parts that were harder than they looked.\n\nReact 18 + Vite on AWS Amplify, Lambda + DynamoDB + API Gateway behind Cognito, Terraform for everything. The interesting choice is the AI layer: there isn't a server-side one. Drafting runs either on Chrome's built-in Gemini Nano (the Prompt API — free, on-device, nothing leaves your machine) or against the user's own OpenAI/Claude key, called directly from the browser. My servers never see a prompt, a draft, or a key. That's a privacy feature and a business model at once: my marginal cost per free user is roughly zero, so the entire product can be free for your first launch.\n\nThe editor is TipTap (ProseMirror). During generation, I lock each section by toggling `setEditable(false)`\n\n. Drafts started vanishing: the AI would finish, the success toast would fire, and the editor would sit there empty.\n\nThe cause took a full evening: **TipTap v3 emits update events for programmatic changes too.**\n\n`setEditable()`\n\nand `setContent()`\n\nboth fire the same `onUpdate`\n\nas a keystroke. So the lock itself triggered `onChange('')`\n\n— an empty \"manual edit\" that overwrote the freshly generated draft in the store, stamped The fix is a ref that marks self-inflicted mutations:\n\n``` js\nconst applyingExternal = useRef(false);\n\nonUpdate: ({ editor }) => {\n  if (applyingExternal.current) return; // our own mutation, not the user's\n  onChange(htmlToMd(editor.getHTML()));\n},\n\nuseEffect(() => {\n  applyingExternal.current = true;\n  editor.setEditable(!readOnly);\n  applyingExternal.current = false;\n}, [readOnly]);\n```\n\nThe general lesson: any rich-text editor's change event answers \"did the document change?\", not \"did the **user** change it?\". If you sync editor state to a store, you need to answer the second question yourself.\n\nTwice — once for projects, once for preferences — I hit the same shape of bug: a GET fires on mount, the user makes a local change, the GET response lands **after** the change and replaces state wholesale. The user's brand-new custom channel just… evaporates. It surfaced as a flaky e2e test that only failed in-suite, which I almost retry-looped away before realizing the flake **was** the bug.\n\nThe guard is a version counter, not a lock:\n\n``` js\nloadPrefs: async () => {\n  const ver = get()._prefsVer;\n  const prefs = await api.getPrefs();\n  if (get()._prefsVer !== ver) return; // a local write won the race — discard\n  set((s) => ({ prefs: { ...s.prefs, ...prefs } }));\n},\nupdatePrefs: (fields) => {\n  set((s) => ({ _prefsVer: s._prefsVer + 1, prefs: { ...s.prefs, ...fields } }));\n},\n```\n\nIf your local state is the source of truth (it should be), a server response is a **suggestion** that expires the moment the user acts.\n\nEvery channel section carries its real limit — HN titles ≤80, Play Store short description ≤80, App Store keywords ≤100. Small on-device models blow past them constantly, and no prompt phrasing fixes it, because counting characters just isn't something a language model does reliably.\n\nWhat shipped: the limit goes into the prompt (\"HARD LIMIT … counting characters, not words — drop hashtags first\"), the output gets measured in actual code, and there are up to two corrective retries that feed the overlong draft back with its measured length. Keep the shortest attempt. If it's **still** over, say so honestly — \"Title is still over its limit, trim it by hand\" — and paint the counter red. Users forgive a model that can't count; they don't forgive a tool that pretends it can.\n\n\"Generate all 42\" sounds like a for-loop. The for-loop version fails four ways:\n\n`28 drafted · 1 failed (rate limit) · 2 skipped`\n\n— and a failed channel keeps its previous draft None of these showed up until a real user ran a real 42-channel generation with a real flaky key.\n\nThe webhook from my payment provider (Lemon Squeezy) identifies the buyer by email, so I resolved email → user and upgraded them. Tested it, worked, shipped it.\n\nThen my own test payment upgraded… nothing I could see. The reason: **one email can be several accounts.** My address existed twice in Cognito — a Google-federated identity (my real login) and a native password user (the e2e test account). The `Limit: 1`\n\nlookup upgraded whichever DynamoDB returned first, which was the wrong one. Any real user who'd ever signed in two ways would have paid and stayed on the free plan.\n\nThe fix is embarrassingly simple — update **every** account matching the payer's email — but the class of bug is worth internalizing: email is contact info, not a primary key.\n\nWhile I was in there: `subscription_cancelled`\n\nmeans **will not renew**, not **expired**. Downgrading on cancel steals the month the customer already paid for. Keep the tier until the `expired`\n\nevent arrives.\n\n`overflow-x: hidden`\n\non `html, body`\n\n— added innocently for mobile sideways-scroll protection — **silently disables position: sticky** everywhere. The spec-compliant fix is\n\n`overflow-x: clip`\n\n, which clips without creating a scroll container. My sticky top bar was broken for two deploys before anyone noticed.And `backdrop-filter`\n\non that same top bar creates a **containing block**: any `position: fixed`\n\nchild gets trapped inside the bar's box. My ⌘K command palette rendered as a black strip inside the header and swallowed outside clicks until I portaled it to `document.body`\n\n.\n\nThe thing I'd defend hardest isn't a feature. It's the 21-test Playwright suite that runs **against production** — on every deploy as a release gate, and nightly on cron. Every bug a user reported this month became a permanent sentinel: security headers served, deep links returning real 200s (Amplify's SPA fallback quietly serves HTTP 404 on every route unless you add an explicit rewrite — renders fine, poisons link unfurlers), zero horizontal overflow at 390px, the sticky bar surviving a scroll, and the full OpenAI/Claude pipeline exercised with the provider APIs intercepted at the network layer — asserting auth headers, model, and body shape with zero API spend.\n\nA UI regression suite against the live site feels like overkill until the first time it catches a stripped stylesheet before a user does. Mine paid for itself in week one.\n\nLaunchPad is live at ** launch.tapdot.org** — there's a\n\nI'd genuinely like feedback on the channel prompts — each of the 42 has a hand-tuned \"native voice\" instruction, and the difference between a Show HN that survives and one that sinks lives in those words. If you launch somewhere I don't cover, tell me and it'll probably exist by next week: most of this product was built from user requests within hours of hearing them.\n\nHappy to answer anything about the Prompt API, the BYO-key architecture, or the regression setup in the comments.", "url": "https://wpnews.pro/news/building-launchpad-one-product-brief-42-launch-channels-and-the-bugs-that-almost", "canonical_source": "https://dev.to/mohanvenkatakrishnan/building-launchpad-one-product-brief-42-launch-channels-and-the-bugs-that-almost-sank-it-3ii7", "published_at": "2026-07-11 15:01:25+00:00", "updated_at": "2026-07-11 15:44:47.731414+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "large-language-models", "ai-tools"], "entities": ["LaunchPad", "TipTap", "ProseMirror", "Gemini Nano", "AWS Amplify", "Lambda", "DynamoDB", "Cognito"], "alternates": {"html": "https://wpnews.pro/news/building-launchpad-one-product-brief-42-launch-channels-and-the-bugs-that-almost", "markdown": "https://wpnews.pro/news/building-launchpad-one-product-brief-42-launch-channels-and-the-bugs-that-almost.md", "text": "https://wpnews.pro/news/building-launchpad-one-product-brief-42-launch-channels-and-the-bugs-that-almost.txt", "jsonld": "https://wpnews.pro/news/building-launchpad-one-product-brief-42-launch-channels-and-the-bugs-that-almost.jsonld"}}