Building a site that answer engines can actually read

Rebuilding useion.ai as a static Astro site: what changed, why every page ships as real HTML, and the difference between writing for search engines and writing for the things that answer questions about you.

A browser window showing a static useion.ai blog page, overlapped by an assistant's answer to "What is Ion?" that quotes a sentence and cites useion.ai.

The Ion site started as one hand-written index.html: a logo, a headline, an email box, and a canvas animation behind it all. That is a perfectly good landing page. It is a terrible thing to try to rank, and a worse thing to try to get quoted.

This post is what I changed and why, mostly so future-me remembers the reasoning when the temptation to add a client-side router arrives.

The problem with a one-page splash

A splash page fails on two fronts at once.

The search engine problem is boring and well understood: one URL, one heading, no internal links, no feed, nothing to index beyond a tagline. There is nothing there to match a query against.

The second problem is newer. Increasingly the thing describing your product to someone is not a search result — it is an assistant answering “what is Ion?” in prose. That system needs a sentence it can lift, attributed to a URL it can cite. A hero image with the tagline burned into it gives it nothing. Neither does a paragraph that only makes sense after reading the two before it.

So the rebuild had one rule: every claim the site makes should be a complete sentence, in the HTML, on a URL.

Why Astro

The stack had to satisfy three things that pull in different directions.

Requirement Why it constrains the choice
Real HTML for every page Rules out a client-rendered SPA
A blog with Markdown files Rules out hand-writing pages
Room for Vue later Rules out a pure static-site generator with no component story

Astro does all three. Pages are pre-rendered to static HTML at build time, so a crawler — or a model with a fetch tool and no JavaScript runtime — sees the finished document. Content collections turn a folder of Markdown into typed, validated entries. And when the blog eventually needs an editor UI backed by Mongo, Vue components drop in as islands without converting the rest of the site into an app.

The signup form is already one of those islands. It hydrates after the browser is idle, and everything around it is inert HTML.

Markdown files go through the Astro build into static HTML for every page, which a crawler or model reads without a JavaScript runtime; on the page, only the signup form is an island that hydrates when the browser is idle

What “AEO” actually means in files

“Answer engine optimisation” gets used loosely. Concretely, here is what it turned into on this site.

A FAQ that is content, not decoration. Seven questions, each with a self-contained answer, rendered as visible text and emitted as a schema.org/FAQPage node. The answers are written to survive being quoted with no surrounding context. Where the honest answer is “not announced yet”, that is what it says — a made-up launch date is a claim that gets repeated back at you.

Structured data as one graph, not scattered tags. Every page emits a single @graph containing Organization, WebSite, and the page node, all with stable @ids. That is what lets a crawler merge every URL into one entity instead of seeing a new anonymous publisher each time.

/llms.txt and /llms-full.txt. The first is a Markdown map of the site; the second inlines every post body. Both are generated from the same content collection as the HTML, so they cannot drift.

A robots.txt that lets the AI crawlers in. This one is a judgement call rather than a technique. Blocking GPTBot and ClaudeBot does not remove you from anything already trained; it removes you from answers being composed today. For a product nobody has heard of yet, that trade is not close.

What answer engine optimisation became in files: a single JSON-LD @graph with Organization, WebSite and page nodes with stable @ids; a seven-question FAQ as visible text and schema.org/FAQPage; /llms.txt and /llms-full.txt generated from the same collection; and a robots.txt that allows GPTBot and ClaudeBot

The unglamorous half

Most of the actual work was not any of the above.

  • Fonts are self-hosted at build time instead of fetched from a third-party stylesheet on the critical path.
  • The two-glyph icon font is gone, replaced by inline SVG.
  • The theme is applied by a tiny blocking script before first paint, so choosing dark does not mean watching a cream flash on every navigation.
  • The logo swaps by CSS rather than by rewriting img.src after load.
  • The background animation stops when the tab is hidden, and never starts at all under prefers-reduced-motion.

None of that shows up in a screenshot. All of it shows up in Core Web Vitals, and Core Web Vitals are the part of ranking you can actually control by editing files.

One real bug, found on the way

The old script picked its API origin at runtime:

if (host === 'www.useion.ai') {
  return 'https://api.useion.ai';
}
return 'https://nataly-unsneering-pinnately.ngrok-free.dev';

www.useion.ai does not resolve. The site is served from the apex domain, so the condition never matched, and every production signup was being posted to a personal ngrok tunnel. It is now resolved at build time from an environment variable, with the dev build pointing at the local FastAPI backend through the dev server proxy.

Before: the runtime check host === ‘www.useion.ai’ never matched because www does not resolve and the site is on the apex domain, so signups went to a personal ngrok tunnel. Now: the production build takes the API origin from an environment variable and the dev build uses the local FastAPI backend through the dev server proxy

That is the recurring shape of this kind of work: you sit down to improve how a page is indexed, and half of what you find is something that was quietly broken in a way nobody was going to notice from the outside.

What is next

The backend is running but idle — FastAPI and Mongo, wired up and serving the signup endpoints, waiting for the blog to need an editor rather than a folder of Markdown files. When that happens the posts move into Mongo and this page keeps its URL.

Until then: files in a folder, one build command, static HTML out the other end.

Get early access.

Invite-only, released in small waves. Free during early access.

Get early access.

Invite-only, released in small waves. Free during early access.