zoe@dev:~$PID 1337
zoe@dev:~$ cat ~/blog/shipping-an-ai-tool.mdx

Shipping an AI Tool: Everything That Broke

2026-07-20·Zoe Lin·5 min read·#nextjs#vercel#seo#security#postmortem
EN中文日本語한국어EspañolFrançaisDeutschPortuguês

Building ~/tools/paper-revision was the easy part. Getting it to behave the same in production as it did on localhost took a lot longer, and most of the bugs weren't in the AI part at all — they were in the plumbing around it. Here's what actually broke.

The silent 502: SSE connections need a heartbeat

Long reviews using a BYOK Anthropic key would occasionally fail after ~100 seconds — no error message, just a dead connection. Vercel's logs showed a clean 502 with nothing pointing at the actual cause.

The real problem: a model can go quiet for a while mid-generation (a long internal "thinking" stretch before the first visible token), and if the response stream sends no bytes for too long, some intermediary between the client and the model decides the connection is idle and kills it. The fix was a heartbeat — an invisible character written into the stream on a fixed interval so bytes never stop flowing:

providers.ts
const HEARTBEAT_MS = 15_000
const HEARTBEAT_CHAR = '' // zero-width space, invisible in rendered markdown
 
const result = await Promise.race([pending, heartbeat])
if (result === 'heartbeat') {
  controller.enqueue(encoder.encode(HEARTBEAT_CHAR))
  continue
}

Chasing this down also surfaced a worse bug hiding behind it: the abort handler was catching every exception named AbortError and treating it as a deliberate user cancel — including ones that came from the network dying on its own. Real failures were being silently swallowed. Fixing that meant tracking whether the user had actually clicked "abort" before trusting the error's name.

Bring-your-own-key, without the footguns

The free tier runs on a hosted model with a rate limit; BYOK users pay with their own key and skip it entirely — except the first version didn't skip it, and BYOK requests were burning through the free-tier quota too. An honest bug report ("but I used my own key and still got rate limited") is how that one surfaced.

The other BYOK lesson was proactive rather than reactive: cap the model allowlist to reasonable price tiers so a request can't accidentally land on the most expensive model available, and cap output tokens regardless of provider. None of that stops a user from spending their own money badly, but it stops our code from being the reason they do.

A favicon Google Search refused to show

The site's favicon rendered fine in every browser tab and just never showed up next to the URL in Google Search results. The cause was almost embarrassingly indirect: Next.js's generic icon convention appends a content hash to the file's URL — /icon.png?d72260d... — and that hash changes every time the file's bytes change. Google's own guidelines are explicit about this exact failure mode: "the favicon URL must be stable — don't change the URL frequently." Every edit to that icon was quietly resetting whatever trust Google had built up in the old URL.

Next.js has a special case for exactly one filename — favicon.ico skips the content hash entirely and gets a permanently stable URL. Wrapping the same artwork in a minimal ICO container and switching to that filename was the whole fix. Worth checking the domain setup at the same time: Google treats different hostnames as entirely separate "sites" for favicon purposes, so www. and the default *.vercel.app domain both got permanent redirects to the one canonical hostname.

Cloudflare Turnstile only renders once

Turnstile's implicit rendering mode scans the page for .cf-turnstile elements exactly once, when its script first loads. That's invisible on a traditional multi-page site where every navigation reloads the script fresh — but this is an SPA, and the review form's state deliberately survives client-side navigation so an in-flight review doesn't get abandoned. Navigate away and back, and the widget's mount point is a brand-new DOM node the already-loaded script never re-scans. The token silently becomes undefined, which — after adding server-side verification to the sharing endpoint — meant every share attempt after a nav-away-and-back failed with no visible explanation.

The fix is Turnstile's explicit rendering API: load the script once, then call turnstile.render() yourself on every mount and turnstile.remove() on every unmount, instead of relying on its one-time automatic scan.

Windows dev, Linux prod

Two separate bugs, same root cause: developing on Windows and deploying to Vercel's Linux runtime aren't the same environment, and PDF/image libraries are exactly where that gap shows up. pdf-parse crashed with a DOMMatrix error in production that never reproduced locally — switching to a serverless-safe PDF library (unpdf) fixed it outright. Separately, next/og's image generation crashed on font loading, traced to a Windows file path containing spaces that the library's URL parsing choked on. Neither bug existed as far as local npm run dev was concerned; both were 100% reproducible on every single deploy.

What a security review actually catches

A full pass over the codebase turned up a handful of real, if quiet, issues: a snapshot-sharing endpoint with no bot verification at all — technically open for anyone to publish arbitrary text to a public URL; a rate limit charged before checking whether the uploaded file could even be parsed, so a malformed upload burned a user's quota for a review that was never going to happen; a zip handler that decompressed an archive entry before checking its declared size, which is exactly the shape of a zip-bomb vulnerability; and a share button that silently created a duplicate snapshot if you navigated away and clicked it again, because the "do we already have a link" state lived in a component that had just been thrown away and recreated.

None of these were exotic. They were the kind of bug that only shows up when someone actually reads the code looking for what could go wrong, rather than just checking that the happy path works.

Closing thought

Every bug on this list shipped, worked, and looked done — right up until a specific input, a specific navigation, or a specific hosting environment proved otherwise. Boring infrastructure, checked carefully, beats clever infrastructure that's never been tried against production. exit 0.

cd ~/blog ←