Docs menu

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.

SEOGEOJSON-LDhreflang

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?