Docs
Sitemaps
Generate sitemap.xml, add page metadata, and split large sites into multiple sitemaps.
A sitemap lists page URLs for search engines to discover. Configure one document with with_sitemap, or named documents and an index with with_sitemaps.
The Rust filenames below show one way to organize your app. Register generators explicitly on FrameworkBuilder. XML output is formatted for readability.
Generating a sitemap using Rust
Add the page entries to your application builder:
use proa_framework_axum::{FrameworkBuilder, SitemapUrl};
let app = FrameworkBuilder::new(manifest)
.with_public_origin("https://example.com")?
.with_sitemap([
SitemapUrl::new("/")?,
SitemapUrl::new("/pricing")?.lastmod("2026-10-25")?,
])
.try_build()?;
Output:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
</url>
<url>
<loc>https://example.com/pricing</loc>
<lastmod>2026-10-25</lastmod>
</url>
</urlset>
Each SitemapUrl is one page entry in this single document. The example includes the home page and pricing page; it does not create two sitemaps. Register the actual page routes separately. Adding a Route::page does not automatically add it to the sitemap.
Good to know:
try_build()validates and renders the document once at startup. Requests serve cached bytes. Enable robots.txt on the same builder to advertise the generated sitemap automatically.
Sitemap entries
An entry can include a modification date, expected change frequency, and priority:
use proa_framework_axum::{
ChangeFreq, FrameworkBuilder, SitemapPriority, SitemapUrl,
};
let pricing = SitemapUrl::new("/pricing")?
.lastmod("2026-10-25")?
.changefreq(ChangeFreq::Weekly)
.priority(SitemapPriority::tenths(8).expect("priority within 0..=10"));
let app = FrameworkBuilder::new(manifest)
.with_public_origin("https://example.com")?
.with_sitemap([pricing])
.try_build()?;
Output:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/pricing</loc>
<lastmod>2026-10-25</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
| Entry API | Meaning |
|---|---|
SitemapUrl::new(path)? | An application-local path or a same-origin absolute HTTP(S) URL. |
.lastmod(date)? | The page's actual modification date as YYYY-MM-DD; valid calendar dates span years 0001 through 9999, including leap years. |
.lastmod_date(date)? | The same modification date supplied as a jiff::civil::Date, without string conversion. |
.changefreq(value) | An optional ChangeFreq value, such as Weekly. |
.priority(value) | An optional SitemapPriority, stored in tenths from 0.0 to 1.0. |
Dates are validated and stored using Jiff. String inputs must be exactly YYYY-MM-DD; timestamps are rejected rather than silently truncated. If your app already uses Jiff, pass its date directly (add jiff = "0.2" to your app's dependencies):
use proa_framework_axum::SitemapUrl;
let pricing = SitemapUrl::new("/pricing")?
.lastmod_date(jiff::civil::date(2026, 10, 25))?;
Output (<lastmod> element):
<lastmod>2026-10-25</lastmod>
Both methods produce the same <lastmod> element shown above, and are also available on SitemapIndexEntry for the modification date of a sitemap document. Date formatting writes into the XML output without a temporary string.
Local paths receive the builder's base path exactly once. Absolute entries retain their spelling and are not prefixed. Absolute URLs must match the configured scheme, host, and effective port: HTTPS with no port matches :443, and HTTP matches :80. Origin comparison uses canonical URL hosts, including case, internationalized domains/punycode, IPv6 spellings, and default ports.
For prefix-mounted apps, absolute page and sitemap-index URLs must resolve inside the final mount directory (Sitemaps: location). With /apps/demo, /apps/demo/page and /apps/demo/ are inside; /apps/demo, /apps/demo2/page, and /apps/demo/../../secret are outside. try_build() rejects them with DiscoveryError::OutsideBasePath. Standalone documents check scope when rendering. Use local / for the application root; it renders as /apps/demo/. Local paths cannot traverse above the application root, including through percent-encoded .. segments.
Query strings are XML-escaped. Paths and URLs reject fragments, malformed percent escapes, C0/C1 controls, XML-forbidden characters, and raw characters excluded from URI syntax such as quotes, angle brackets, and braces. Square brackets are allowed around IPv6 hosts, but must be percent-encoded in paths and queries. Origins and absolute URL hosts are parsed and validated as HTTP(S) URLs. Each final loc must be shorter than 2048 bytes. Dates should reflect actual content changes, rather than the time the generator runs.
Supply URL-encoded components when needed; XML entity escaping is a separate step (Sitemaps: escaping). Non-ASCII hosts and paths remain accepted and are written as supplied; provide encoded URLs when required by your server or crawler. The XML writer handles all five listed entities without temporary URL strings. Parsed absolute URLs are retained for origin and scope checks, so rendering does not reparse them. Proa's upper byte limit is conservative for Unicode IRIs; the protocol states a character limit. Final locations also need at least 12 characters under the official XML schemas. The date-only lastmod API and priorities in tenths are supported subsets of the protocol.
Multiple sitemaps
Use with_sitemaps to split pages, products, or other collections into separate documents. Each SitemapFile contains page entries; its ID determines the document's route:
use proa_framework_axum::{FrameworkBuilder, RobotsTxt, SitemapFile, SitemapUrl};
let app = FrameworkBuilder::new(manifest)
.with_public_origin("https://example.com")?
.with_sitemaps([
SitemapFile::new("pages", [
SitemapUrl::new("/")?,
SitemapUrl::new("/pricing")?,
])?,
SitemapFile::new("products-0", [
SitemapUrl::new("/products/1")?.lastmod("2026-10-25")?,
])?,
])
.with_robots(RobotsTxt::new())
.try_build()?;
Output:
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/sitemap-pages.xml</loc>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap-products-0.xml</loc>
</sitemap>
</sitemapindex>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
</url>
<url>
<loc>https://example.com/pricing</loc>
</url>
</urlset>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/products/1</loc>
<lastmod>2026-10-25</lastmod>
</url>
</urlset>
Sitemap: https://example.com/sitemap.xml
/sitemap.xml now contains a <sitemapindex> linking to the two <urlset> documents. Robots.txt advertises the index. Named documents live alongside the index so their default directory scope includes the application's page paths. Incoming handler paths are /sitemap.xml, /sitemap-pages.xml, /sitemap-products-0.xml, and /robots.txt.
IDs must be unique, nonempty, and contain only ASCII letters, digits, -, or _. An empty file list or sitemap with no page entries returns a startup error. with_sitemap and with_sitemaps select alternative modes; the last call replaces the previous configuration. One file passed to with_sitemaps still produces an index and a child document.
Async generation from application data
An application-owned generate_sitemaps function can query a database or CMS and group the resulting URLs into named documents. This example assumes your app defines crate::db::Database, whose published_products() method returns products with path and last_modified strings:
use crate::db::Database;
use proa_framework_axum::{
DiscoveryError, SitemapFile, SitemapUrl, MAX_SITEMAP_ENTRIES,
};
pub async fn generate_sitemaps(db: &Database) -> anyhow::Result<Vec<SitemapFile>> {
let products = db.published_products().await?;
let mut files = vec![SitemapFile::new("pages", [
SitemapUrl::new("/")?,
SitemapUrl::new("/pricing")?,
])?];
for (id, products) in products.chunks(MAX_SITEMAP_ENTRIES).enumerate() {
let urls = products.iter().map(|product| {
SitemapUrl::new(product.path.as_str())?
.lastmod(product.last_modified.as_str())
}).collect::<Result<Vec<_>, DiscoveryError>>()?;
files.push(SitemapFile::new(format!("products-{id}"), urls)?);
}
Ok(files)
}
Await the generator when configuring the builder:
mod sitemap;
use proa_framework_axum::{FrameworkBuilder, RobotsTxt};
// Inside async main, before binding the listener:
let app = FrameworkBuilder::new(manifest)
.with_dynamic_routes_and_metadata(routes, metadata)
.with_base_path(base_path)
.with_public_origin(&origin)?
.with_sitemaps(sitemap::generate_sitemaps(&db).await?)
.with_robots(RobotsTxt::new())
.try_build()?;
For one published product at /products/1, last changed on 2026-10-25, the generated product document is:
Output:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/products/1</loc>
<lastmod>2026-10-25</lastmod>
</url>
</urlset>
The index and pages document follow the same structure as the preceding example. This uses the named-document approach of Next.js generateSitemaps, with each Proa result supplying both an ID and its entries.
Generation runs once at startup, followed by validation and rendering. New content requires regenerating/restarting the app, or custom handlers with application-managed refresh.
Sitemaps and indexes enforce the protocol limits: at most 50,000 entries and 50 MiB of uncompressed UTF-8 XML per document, including escaping. Long URLs may require batches smaller than MAX_SITEMAP_ENTRIES. Oversized documents return a DiscoveryError during rendering, before the response buffer is flattened into a string.
Configure public URLs
Set an explicit public origin and optional runtime base path. The origin contains only a scheme, host, and optional port; the mount prefix belongs in with_base_path:
use proa_framework_axum::{BasePath, FrameworkBuilder, RobotsTxt, SitemapUrl};
let app = FrameworkBuilder::new(manifest)
.with_public_origin("https://example.com")?
.with_base_path(BasePath::new("/apps/demo")?)
.with_sitemap([SitemapUrl::new("/pricing")?])
.with_robots(RobotsTxt::new().allow("/")?.disallow("/admin")?)
.try_build()?;
Output:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/apps/demo/pricing</loc>
</url>
</urlset>
Configuration order does not matter: documents use the final origin and base path. Request Host and forwarded headers do not override that configuration. Index links receive the same prefix as page URLs.
Incoming routes stay unprefixed behind a prefix-stripping proxy. Forward both the index and its child sitemap documents. Crawlers also need robots.txt at the origin root.
Run an application
Configure discovery endpoints and start the HTTP server in src/main.rs. Route definitions are abbreviated below; replace the comment in vec![] with your application routes. The example loads the app's generated manifest from dist/island-manifest.json.
use std::time::Duration;
use proa_framework_axum::{
BasePath, FrameworkBuilder, IslandManifest, RobotsTxt, SitemapUrl,
router_from_spec, serve,
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let manifest = IslandManifest::load_from_path("dist/island-manifest.json")?;
let base_path = BasePath::new(&std::env::var("PROA_BASE_PATH").unwrap_or_default())?;
let origin = std::env::var("PUBLIC_BASE_URL")?;
let (routes, metadata) = router_from_spec(vec![/* your application routes */]);
let app = FrameworkBuilder::new(manifest)
.with_dynamic_routes_and_metadata(routes, metadata)
.with_base_path(base_path)
.with_public_origin(&origin)?
.with_sitemap([
SitemapUrl::new("/")?,
SitemapUrl::new("/pricing")?.lastmod("2026-10-25")?,
])
.with_robots(RobotsTxt::new().allow("/")?.disallow("/admin")?)
.try_build()?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
serve(listener, app, Duration::from_secs(30)).await?;
Ok(())
}
From the application crate, run with an empty base path for local development:
PUBLIC_BASE_URL=http://127.0.0.1:3000 PROA_BASE_PATH="" cargo run
Visit http://127.0.0.1:3000/sitemap.xml for the sitemap and /robots.txt for crawler rules. The example sitemap lists / and /pricing; register those page routes in the application route list.
try_build() returns an Axum router. Pass it to serve() with a bound TCP listener to start accepting requests. The server runs until Ctrl+C or SIGTERM, then allows up to 30 seconds for in-flight requests to finish.
Custom and dynamic handlers
with_sitemap and with_sitemaps install their routes explicitly. Do not register custom handlers at the same paths when using them.
Use standalone Sitemap, SitemapIndex, sitemap_xml, and sitemap_index_xml for request-time generation or application-managed refresh. Pass the configured origin and base path explicitly. The response helpers return UTF-8 application/xml; render failures are logged and returned as a generic 500.
try_build() returns invalid discovery configuration as a DiscoveryError. The existing build() API also works, but panics on invalid configuration. Axum's duplicate-route restrictions apply to both.
Next steps
- Robots.txt
- Configure bot-specific crawler rules and advertise the sitemap or index.
- Deployment
- Configure the public prefix and proxy routes.
- Configuration
- Configure FrameworkBuilder and its router integrations.
- Environment
- Load the public origin and base path at startup.