A public web page needs three things before a search engine can index it well:

  1. Meta tags in the <head> that give the page a title, a summary, and a preview card.
  2. A sitemap.xml that lists every URL the crawler must visit.
  3. A robots.txt that tells the crawler where it can go.

Autumn builds all three. You do three things:

  • Declare the values that never change on the route attribute.
  • Refine the values that change in the handler.
  • Set the site base URL in autumn.toml.

The framework then mounts /robots.txt and /sitemap.xml for you.

Runnable example: examples/reddit-clone wires the full feature. Its src/seo.rs holds the sitemap source and the canonical-URL helpers. Its autumn.toml holds the [seo] block. Its routes in src/routes/ carry the seo(...) arguments. examples/blog shows the same feature on a locale-prefixed site.


Turn the feature on

Set one value in autumn.toml:

TOML
[seo]
base_url = "https://example.com"

base_url does three jobs:

  • It mounts GET /robots.txt and GET /sitemap.xml.
  • It makes the absolute URLs that both documents need.
  • It adds the Sitemap: line to robots.txt.

Set it to the real public host. A wrong base_url sends crawlers to URLs that do not exist.

The routes also appear when you register a sitemap source (see Sitemap). You do not need both.


Meta tags on the route

Declare the values that never change on the route attribute. Add a seo(...) argument, then take a SeoMeta parameter. The parameter arrives with the declared values already in it.

Rust
use autumn_web::prelude::*;

#[get(
    "/about",
    seo(
        title = "About • Autumn Reddit",
        description = "Why this site exists and what it demonstrates.",
        og_type = "website"
    )
)]
pub async fn about(seo: SeoMeta) -> Markup {
    layout_with_seo(seo, html! { h1 { "About" } })
}

Two rules govern the argument:

  • Each value must be a string literal. The macro reads it at compile time.
  • An unknown key or a repeated key is a compile error.

The argument gives the handler values, not markup. The handler still chooses where to write them. A route that declares seo(...) but never takes a SeoMeta parameter renders nothing.

The extractor never fails. A route without a seo(...) argument gives the handler an empty builder, so you can add the parameter to any route.

Where the argument works

MacroAccepts seo(...)
#[get], #[post], #[put], #[patch], #[delete]yes
#[static_get]yes — the pre-rendered HTML carries the tags
#[ws]no — a WebSocket upgrade serves no crawlable page

The keys

Each key matches one SeoMeta builder method.

KeyTag it renders
title<title>
description<meta name="description">
canonical<link rel="canonical">
robots<meta name="robots">
og_title, og_description, og_image, og_type, og_url<meta property="og:…">
twitter_card, twitter_title, twitter_description, twitter_image<meta name="twitter:…">

SeoMeta::render writes only the tags you set. It also fills three gaps for you:

  • og:title falls back to title.
  • og:description falls back to description.
  • og:url falls back to canonical.

The Twitter title and description fall back in the same way, but only when you set twitter_card. Set each value one time and let the fallbacks do the rest.

Match the card type to what you have. Use summary until the page can supply an og_image. A summary_large_image card with no image renders as a plain link.


Refine the values in the handler

A detail page cannot put its title on the attribute, because the title comes from the database. Declare the fixed part on the attribute. Add the changing part in the handler.

Rust
#[get(
    "/r/{sub_slug}/posts/{post_slug}",
    seo(og_type = "article", twitter_card = "summary")
)]
pub async fn show(
    Path((sub_slug, post_slug)): Path<(String, String)>,
    seo: SeoMeta,
    mut db: Db,
) -> AutumnResult<Markup> {
    let post = load_post(&mut db, &sub_slug, &post_slug).await?;

    let seo = seo
        .title(format!("{} • Autumn Reddit", post.title))
        .description(summarize(&post.body, 155));

    Ok(layout_with_seo(seo, html! { h1 { (post.title) } }))
}

SeoMeta is a normal builder. Each method takes the builder and returns it, so you can chain the calls.


Render the tags in your layout

SeoMeta::render returns Maud Markup. Put one call in the <head> of your layout. Every page then gets the same treatment.

Rust
pub fn layout_with_seo(seo: SeoMeta, content: Markup) -> Markup {
    html! {
        (PreEscaped("<!DOCTYPE html>"))
        html lang="en" {
            head {
                meta charset="utf-8";
                meta name="viewport" content="width=device-width, initial-scale=1";
                (seo.render())
                link rel="stylesheet" href="/static/css/app.css";
            }
            body { main { (content) } }
        }
    }
}

render writes the <title> tag, so the layout must not write its own. Give each page a title, or the page gets no title at all.

Keep the old plain-title entry point if your application has many pages. Let it build a SeoMeta and call the new function:

Rust
pub fn layout(title: &str, content: Markup) -> Markup {
    layout_with_seo(SeoMeta::new().title(format!("{title} — Autumn Reddit")), content)
}

examples/reddit-clone uses this pattern in src/routes/layout.rs. Most of its pages still call layout. Only the pages that need more tags call layout_with_seo.


Canonical URLs

A canonical tag names the one true address of a page. Add it when the same content answers at more than one URL. Three common causes are:

  • a second route that redirects to the first, such as /posts/{id};
  • a tracking parameter in the query string;
  • a page that appears under both / and /index.

The tag must hold an absolute URL, so the handler needs base_url at run time. Read it one time at start-up and keep it in a OnceLock:

Rust
static BASE_URL: OnceLock<Option<String>> = OnceLock::new();

pub fn init_base_url(config: &AutumnConfig) {
    let base = config.seo.base_url.as_deref()
        .map(|url| url.trim_end_matches('/').to_owned());
    let _ = BASE_URL.set(base);
}

#[must_use]
pub fn with_canonical(seo: SeoMeta, path: &str) -> SeoMeta {
    match BASE_URL.get().and_then(Option::as_deref) {
        Some(base) => seo.canonical(format!("{base}{path}")),
        None => seo,
    }
}

Call init_base_url in main before the application starts. Then wrap the builder in each handler:

Rust
let seo = with_canonical(seo.title(post.title.clone()), &post_path);

The AutumnConfig extractor also reads the value, but it clones the whole configuration on each request. The OnceLock costs one pointer read.


Keep a page out of the index

Set robots on the route:

Rust
#[secured]
#[get("/submit", seo(title = "Submit a post", robots = "noindex, nofollow"))]
pub async fn submit_form(seo: SeoMeta) -> AutumnResult<Markup> { /* … */ }

The directive has two effects:

  1. The page renders <meta name="robots" content="noindex, nofollow">.
  2. The framework drops the page from sitemap.xml, but only for a #[static_get] route. See the next rule for why.

Autumn reads noindex as a comma-separated directive, so "noindex", "noindex, nofollow", and "nofollow, noindex" all count. An unrelated value such as "noarchive" does not.

Choose the second half of the directive on purpose. noindex, follow keeps the page out of the index but lets crawlers walk its links, which is what a thin profile page wants. noindex, nofollow stops both.

Do not also add the URL to robots.txt. See Do not use Disallow and noindex on the same URL.

The sitemap-exclusion rule

The framework filters only the paths it derives on its own. Those are the concrete #[static_get] paths. It never filters the entries a sitemap source supplies.

The reason is that a SitemapEntry carries only a URL string. Nothing ties the entry back to a route, so the framework cannot match your URLs against your route templates. It also must not drop a URL you asked for.

The practical result is one rule: when you add a URL to a sitemap source, do not also mark its route noindex. Remove the URL from the source instead.


robots.txt

The active profile sets the default:

ProfileBody
dev, testUser-agent: * and Disallow: /
prod, productionUser-agent: * and Allow: /

Override the default and add your own rules under [seo.robots]:

TOML
[seo.robots]
# Force the answer, whatever the profile says. Use `false` on a staging host
# that runs the `prod` profile.
allow_all = false

# Extra lines. Autumn appends them after the User-agent block.
# Machine endpoints only. Read the next section before you add a page here.
additional_rules = [
  "Disallow: /actuator/",
  "Disallow: /api/",
  "Crawl-delay: 5",
]

# An explicit Sitemap: URL. Autumn computes it from `base_url` when you omit it.
sitemap_url = "https://cdn.example.com/sitemap.xml"

Do not use Disallow and noindex on the same URL

The two rules do different jobs, and they cancel each other out:

  • robots.txt stops the crawl.
  • noindex stops the indexing.

A crawler must fetch a page to read its noindex tag. A Disallow line stops that fetch, so the crawler never reads the tag. The damage goes further. A page that another site links to can still enter the index without a crawl. The URL then appears as a bare result, and you have no way to remove it.

Pick one tool for each URL:

GoalUse
Keep the URL out of the indexAllow the crawl. Serve seo(robots = "noindex").
Keep crawlers off a machine endpointDisallow it. Accept that URL-only indexing stays possible.

A noindex tag also has to be reachable. A page behind #[secured] answers an anonymous crawler with a redirect to the login form. The crawler never sees the tag on the page itself. Put the directive where a crawler can read it, or send an X-Robots-Tag: noindex header on the redirect.


sitemap.xml

Autumn builds the document from two sources.

1. Static routes, derived for you

The framework adds one entry for each #[static_get] path when base_url is set. It skips a path that holds a { placeholder, because a template is not a URL.

2. A SitemapSource you register

Every other URL comes from your application. Implement SitemapSource and register it on the builder:

Rust
use autumn_web::seo::{SitemapChangefreq, SitemapEntry, SitemapSource};
use std::future::Future;
use std::pin::Pin;

struct PostSitemapSource {
    pool: Option<Pool<RuntimeConnection>>,
}

impl SitemapSource for PostSitemapSource {
    fn entries(&self) -> Pin<Box<dyn Future<Output = Vec<SitemapEntry>> + Send + '_>> {
        Box::pin(async {
            vec![
                SitemapEntry::new("https://example.com/posts/hello")
                    .lastmod("2026-05-01")
                    .changefreq(SitemapChangefreq::Weekly)
                    .priority(0.6),
            ]
        })
    }
}

// In main():
autumn_web::app()
    .seo_source(PostSitemapSource { pool })
    .run()
    .await;

Each entry holds one URL and three optional hints:

FieldMeaning
locThe absolute URL. Autumn escapes the XML characters for you.
lastmodThe last change date, as YYYY-MM-DD.
changefreqAlways, Hourly, Daily, Weekly, Monthly, Yearly, or Never.
priorityA number from 0.0 to 1.0. Autumn clamps it.

Read the database in a source

The application state does not exist when the framework collects the entries, so a source cannot use the Db extractor. Build a pool of its own from the configuration:

Rust
let pool = autumn_web::db::create_pool(&config.database)?;
let source = PostSitemapSource { pool };

examples/reddit-clone/src/seo.rs does this. It lists the communities and the posts.

Cap the number of entries, and log a warning when the cap bites, so a partial sitemap is never a silent one.

Know what a LIMIT bounds. It bounds the size of the document. It does not bound the work: a database must read every candidate row before it can know which rows are newest. Boot time therefore grows with the table. That is acceptable while the table is small. Past that point, stop building the sitemap at boot and serve /sitemap.xml from your own route, as The entries are a start-up snapshot describes.

Give lastmod the page, not one column

lastmod tells a crawler whether it must fetch the page again. It must therefore describe the whole page. One timestamp column rarely does.

A post page is its body plus its comment thread. An edit advances posts.updated_at. A new comment does not: it writes a different table through a different route. A lastmod read straight from posts.updated_at therefore tells the crawler nothing changed, and the new comments stay out of the index.

You have two ways to fix that:

  1. Write the timestamp from each subsystem that changes the page.
  2. Derive it in the sitemap query.

Prefer the second. One query then owns the definition, and no write path has to remember to take part:

Sql
SELECT GREATEST(
           p.updated_at,
           COALESCE(MAX(c.created_at) FILTER (WHERE c.deleted_at IS NULL), p.updated_at),
           COALESCE(MAX(c.deleted_at), p.updated_at)
       ) AS last_modified
  FROM posts p
  LEFT JOIN comments c
         ON c.commentable_type = 'Post'
        AND c.commentable_id = p.id
 GROUP BY p.id
 ORDER BY last_modified DESC

Two details in that query earn their place:

  • Match the discriminator. A #[commentable] table is polymorphic, so commentable_id is unique only with commentable_type. Without the type, a comment on community 7 moves post 7's date.
  • Count a deletion too. A removed comment changes the page as much as a new one. Filter the deleted rows out of the aggregate, not out of the join: drop them in the join and the date can move backward when somebody deletes the newest reply.

Order on the derived value, not on the column. Your entry cap cuts the list, and it must cut the pages that really are the oldest.

Leave out the changes a reader does not come for. A vote count is one of them. Search engines ask you not to advertise a trivial change. A sitemap that moves every date on every vote also teaches crawlers to distrust all of its dates.

Handle a query failure inside the source. Log the failure and return the entries you have. A short sitemap is better than a failed boot.

The entries are a start-up snapshot

Autumn calls entries() one time, while it builds the router. It renders the result into a fixed response body. Two things follow:

  • The route costs nothing to serve. It writes one cached string.
  • The sitemap does not change until the next deploy or restart.

That trade suits most sites, because a crawler reads the file hours after a deploy. When your site must list new content at once, register your own route instead:

Rust
#[get("/sitemap.xml")]
async fn sitemap(mut db: Db) -> impl IntoResponse {
    let entries = load_entries(&mut db).await;
    (
        [("Content-Type", "application/xml; charset=utf-8")],
        autumn_web::seo::sitemap_xml(&entries, Some("https://example.com")),
    )
}

Autumn sees the collision, logs a warning, and mounts neither of its own SEO routes. Your route wins. Serve /robots.txt yourself as well in that case, or build its body with autumn_web::seo::robots_txt.

The 50,000-URL limit

The sitemap protocol allows 50,000 URLs in one file. sitemap_xml serves the first 50,000 entries and logs a warning for the rest. A larger site needs a sitemap index, which is a file that points at several sitemap files. Build the index in your own /sitemap.xml route.


Locale-prefixed sites

Turn on locale-prefixed routing in autumn.toml:

TOML
[i18n]
locale_prefix_enabled = true
supported_locales = ["en", "es"]

Autumn then writes one sitemap entry per locale for each derived static path. /about becomes https://example.com/en/about and https://example.com/es/about.

Tell the crawler which URLs are translations of each other with hreflang links. locale_alternates builds the pairs, and hreflang_alternates renders them:

Rust
use autumn_web::seo::{SeoMeta, locale_alternates};

#[get("/posts", seo(title = "Posts"))]
async fn index(locale: Locale, seo: SeoMeta) -> Markup {
    let seo = seo.hreflang_alternates(locale_alternates(
        "https://example.com",
        "/posts",
        "en",
        &["en".to_owned(), "es".to_owned()],
    ));
    html! { head { (seo.render()) } }
}

The function adds an x-default pair that points at the default locale. Pass the locale-stripped path, which is what the Uri extractor gives you inside a locale-prefixed nest.

Two exclusions apply, and both keep the sitemap in step with the router:

  • Autumn excludes #[static_get] routes from locale prefixing, because pre-rendering requests one unprefixed path. Such a route stays one entry.
  • Autumn lists a path from [i18n] locale_prefix_exclude without a prefix.

A SitemapSource entry is your own text. Autumn never rewrites it, so add the locale prefix yourself. examples/blog shows both halves.


Static builds

autumn build writes robots.txt and sitemap.xml into the output directory next to the pre-rendered pages. It uses the same configuration and the same sources as the server, so the two agree.

The build never overwrites a file your own routes already produced. A #[static_get("/robots.txt")] route therefore wins, and the build prints a line that says it skipped the file.


Test it

Start the application and read the two documents:

Shell
curl http://localhost:3000/robots.txt
curl http://localhost:3000/sitemap.xml
curl -s http://localhost:3000/ | grep -E '<title>|og:|canonical'

In a test, assert on the rendered markup:

Rust
#[test]
fn post_page_declares_its_canonical_url() {
    let seo = SeoMeta::new()
        .title("hello • Autumn Reddit")
        .canonical("https://example.com/r/rust/posts/hello")
        .og_type("article");

    let html = layout_with_seo(seo, html! {}).into_string();

    assert!(html.contains(r#"<link rel="canonical" href="https://example.com/r/rust/posts/hello">"#));
    assert!(html.contains(r#"<meta property="og:type" content="article">"#));
}

examples/reddit-clone carries two suites:

  • src/routes/layout.rs asserts the rendered tags. It needs no database.

  • tests/seo_pg_integration.rs seeds a Postgres, runs the sitemap source, and asserts the URLs and the lastmod values. Run it with:

    Shell
    cargo test -p reddit-clone --test seo_pg_integration -- --ignored
    

Checklist

  1. Set [seo] base_url to the real public host.
  2. Put (seo.render()) in the <head> of your layout.
  3. Declare a title and a description on each public route.
  4. Add a canonical URL on each page that answers at more than one address.
  5. Mark each page you want out of the index robots = "noindex", and leave that URL out of robots.txt.
  6. Register a SitemapSource for the pages the framework cannot derive.
  7. Read /robots.txt and /sitemap.xml before you deploy.

Reference

ItemWhere
seo(...) route argument#[get], #[post], #[put], #[patch], #[delete], #[static_get]
SeoMeta builder and extractorautumn_web::seo::SeoMeta
locale_alternatesautumn_web::seo::locale_alternates
SitemapSource, SitemapEntry, SitemapChangefreqautumn_web::seo
sitemap_xml, robots_txtautumn_web::seo
AppBuilder::seo_sourceautumn_web::app::AppBuilder
[seo], [seo.robots]autumn.toml
  • Internationalization — locale-prefixed routing and the locale switcher that the hreflang links pair with.
  • Atom and RSS Feeds — the other document a content site publishes for machines.
  • Getting Started#[static_get], the route kind whose paths Autumn puts in the sitemap for you.
  • Deployment — profiles, which decide the robots.txt default.

This guide follows ASD-STE100 Simplified Technical English: short sentences, active voice, simple tenses, and one meaning per word. Keep that style when you edit it.