Features & Build Notes · · 3 min read
This site's SEO + GEO stack: sitemap, hreflang, JSON-LD, llms.txt
Five layers so a site is found by Google AND generative AI engines: reciprocal bilingual hreflang, a sitemap generated from the router, FAQPage JSON-LD from display data, an AI-bot-friendly robots.txt, and llms.txt.
This site’s technical SEO is built for two readers at once: classic Google crawlers and the generative AI engines (ChatGPT, Claude, Perplexity) that now help decide who gets found. The second discipline is often called GEO (Generative Engine Optimization). Every layer is generated from code — nothing is hand-written twice.
The layer map
| Layer | For whom | Where |
|---|---|---|
| Canonical + hreflang | Google (bilingual site) | Every page’s <head> |
| Dynamic sitemap | All crawlers | /sitemap.xml |
| JSON-LD (Person, Service, FAQ, TechArticle) | Rich results + AI engines | <script type="application/ld+json"> |
| robots.txt with explicit AI-bot allows | AI crawlers | /robots.txt |
| llms.txt | Generative AI engines | /llms.txt |
1. Reciprocal hreflang for a bilingual site
The rule people break most: hreflang must be reciprocal — the ID page points to EN, and EN must point back to ID, or Google ignores it. Because paths here are locale-neutral, one function guarantees reciprocity:
// src/lib/i18n.ts — called from every mirrored page's <head>
export function hreflangAlternates(neutralPath: string): AltLink[] {
return [
{ lang: "id", path: href("id", neutralPath) }, // /services
{ lang: "en", path: href("en", neutralPath) }, // /en/services
{ lang: "x-default", path: href("id", neutralPath) }, // ID = primary language
];
}
2. A sitemap that never goes stale
The sitemap is assembled from the same sources of truth as the router — the static route list + a D1 query for published posts + the docs registry. Adding a new doc automatically adds a sitemap entry:
const publicSitemapRoutes = [
{ path: "/", priority: "1.0" },
// ...main pages in both locales...
...docs.map((d) => ({ path: "/docs/" + d.slug, priority: "0.6" })),
];
seoRoutes.get("/sitemap.xml", async (c) => {
/* + blog posts from D1, with <lastmod> from updated_at */
});
3. JSON-LD from the same data as the visible page
The principle: schema is never hand-written — it’s generated from the data that also renders the page, so the two can’t drift apart. Example: FAQPage (the format AI engines quote most readily):
// src/lib/schema.ts
export function faqPageJSON(items: Array<{ q: string; a: string }>, locale = "id") {
return marshal({
"@context": "https://schema.org",
"@type": "FAQPage",
inLanguage: locale,
mainEntity: items.map((f) => ({
"@type": "Question",
name: f.q,
acceptedAnswer: { "@type": "Answer", text: f.a },
})),
});
}
The /services page calls it with the same FAQ array that renders as <details> — one source, two outputs.
4. llms.txt: a site map for AI engines
llms.txt (the llmstxt.org format) is a curated summary for language models: who you are, the key facts you want retrieved (pricing, process, contact), and links to the pages that matter. Paired with a robots.txt that explicitly allows GPTBot, ClaudeBot, PerplexityBot and friends — being retrievable by AI is a distribution strategy, not a leak.
5. Don’t forget the foundation: content without JavaScript
All the metadata above is worthless if crawlers get an empty page. This site renders fully on the server; reveal animations are gated behind a .js class so content is fully visible even without JavaScript — verified automatically on every release by scripts/verify.mjs.
Lessons
- Generate every piece of metadata from the same data source as the visible page — manual synchronization always loses.
- Hreflang without reciprocity = no hreflang.
- GEO isn’t a new trick: server-rendered, structured content with plainly stated facts is 90% of the job.
Want something like this built for your business?