Docs

Metadata reference

See each head mutation and the HTML it generates.

Open Markdown

Use cx.head_mut() inside a layout or Page's render_head hook. Each example below puts the mutation directly above its generated HTML. See Metadata and OG images to set up a document.

Examples are independent: they use a fresh managed document, https://shop.example as the public origin, / as the mount, and no default fields or CSP nonce unless stated otherwise. Output shows only the generated tags, with line breaks added for readability. Charset, viewport, and other manual tags remain in your document template.

Apply a metadata object

Use metadata to set several fields in one call:

render_head
use proa::HeadMetadata;

cx.head_mut().metadata(HeadMetadata {
    title: "Trail & Travel".into(),
    description: "Built for the outdoors.".into(),
    canonical: "/products/trail-jacket".into(),
    ..Default::default()
})?;
Generated HTML
<title>Trail &amp; Travel</title>
<meta name="description" content="Built for the outdoors.">
<link rel="canonical" href="https://shop.example/products/trail-jacket">

Every field defaults to HeadValue::Inherit. Use .into() to set a value and HeadValue::Clear to remove an inherited field. The whole patch is validated before any field changes, including URLs and list limits. A normal title uses the nearest ancestor template, just like title(...).

render_head
use proa::{HeadMetadata, HeadTitle, HeadValue};

cx.head_mut().metadata(HeadMetadata {
    title: HeadTitle::absolute("Checkout").into(),
    description: HeadValue::Clear,
    ..Default::default()
})?;
Generated HTML
<title>Checkout</title>

This clears any inherited description and bypasses an ancestor title template. Other inherited fields stay in place.

FieldValueInheritance when set
titleHeadTitle or a string with .into()Replace; HeadTitle::absolute bypasses the template
description, title_template, canonicalTextReplace
open_graphOpenGraphPatch individual properties
twitterTwitterPatch individual properties
robots, googlebotRobotsReplace the entire directive policy
iconsVec<HeadIcon>Replace the entire list
alternatesVec<HeadAlternate>Replace the entire list
manifestURL textReplace
authorsVec<HeadAuthor>Replace the entire list
application_name, creator, publisherTextReplace
keywordsVec<Cow<'static, str>>Replace the entire list
referrerReferrerPolicyReplace
verificationVec<HeadVerification>Replace the entire list

An empty list removes inherited list entries. Clearing open_graph or twitter removes the whole inherited object. To suppress one social fallback, use the object's clear_* builder method instead. Existing individual setters remain available; migrate old bulk fields such as title: Some("Shop".into()) to title: "Shop".into().

Document metadata

title

Set the document title. Text is escaped when it is written to HTML.

render_head
cx.head_mut().title("Trail & Travel")?;
Generated HTML
<title>Trail &amp; Travel</title>

description

Set the description shown to search engines and link previews.

render_head
cx.head_mut().description("Lightweight layers for everyday adventures.")?;
Generated HTML
<meta name="description" content="Lightweight layers for everyday adventures.">

canonical

Resolve a canonical path against the trusted public origin.

render_head
cx.head_mut().canonical("/products/trail-jacket")?;
Generated HTML
<link rel="canonical" href="https://shop.example/products/trail-jacket">

Pass paths relative to the configured public mount. Do not first prefix them with cx.url(...).

title_template

An ancestor can provide a title template. A descendant supplies the title.

render_head
// Layout hook
cx.head_mut().title_template("%s | Acme")?;

// Page hook
cx.head_mut().title("Trail Jacket")?;
Generated HTML
<title>Trail Jacket | Acme</title>

Dynamic routes work the same way: a Page at /products/:id or /blog/:slug uses cx.param(...) to load its display title and passes it to title(...) or metadata(...). The template formats that title; it does not infer a title from the URL.

A template must contain exactly one %s. The nearest ancestor template applies once. A template set in a hook applies to descendants, not to a title set in that same hook.

absolute_title

Bypass an inherited title template for this title.

render_head
// Layout hook
cx.head_mut().title_template("%s | Acme")?;

// Page hook
cx.head_mut().absolute_title("Checkout")?;
Generated HTML
<title>Checkout</title>

clear_title_template

Stop a template from being inherited by deeper descendants.

render_head
// Root layout hook
cx.head_mut().title_template("%s | Acme")?;

// Nested layout hook
cx.head_mut().clear_title_template()?;

// Page hook
cx.head_mut().title("Checkout")?;
Generated HTML
<title>Checkout</title>

Clearing a template affects descendants. To bypass an ancestor template for a title in the current hook, use absolute_title.

Named meta tags

meta

Set a named meta tag. Setting the same name again replaces its value; names are matched without case sensitivity.

render_head
cx.head_mut().meta("robots", "index, follow")?;
cx.head_mut().meta("author", "Acme")?;
Generated HTML
<meta name="robots" content="index, follow">
<meta name="author" content="Acme">

description(value) is shorthand for meta("description", value). Use open_graph for Open Graph properties.

Twitter cards

Use the typed card builder to resolve image URLs and reuse final metadata. These types come from proa.

render_head
cx.head_mut().title("Trail Jacket")?;
cx.head_mut().twitter(
    Twitter::new()
        .card(TwitterCard::SummaryLargeImage)
        .site("@acme")
        .image(HeadMedia::new("/og/trail-jacket.png").alt("Forest green jacket")),
)?;
Generated HTML
<title>Trail Jacket</title>
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Trail Jacket">
<meta name="twitter:site" content="@acme">
<meta name="twitter:image" content="https://shop.example/og/trail-jacket.png">
<meta name="twitter:image:alt" content="Forest green jacket">

Twitter::new() enables a summary card. Title and description fall back to explicit Open Graph values, then the final document values. The image falls back to the first final Open Graph image. Card output is opt-in; Open Graph alone does not enable Twitter tags. SummaryLargeImage selects the large image card; this API currently supports summary cards, rather than player or app cards.

Use .title(...), .description(...), .creator(...), .site_id(...), and .creator_id(...) to customize the card. .clear_title(), .clear_description(), and .clear_image() suppress fallback values. A descendant patch keeps unspecified parent card properties. cx.head_mut().clear_twitter()? removes the whole card.

Named .meta("twitter:...", value) overrides the corresponding typed tag without duplication. Named meta values remain text and do not resolve URLs. Setting a typed property again replaces its named override. Overriding twitter:image also suppresses the old typed image's alt text.

Robots directives for a page

Set indexing and preview directives on an individual page:

render_head
cx.head_mut().robots(Robots {
    index: Some(false),
    follow: Some(true),
    max_image_preview: Some(ImagePreview::Large),
    ..Default::default()
})?;
Generated HTML
<meta name="robots" content="noindex, follow, max-image-preview:large">

Use googlebot(Robots { ... }) for a separate <meta name="googlebot"> policy. Optional directives emit nothing when omitted. max_snippet and max_video_preview accept -1 for unlimited or a nonnegative limit. A descendant's policy replaces the parent's entire policy, so directives can be intentionally removed. clear_robots() and clear_googlebot() remove those tags.

This is separate from FrameworkBuilder::with_robots(RobotsTxt), which serves the site's /robots.txt. That file controls which URL paths crawlers may fetch. The HTML meta tag controls indexing and previews for a fetched page. A crawler must be allowed to fetch a page to read its noindex tag; disallowing it in robots.txt does not guarantee removal from search results. See Google's page-level directives and robots.txt guide.

Icons

Replace the inherited icon list with typed links:

render_head
cx.head_mut().icons([
    HeadIcon::new("/favicon.svg").sizes("any").mime_type("image/svg+xml"),
    HeadIcon::new("/apple-touch-icon.png").kind(IconKind::AppleTouch).sizes("180x180"),
])?;
Generated HTML
<link rel="icon" href="https://shop.example/favicon.svg" sizes="any" type="image/svg+xml">
<link rel="apple-touch-icon" href="https://shop.example/apple-touch-icon.png" sizes="180x180">

IconKind::Mask emits rel="mask-icon". .media(...) and .color(...) add optional attributes. Use an empty list or clear_icons() to remove inherited icons. URLs are validated and resolved with the trusted public origin and mount.

Alternate languages and feeds

render_head
cx.head_mut().alternates([
    HeadAlternate::new("/fr/products/trail-jacket").language("fr"),
    HeadAlternate::new("/products/trail-jacket").language("x-default"),
    HeadAlternate::new("/feed.xml").mime_type("application/rss+xml").title("Shop news"),
])?;
Generated HTML
<link rel="alternate" href="https://shop.example/fr/products/trail-jacket" hreflang="fr">
<link rel="alternate" href="https://shop.example/products/trail-jacket" hreflang="x-default">
<link rel="alternate" href="https://shop.example/feed.xml" type="application/rss+xml" title="Shop news">

Each alternate can also carry .media(...). Setting alternates replaces the whole inherited list; canonical remains independent. Use clear_alternates() to remove the list.

Manifest

render_head
cx.head_mut().manifest("/app.webmanifest")?;
Generated HTML
<link rel="manifest" href="https://shop.example/app.webmanifest">

clear_manifest() removes an inherited manifest link.

Authors, application details, and verification

render_head
cx.head_mut().metadata(HeadMetadata {
    application_name: "Acme Shop".into(),
    authors: vec![HeadAuthor::new("Alex").url("/authors/alex")].into(),
    referrer: ReferrerPolicy::StrictOriginWhenCrossOrigin.into(),
    verification: vec![HeadVerification::google("verification-token")].into(),
    ..Default::default()
})?;
Generated HTML
<meta name="application-name" content="Acme Shop">
<meta name="referrer" content="strict-origin-when-cross-origin">
<meta name="author" content="Alex">
<link rel="author" href="https://shop.example/authors/alex">
<meta name="google-site-verification" content="verification-token">

authors([...]) and verification([...]) replace their entire inherited lists. Multiple authors and verification tokens emit multiple tags. HeadVerification::new("msvalidate.01", token) supports other verification providers; reserved document and Twitter names are rejected. application_name, creator, publisher, keywords, and referrer also have individual setters. Named .meta(...) calls override the corresponding typed author or verification name without duplication; remove_meta(...) suppresses it.

Clear inherited metadata

Clear only the inherited fields you want to remove. Other metadata stays in place.

render_head
// Layout hook
cx.head_mut().title("Shop")?;
cx.head_mut().description("Browse our products.")?;
cx.head_mut().canonical("/products")?;
cx.head_mut().meta("robots", "noindex")?;
cx.head_mut().meta("author", "Acme")?;

// Page hook
cx.head_mut().clear_title()?;
cx.head_mut().clear_description()?;
cx.head_mut().clear_canonical()?;
cx.head_mut().remove_meta("robots")?;
Generated HTML
<meta name="author" content="Acme">

clear_title, clear_description, clear_canonical, and remove_meta suppress inherited values. Omitting a mutation keeps the inherited value.

Open Graph

Use OpenGraph and HeadMedia from proa for these builders.

open_graph

Enable Open Graph and reuse the final document title, description, and canonical URL.

render_head
cx.head_mut().title("Trail Jacket")?;
cx.head_mut().description("Built for the outdoors.")?;
cx.head_mut().canonical("/products/trail-jacket")?;
cx.head_mut().open_graph(
    OpenGraph::new().kind("product").site_name("Acme"),
)?;
Generated HTML
<title>Trail Jacket</title>
<meta name="description" content="Built for the outdoors.">
<link rel="canonical" href="https://shop.example/products/trail-jacket">
<meta property="og:title" content="Trail Jacket">
<meta property="og:description" content="Built for the outdoors.">
<meta property="og:url" content="https://shop.example/products/trail-jacket">
<meta property="og:site_name" content="Acme">
<meta property="og:type" content="product">

Open Graph is opt-in. OpenGraph::new() enables it. Unspecified title, description, and URL fall back to the final document fields at emission; a templated title includes its template.

A different social title

Set an Open Graph title without changing the document title.

render_head
cx.head_mut().title("Trail Jacket")?;
cx.head_mut().open_graph(
    OpenGraph::new().title("Meet your new everyday jacket"),
)?;
Generated HTML
<title>Trail Jacket</title>
<meta property="og:title" content="Meet your new everyday jacket">

Open Graph images

Describe an existing image with its URL, alternative text, and dimensions.

render_head
cx.head_mut().open_graph(
    OpenGraph::new().images([
        HeadMedia::new("/og/trail-jacket.png")
            .mime_type("image/png")
            .alt("Trail Jacket in forest green")
            .dimensions(1200, 630),
    ]),
)?;
Generated HTML
<meta property="og:image" content="https://shop.example/og/trail-jacket.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:alt" content="Trail Jacket in forest green">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">

Import HeadMedia and OpenGraph from proa. Proa resolves the image URL and emits its metadata; it does not generate or resize the image. secure_url(...) adds an optional og:image:secure_url.

Replace inherited images

An images list replaces the whole inherited list.

render_head
// Layout hook
cx.head_mut().open_graph(
    OpenGraph::new().images([HeadMedia::new("/og/shop.png")]),
)?;

// Page hook
cx.head_mut().open_graph(
    OpenGraph::default().images([HeadMedia::new("/og/trail-jacket.png")]),
)?;
Generated HTML
<meta property="og:image" content="https://shop.example/og/trail-jacket.png">

OpenGraph::default() is a patch: it changes only the supplied fields. It does not enable a previously disabled graph. Use images([]) to remove inherited images.

Open Graph locales

Set a primary locale and alternate locales.

render_head
cx.head_mut().open_graph(
    OpenGraph::new()
        .locale("en_US")
        .alternate_locales(["fr_FR", "es_ES"]),
)?;
Generated HTML
<meta property="og:locale" content="en_US">
<meta property="og:locale:alternate" content="fr_FR">
<meta property="og:locale:alternate" content="es_ES">

The alternate locale list replaces the whole inherited list. Pass an empty typed list, such as [] as [&str; 0], to clear it.

Audio and video

Audio and video use the same HeadMedia records as images.

render_head
cx.head_mut().open_graph(
    OpenGraph::new()
        .audio([HeadMedia::new("/media/trail.mp3").mime_type("audio/mpeg")])
        .video([HeadMedia::new("/media/trail.mp4").dimensions(1280, 720)]),
)?;
Generated HTML
<meta property="og:audio" content="https://shop.example/media/trail.mp3">
<meta property="og:audio:type" content="audio/mpeg">
<meta property="og:video" content="https://shop.example/media/trail.mp4">
<meta property="og:video:width" content="1280">
<meta property="og:video:height" content="720">

Each audio or video list replaces its inherited list. Related properties stay with their media record.

Clear an Open Graph fallback

Explicitly clear a social field to suppress its fallback while leaving the document field alone.

render_head
cx.head_mut().title("Trail Jacket")?;
cx.head_mut().description("Built for the outdoors.")?;
cx.head_mut().open_graph(OpenGraph::new().clear_title())?;
Generated HTML
<title>Trail Jacket</title>
<meta name="description" content="Built for the outdoors.">
<meta property="og:description" content="Built for the outdoors.">

An omitted social field inherits or falls back; a cleared field stays absent. The same pattern applies to clear_description, clear_url, clear_site_name, clear_determiner, clear_kind, and clear_locale on OpenGraph.

Disable Open Graph

Suppress the whole graph without changing ordinary document metadata.

render_head
cx.head_mut().title("Checkout")?;
cx.head_mut().open_graph(OpenGraph::new().kind("website"))?;
cx.head_mut().open_graph(OpenGraph::default().enabled(false))?;
Generated HTML
<title>Checkout</title>

Article metadata

render_head
cx.head_mut().open_graph(OpenGraph::new().article(ArticleMetadata {
    published_time: Some("2026-10-30T12:00:00Z".into()),
    authors: vec!["/authors/alex".into()],
    tags: vec!["Rust".into(), "Web".into()],
    ..Default::default()
}))?;
Generated HTML
<meta property="og:type" content="article">
<meta property="article:published_time" content="2026-10-30T12:00:00Z">
<meta property="article:author" content="https://shop.example/authors/alex">
<meta property="article:tag" content="Rust">
<meta property="article:tag" content="Web">

article(...) selects og:type="article" and replaces the inherited article object. It also accepts modified_time, expiration_time, and section. Author values are profile URLs. Provide valid timestamp text; Proa validates text size and escaping, not timestamp syntax. clear_article() removes article properties. Article properties emit only while the final Open Graph type is article.

Stylesheets and scripts

stylesheet

Register a stylesheet in a typed resource slot.

render_head
#[derive(PartialEq, Eq)]
enum Style { Theme }

cx.head_mut().stylesheet(Style::Theme, "/styles/theme.css")?;
Generated HTML
<link rel="stylesheet" href="https://shop.example/styles/theme.css">

Replace a stylesheet

Writing the same slot again changes its URL without emitting a second tag.

render_head
#[derive(PartialEq, Eq)]
enum Style { Theme }

cx.head_mut().stylesheet(Style::Theme, "/styles/base.css")?;
cx.head_mut().stylesheet(Style::Theme, "/styles/shop.css")?;
Generated HTML
<link rel="stylesheet" href="https://shop.example/styles/shop.css">

A slot is identified by its resource family, enum type, and variant. Different slots may intentionally use the same URL.

module

Register an external module script.

render_head
#[derive(PartialEq, Eq)]
enum Script { Site }

cx.head_mut().module(Script::Site, "/scripts/site.js")?;
Generated HTML
<script type="module" src="https://shop.example/scripts/site.js"></script>

When a CSP nonce is installed before managed-head initialization, Proa adds it to resource tags automatically. The output above assumes no nonce.

Remove a resource

Remove a stylesheet or module by its slot key.

render_head
#[derive(PartialEq, Eq)]
enum Style { Theme }
#[derive(PartialEq, Eq)]
enum Script { Site }

cx.head_mut().stylesheet(Style::Theme, "/styles/theme.css")?;
cx.head_mut().module(Script::Site, "/scripts/site.js")?;
cx.head_mut().remove_stylesheet(Style::Theme)?;
cx.head_mut().remove_module(Script::Site)?;
cx.head_mut().title("Checkout")?;
Generated HTML
<title>Checkout</title>

Removal preserves the slot position if a descendant fills it again. Only the title remains in this example.

URL resolution

Application URL composition

Use cx.url(path) to explicitly apply the application base path to a URL in an HTML attribute or Markdown link. The helper writes the shared prefix and borrowed path directly to the output buffer without creating a concatenated String.

Component body
let product_url = cx.url("/products/42");

html_sync! {
    <a href={product_url}>"View product"</a>
}
.render(cx)

With application base path /shop, this produces:

Generated HTML
<a href="/shop/products/42">View product</a>

Plain URL strings in templates stay unchanged. Absolute URLs, protocol-relative URLs, document-relative paths, queries, and fragments pass through cx.url(...) without adding the base path. Pass an unprefixed application path to avoid applying the prefix twice. cx.request() remains the accessor for incoming request metadata, including headers, location, and route parameters.

Metadata URL setters currently accept strings and resolve them using the separately configured HeadUrlPolicy below. Do not convert a cx.url(...) value to a string and then feed it through a metadata policy that applies the same mount again.

Planned dynamic segments and metadata URLs

Appending dynamic values with .segment(...) and passing the resulting typed value directly to canonical(...) are proposed and are not yet implemented. cx.url(path) itself is available today.

The proposed segment API builds on the context helper. For a dynamic product ID:

Proposed render_head API (not yet implemented)
let product_url = cx.url("/products").segment(self.id);
cx.head_mut().canonical(product_url)?;

With public origin https://shop.example, application base path /shop, and self.id = 42, the expected output is:

Expected HTML once implemented
<link rel="canonical" href="https://shop.example/shop/products/42">

.segment(value) would append and percent-encode one path segment. For example, .segment("red/blue") would append red%2Fblue, preserving the slash as part of the slug. Fixed paths would use cx.url("/products/42") directly.

The same typed value is intended to work in ordinary URL attributes:

Proposed template API (not yet implemented)
let product_url = cx.url("/products").segment(self.id);

html_sync! {
    <a href={product_url}>"View product"</a>
}
Expected HTML once implemented
<a href="/shop/products/42">View product</a>

Dynamic segment composition is intended to preserve the direct-buffer approach, with URL validation, segment encoding, and allocation tests. Metadata retains an owned, validated URL, so its storage can still allocate. A composed application URL would carry its prefix through to metadata without applying the mount a second time.

URL base and mount

Configure metadata URL resolution once at application startup, then pass the resulting HeadConfig to FrameworkBuilder with with_head(...). The builder makes this policy available to the rendered routes' cx.head_mut() setters. Creating the configuration alone does not install it.

In this example, routes is your application's Axum Router. Both the framework and metadata policy use the same /shop mount:

App configuration
use proa::{HeadConfig, HeadUrlPolicy};
use proa::axum::{BasePath, FrameworkBuilder};

let mount = "/shop";
let head = HeadConfig::default()
    .with_urls(HeadUrlPolicy::new("https://shop.example", mount)?);

let app = FrameworkBuilder::new()
    .with_base_path(BasePath::new(mount)?)
    .with_head(head)
    .with_dynamic_routes(routes)
    .build();

Inside a Page or layout's head hook, paths now resolve using that installed policy:

render_head
cx.head_mut().canonical("/products/trail-jacket")?;
Generated HTML
<link rel="canonical" href="https://shop.example/shop/products/trail-jacket">

The leading / is still relative to the configured mount. Configure HeadUrlPolicy::new(public_origin, mount) before with_defaults: defaults resolve URLs immediately.

Currently, this mount is configured separately from FrameworkBuilder::with_base_path(...); the builder does not automatically pass its base path into the head URL policy. Keep both configurations aligned for a mounted application. Inside a component, cx.request().base_path().as_str() reads the configured application prefix ("" at the origin root).

External resource origins

This is also application startup configuration for FrameworkBuilder. Stylesheets and modules registered through cx.head_mut() must come from the public origin or an allowed resource origin. Allow a CDN origin in the policy, then install it with with_head(...) before serving your routes. routes is your application's existing Axum router:

App configuration
use proa::{HeadConfig, HeadUrlPolicy};
use proa::axum::FrameworkBuilder;

let urls = HeadUrlPolicy::new("https://shop.example", "/")?
    .allow_resource_origin("https://cdn.example")?;
let head = HeadConfig::default().with_urls(urls);

let app = FrameworkBuilder::new()
    .with_head(head)
    .with_dynamic_routes(routes)
    .build();

A Page or layout can then register a stylesheet from the allowed CDN:

render_head
#[derive(PartialEq, Eq)]
enum Style { Theme }

cx.head_mut().stylesheet(Style::Theme, "https://cdn.example/styles/theme.css")?;
Generated HTML
<link rel="stylesheet" href="https://cdn.example/styles/theme.css">

The allowlist permits registration; it does not register or fetch the resource itself. RouteResponse::with_head(head) can install a head configuration for an individual response instead of using app-wide builder configuration.

Images, audio, video, canonical URLs, og:url, Twitter images, icons, alternate links, manifests, and author URLs do not use this allowlist. They accept a mount-relative path or any absolute http or https URL that has a host and carries no credentials.

The public origin comes from trusted application configuration, including staging configuration. It is never inferred from Host or forwarded headers. Proa resolves and validates these URLs; it does not fetch resources.

Other head tags

Keep charset, viewport, fonts, and other manual tags in the document's html! or html_sync! template, next to {cx.head()}. Proa does not parse or deduplicate manual tags. Use the typed icon setters above for managed favicons. There is no automatic favicon or Open Graph image file convention. Do not also emit a manual icon for the same purpose unless you intend both links.

When mutations are allowed

Set metadata in render_head or in HeadConfig::with_defaults. A Page declares ROLE = PAGE and seals metadata before its body. An ancestor that supplies metadata declares ROLE = LAYOUT. Ordinary COMPONENT values skip head invocation.

These mutations also work in synchronous hooks returning impl SyncHeadOperation on WebRenderSync. Send components use render_head_send (or render_head_send_sync for synchronous rendering); an async Send hook must produce a Send future.

Page bodies, Suspense children, captures, and loading/error fallbacks cannot mutate metadata. A late mutation returns an error and invalidates the managed document, even if its Result is ignored. Invalid field values fail atomically and may be handled within a hook.

Each managed document needs one Page and one head outlet. Raw child.render(cx) calls render only the body; use template composition or framework render helpers for lifecycle dispatch. Run cargo proa lint <changed-paths> --deny warnings to catch async head overrides with missing roles or an explicit COMPONENT role. Synchronous hooks also retain a code-generation role assertion.

Metadata and OG images · Data loading · Headers and CSP

Search

Type at least 2 characters