A static Astro site built around one question — what does a page need to look like for a language model to quote it accurately? Separating the parts that definitely work from the parts that are still a bet.
I built a website for a self-published book series recently. Small niche, unknown author, no existing audience, no backlinks, no budget. The kind of site that would normally take two years to rank for anything and might never rank at all.
So I built it for a different surface. Not "rank #4 for a keyword", but "be the thing ChatGPT quotes when someone asks the question".
This post is the whole approach: 126 pages, what I actually shipped, and — more useful — an honest split between the parts I know work and the parts that are still an educated guess. I have no results yet. The site is days old, not months, and Bing currently lists it as "discovered but not crawled" — exactly what you would expect for a domain with zero inbound links. So treat this as a build log, not a case study.
I would rather post the method now and be told what I got wrong than post a victory lap in six months.
The premise
Search and LLM retrieval reward different things.
A search engine ranks documents. It wants to hand you ten links and let you decide. Authority, links and freshness dominate, which is why a new domain is stuck: the signals it lacks are the signals that matter most.
A language model answering a question is doing something closer to extraction. It needs a passage that answers the question cleanly, that it can lift without mangling, and that it can attribute to an entity it recognises. Authority still matters — but extractability is a real, separate axis, and it's one you can control completely on day one.
That's the bet. Not that structure beats authority. That structure is the part a new site can actually compete on.
Stack, briefly
Astro in static mode, deployed to Cloudflare Workers static assets. Content in markdown via Astro's content collections, with typed frontmatter schemas.
126 pages, all pre-rendered at build time. Exactly one dynamic endpoint (/api/geo, which preselects an Amazon storefront). Everything else is a file on Cloudflare's edge.
The content splits into:
- 42 Learn articles across four topic pillars
- 60 glossary entries, one term each
- 5 FAQ pages
- 19 hub, book and utility pages
Total JavaScript on a content page: near zero. The navigation menu, the FAQ accordions and the glossary search are all built on <details>/<summary> or degrade to plain visible content. This matters more than it sounds — see the "boring things" section.
Part 1: the boring things that definitely work
Nothing here is novel. It's all just done properly, which is the entire point.
Server-rendered HTML
Some crawlers execute JavaScript. Many don't, or do it on a delayed second pass, or budget it heavily. A static build sidesteps the question entirely. The bytes that arrive contain the content.
If you take one thing from this post, take this one. It's unglamorous and it dominates everything else.
One question per page, answered in the first paragraph
Every Learn and FAQ page is a single question. The title is the question. The H1 is the question. The first thing after the H1 is a 40–70 word direct answer that stands alone with no surrounding context.
---
question: "What is investing, actually?"
pillar: "investing"
answer: "Investing is buying a share of something productive — companies,
property, loans — in the hope it earns money over time. Unlike saving, the
value moves, and it can move down. You are being paid, on average and over
long periods, for accepting that uncertainty rather than for being clever."
description: "Investing means owning a share of something productive and
accepting that its value moves. The return is payment for uncertainty, not
for skill."
---
The answer field is enforced by the content schema, so a page cannot ship without one. Then the article goes deep underneath.
The self-contained bit is the discipline. "As we saw above, this means…" is useless when lifted out of context. Every answer has to survive being quoted with nothing around it.
An entity graph, not a pile of schema
This is the piece I see done badly most often. Most sites emit disconnected JSON-LD blobs — an Article here, an Organization there, no relationship between them.
Instead, every page emits one @graph where nodes reference each other by @id:
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": "https://example.com/#author",
"name": "…",
"knowsAbout": ["personal finance", "saving", "investing", "debt"],
"sameAs": ["https://www.amazon.com/stores/author/…", "…"]
},
{
"@type": "WebSite",
"@id": "https://example.com/#website",
"publisher": { "@id": "https://example.com/#author" }
},
{
"@type": "Article",
"@id": "https://example.com/learn/x#article",
"author": { "@id": "https://example.com/#author" },
"abstract": "the same 40-70 word answer"
}
]
}
The Person and WebSite nodes are emitted by one component on every single page, so the entity facts are byte-identical site-wide. Pages add their own nodes on top. There is no way for one page to describe the author differently from another, because there is only one place the description exists.
sameAs is the highest-value line in the whole graph. It's what tells a search engine that the profile on one platform and the profile on another are one entity rather than three coincidences.
Internal links that reflect the actual text
The 60 glossary pages were originally reachable only from the glossary index. To a crawler that reads as "peripheral", and to a reader it means never encountering a definition at the moment they need it.
So there's a component that scans the page's rendered text and links the glossary terms that genuinely appear in it:
const hit = names.find((n) => {
const safe = n.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\b${safe}s?\\b`).test(haystack);
});
Whole-word matching, so "share" doesn't fire inside "shareholder". Capped at five per page.
Two deliberate choices. Nothing is injected into the prose — automatic inline linking produces awkward anchors and text that reads as over-optimised. It's an honest list at the foot of the page instead. And the cap started at eight; I dropped it to five because a long block of machine-matched links looks machine-made even when every match is legitimate.
IndexNow
A 68-line script that reads the built sitemap and pings Bing, Yandex and Seznam on deploy. Verification is a single static file at the site root containing your key. That's it — no dashboard, no registration.
Google has no equivalent, but Bing's index feeds ChatGPT and Copilot, which makes it disproportionately worth the twenty minutes.
Part 2: the bets
These I cannot justify with evidence. I shipped them because the cost is near zero and the asymmetry is good. Be appropriately sceptical — I am.
abstract on every Article node
The same 40–70 word answer that appears in the visible HTML also goes into schema as abstract.
Honest assessment: I have no evidence any model reads abstract specifically. The argument for shipping it is that the marginal cost is one line in a component and the field is already in the visible text, so there's no cloaking risk and nothing to keep in sync. If it does nothing, it cost nothing.
llms.txt
A markdown file at the site root stating the plain facts about the entity, what each section is for, and the canonical URLs.
Honest assessment: this is a proposed convention, not a standard. I'm not aware of evidence that major crawlers consume it today. It costs one file. The genuine side benefit is that writing it forces you to state, in one page, what your site actually claims to be — which surfaced two inconsistencies in my own copy.
Content Signals in robots.txt
This one is a deliberate inversion of the usual advice:
User-agent: *
Content-Signal: ai-train=yes, search=yes, ai-input=yes
Allow: /
Plus explicit Allow blocks for GPTBot, ClaudeBot, PerplexityBot, CCBot, Google-Extended and the rest.
Most sites are busy blocking these. For an established publisher with traffic to protect, blocking is a coherent position. For an unknown author, absence from AI answers is a far bigger risk than inclusion in them. The calculus is genuinely different depending on who you are, and I think a lot of people are copying the default without doing that arithmetic.
Two things worth knowing if you go this route:
Edge rules are enforced before robots.txt is ever fetched. Your CDN's bot-protection settings can silently override every Allow: line you wrote. Your repo says one thing, your edge does another, and you have no idea.
And some CDNs will manage robots.txt for you. If # BEGIN Cloudflare Managed content appears in the served file, it isn't yours any more. Diff what's served against what's in your repo, not what you think you deployed.
Two Cloudflare gotchas that cost me real time
Both are specific to Workers static assets, and neither is obvious from the docs.
Redirects in Worker code never fire for files that exist. A request matching a static asset is served at the edge without invoking your script at all. I wrote a www → apex redirect in the Worker and it was dead code from the moment I shipped it — it only ran for URLs that didn't resolve to a file, which is to say never, for any real page. Host-level redirects belong in a Redirect Rule, which runs earlier in the pipeline.
A wildcard route will eat your subdomains. This one cost a full day. I had *.example.com/* on the Worker. Later I put 60 video files in R2 behind media.example.com. Every single one returned 404.
The bucket was fine. The files were fine, correct content types, correct sizes. The custom domain showed Active. The problem was that Worker routes take precedence over R2 custom domains, so the Worker was answering, finding no matching file, and serving the website's own 404 page.
The tell was that the 404 was mine — my styling, my copy — on a URL that had nothing to do with the site. If you ever see your own error page on a subdomain that shouldn't be serving HTML at all, check your Worker routes before you touch anything else. I spent hours debugging a service that was working perfectly.
What I would tell you to do
If you have a new site and no authority, in order:
- Ship server-rendered HTML. Everything else is a rounding error next to this.
- One question per page, answered in the first 70 words, self-contained.
-
One entity graph with
@idreferences, emitted from one component, identical everywhere. -
Fill in
sameAs. It's the cheapest strong signal available. - Link internally based on what your text actually says, not on a keyword map.
- Then worry about the speculative stuff.
Items 1–5 are just doing the fundamentals properly. If the LLM-extraction thesis turns out to be overblown, that work still pays off through ordinary search. That's the main reason I'd defend this approach even if the premise is wrong — the downside case is "you built a well-structured site."
What I don't know
Whether any of Part 2 does anything. Whether question-first structure measurably improves citation rates versus simply being a good page. Whether llms.txt will be a standard in a year or a footnote.
I'll post numbers when I have them. If you've measured any of this properly, I'd genuinely like to hear it — particularly if you've found the structural stuff makes no difference and it really is authority all the way down.
The site is edmundwarde.com if you want to look at the implementation. View source on any Learn page; the graph is all there.
Top comments (0)