For an AI

SEO, GEO & AEO

Resuma is a long-running SSR process: titles, descriptions, canonicals, and answers are in the first HTML response. Search engines and answer engines can read them without executing your app JS. This page is the production pattern this docs site uses — including SITE_URL so you can ship on a .fly.dev host today and point a custom domain at the same binary tomorrow.

What each acronym means

  • SEO — crawl, index, and snippets in Google/Bing: canonical URLs, unique titles/descriptions, sitemap, status codes, Open Graph.
  • GEO (generative engines) — help assistants find a trustworthy source: llms.txt, crawler-specific robots.txt rules, copy-as-markdown, no JS-only docs.
  • AEO (answer engines) — a visible H1 plus a first paragraph that answers the query, FAQ as H2+paragraph, and FAQ JSON-LD only where the Q&A is on the page.

Google documents that AI Overviews/AI Mode use the same foundations as Search. As of 2026-08-03, Google also documents that llms.txt neither helps nor harms Google ranking. Keep llms.txt for other agents (ChatGPT, Perplexity, editor skills). Do not promise citations or rich results.

Minimal FlowApp

use resuma::prelude::*;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let origin = std::env::var("SITE_URL")
        .unwrap_or_else(|_| "https://my-app.fly.dev".into());

    let kit = SeoKit::new("My App", &origin)
        .with_locale("en_US")
        .with_llms_summary("My App does X for Y. Not a résumé builder.")
        .with_llms_section("Docs", format!("{origin}/docs — how to use the product."));

    FlowApp::new()
        .with_title("My App")
        .with_description("One or two sentences that match the homepage.")
        .with_site_url(origin)
        .with_og_image("/og.png") // PNG/JPEG 1200×630 — avoid SVG for Facebook/LinkedIn
        .with_seo_kit(kit)
        .with_sitemap_exclude(["/search"]) // noindex / thin URLs
        .auto_pages(pages_root, PagesRegistry)
        .serve(FlowServeOptions::default())
        .await
}

SeoKit is in resuma::prelude. Call with_seo_kit once — kit routes live on the app router; Flow does not remount /robots.txt.

SITE_URL — fly.dev today, your domain tomorrow

Canonical, Open Graph, JSON-LD, sitemap &lt;loc&gt;, and llms.txt links all come from SITE_URL (no trailing slash). This docs site currently uses https://resuma-docs.fly.dev. When you attach a custom domain, change one value and redeploy — do not hardcode the host in page bodies.

# fly.toml [env]
SITE_URL = "https://resuma-docs.fly.dev"

# After DNS + fly certs add docs.example.com:
SITE_URL = "https://docs.example.com"

Cutover checklist (do this when the domain is live, not before):

  1. Add the hostname in Fly (fly certs add) and wait for HTTPS.
  2. Set SITE_URL to the new origin and deploy.
  3. 301 the old .fly.dev host to the new origin (Fly rewrite or a tiny redirect app).
  4. New Search Console / Bing property on the custom domain; submit /sitemap.xml.
  5. Keep internal links relative (/docs/…) so HTML does not bake the old host.

This docs site is already wired that way. Do not add the 301 until the custom host serves HTTPS.

Per-page title and description

Call these during render (layout or page). Streaming SSR still sees them: the view is built before the document head is sent.

pub fn page(_req: FlowRequest) -> View {
    set_page_title("Workers | Resuma Docs");
    set_page_description(
        "Background workers in Resuma OS: register_worker, events, and jobs next to SSR.",
    );
    view! { <h1>"Workers"</h1> /* … */ }
}

Search UIs and parameterized pages should not compete with real docs. This site sets noindex, follow on /docs/search and omits it from the sitemap.

set_page_robots("noindex, follow");
    FlowApp::new().with_sitemap_exclude(["/docs/search", "/old-alias"])

Redirect aliases, don't duplicate

If two URLs render the same guide, pick one canonical and 301 the other. This site maps /docs/cookbook/docker/docs/cookbook/deploy.

pub fn page(_req: FlowRequest) -> View {
    stage_response_status(301);
    stage_response_redirect("/docs/cookbook/deploy");
    View::empty()
}

What SeoKit serves

  • /robots.txtAllow: / plus GPTBot, OAI-SearchBot, ChatGPT-User, Google-Extended, Claude-Web, PerplexityBot, and a Sitemap line
  • /llms.txt — short product summary + section URLs (from SITE_URL)
  • /sitemap.xml — static Flow routes, minus with_sitemap_exclude
  • Head extras: llms.txt alternate link, optional theme-color (no duplicate robots meta — pages own noindex)

On this site: /robots.txt · /llms.txt · /sitemap.xml · OG Image.

Crawler split (OpenAI): OAI-SearchBot is ChatGPT Search discovery; GPTBot may be used for model training; ChatGPT-User is a user-triggered fetch and may not follow robots the same way. Do not treat one directive as a switch for all three.

AEO on a Resuma page

  1. One H1 that names the topic (same idea as the title).
  2. A lead paragraph that answers in plain language before the first code sample.
  3. Section headings as real questions when the page is an FAQ.
  4. Answers in HTML from SSR — not only after a client fetch.
  5. FAQ JSON-LD only on pages that show the same Q&A. Valid Schema.org is not a Google FAQ rich-result guarantee (Google limited that feature). This site adds FAQPage on /docs/faq only.
// Visible Q&A + matching JSON-LD (same questions/answers)
set_page_json_ld(faq_graph(&origin, &[
    ("What is resumability vs hydration?", "Hydration replays the tree; Resuma resumes from SSR HTML."),
]));

GEO extras that actually help people

  • Copy page / Copy nav — agents and humans can paste canonical markdown with URLs (this docs chrome).
  • Disambiguation — say what the product is not (Resuma is not a résumé builder) in the homepage title, description, and llms.txt.
  • Honest JSON-LD — Organization + WebSite on every page; SoftwareApplication on the homepage; no fake ratings/reviews.

Open Graph

Use a PNG or JPEG 1200×630. Many social crawlers still mishandle SVG. Resuma still serves /og.svg as a fallback icon; this site sets with_og_image("/og.png").

FlowApp::new()
    .with_og_image("/og.png")
    .with_public_dir("public"); // public/og.png → GET /og.png

What not to do

  • Do not put the same meta description on every URL.
  • Do not list redirect aliases and search-result URLs in the sitemap.
  • Do not emit Open Graph tags as meta name=og:… — use property (Resuma 1.3.1+).
  • Do not stuff meta name="keywords" — Google ignores it.
  • Do not block duplicates in robots.txt as a canonicalization method; 301 or noindex instead.
  • Do not claim llms.txt improves Google rank or that Schema.org guarantees rich results or AI citations.

Analytics (optional)

SeoKit can attach a Meta Pixel or GTM snippet with SPA PageView on resuma:navigate. That is analytics, not ranking. This docs site does not load third-party tags.

// Optional — skip unless you actually use the pixel
    SeoKit::new("My App", &origin).with_meta_pixel("1234567890");