Summary#
head { } is what a page says about itself to everything that is not a person looking at it: the browser tab, a
bookmark, a search result, a link preview in a chat, a crawler reading structured data. A product page says which
product it is; a moved page says where it went.
[Route("/p/{slug}")]
[AllowAnonymous]
component ProductPage(string slug) {
var product = Product.Where(p => p.Slug == slug).FirstOrDefault();
head {
Title = product == null ? "Not found" : product.Name;
Description = product?.Summary;
Canonical = "/p/" + slug;
}
render { … }
}Signature#
head {
Title = <string?>; // the <title> — the tab, a bookmark, a search result's headline (also og:title)
Description = <string?>; // <meta name="description"> — a search result's snippet (also og:description)
Canonical = <string?>; // <link rel="canonical"> — the one address this content lives at (also og:url)
Image = <string?>; // og:image — the picture a shared link shows
StructuredData = <JsonLd?>; // JSON-LD — an Osysharp.Seo block, JsonLd.Raw(json), or [a, b] for several
NoIndex = <bool>; // <meta name="robots" content="noindex">
RedirectTo = <string?>; // answer 301 to this path instead of rendering the page
}Every setting is optional, and each may be set once. A null value writes no tag. The block belongs to a routed
page (one with [Route("/…")]); on any other component it is a compile error, because nothing else has a document.
Description#
It reads the page's own data#
A setting is an ordinary expression, and it may read the page's parameters and its members — a
var product = Product.Where(…).FirstOrDefault(); declared on the page is readable from the head exactly as it is
from render. Only the members the head actually reads are computed for it, so a member that only makes sense in the
browser does not stop a head that never looks at it.
A wrong type is the ordinary assignment error, at the line that wrote it: NoIndex = "yes"; is refused because
NoIndex is a bool.
It is in the first response — whatever [Render] says#
A crawler reads the head of the first response and runs nothing. So the head is computed on the server for every
page a signed-out visitor may open ([AllowAnonymous]), including a [Render(CSR)] page that paints nothing on the
server at all. Relative Canonical and Image values are made absolute against the address the request came to,
because the machines that read them do not resolve a relative address the way a browser does.
A page that requires a sign-in is reached by a hard navigation that carries no credential, so its head is not computed
there: the tab shows its [Title] until the client has signed in, and then the head is applied as below. Nothing the
head would have computed reaches a visitor the page itself refuses.
And it follows the visitor around the app#
Every page after the first is reached by the client router, which never asks for a new document. After each such
navigation the client asks the server for the new page's head — computed as the signed-in caller, and refused to a
caller the page itself refuses ([AllowAnonymous] / [Authorize] apply exactly as they do to the page) — and replaces
the previous page's head tags with it. A page with no head clears them, so a canonical address or structured data never
outlives the page it described. A page with no head { } costs no request.
Title: the head's wins the tab#
The browser tab reads, most specific first: the head's Title, then the page's [Title("…")], then the component's
own name. [Title] goes on naming the route for the app's own chrome — a breadcrumb, a tab strip reading
Navigation.Routes — so a page can be "Blue Kettle — Shop" in the browser and "Product" in its
breadcrumb.
A moved page answers 301#
RedirectTo makes the page answer 301 Moved Permanently before anything renders, which is what tells a search
engine to carry the old address's standing over to the new one. It accepts a path on this app — one that starts
with a single /. A value naming another host (https://…, or //host, which a browser also reads as another host)
is not followed: the page is served instead and the server logs why. That is deliberate — a head computes its value
from data and from the address, and a head that forwarded one would be a way for any link to send your visitors to
another site from your own domain.
After a client navigation the same rule applies, and the router replaces the address rather than adding one.
Structured data: typed blocks, or your own JSON#
StructuredData holds a JsonLd block — what search engines read to show a product's price and stock, a breadcrumb
trail or a site's name in a result. The Osysharp.Seo kit ships schema.org's shapes as classes that
derive from it (SeoProduct, SeoOffer, SeoBreadcrumbList, SeoOrganization, SeoWebSite), each writing its own
@context and @type. JsonLd.Raw(json) is JSON you wrote yourself, for anything the kit does not model. Several
blocks are a list — StructuredData = [product, breadcrumbs]; — which is written as one JSON-LD array
(JsonLd.Of(…) spells the same thing).
It cannot break out of its script. The block's JSON is written into a <script type="application/ld+json"> after it is
parsed — text that is not JSON is dropped, never written — and every < in it is written as the escape \u003c, which is the same character to a JSON
reader and can never end the script element. So a product whose name is </script><script>… shows up in the
structured data as that name, and not as script on your page.
What a head is not#
- Not per-visitor chrome. It is computed for the page's address. A head that greets the signed-in user by name
would show that name to a crawler reading the anonymous response — use the page's
renderfor that. - Not on a generic page. A head is computed from the address alone, and an address cannot choose a type argument,
so
head { }oncomponent Board<T>(…)is refused.
Examples#
A product page that names its product#
entity Product {
[MaxLength(200)] string Name;
[MaxLength(200)] string Slug;
[MaxLength(500)] string? Summary;
security { allow read when IsAnonymous || IsAuthenticated; }
}
[Route("/p/{slug}")]
[AllowAnonymous]
[Title("Product")]
component ProductPage(string slug) {
var product = Product.Where(p => p.Slug == slug).FirstOrDefault();
head {
Title = product == null ? "Not found" : product.Name + " | Shop";
Description = product == null ? null : product.Summary;
Canonical = "/p/" + slug;
Image = "/img/" + slug + ".png";
StructuredData = product == null ? null
: JsonLd.Raw("{\"@context\":\"https://schema.org\",\"@type\":\"Product\",\"name\":" + JsonSerializer.Serialize(product.Name) + "}");
NoIndex = product == null;
RedirectTo = slug == "kettle-blue" ? "/p/blue-kettle" : null;
}
render { Text(product == null ? "Not found" : product.Name); }
}What the compiler refuses#
[Route("/p/{slug}")]
component ProductPage(string slug) {
head {
Title = "Kettle";
Keywords = "kettle, blue"; // ERROR: a head sets `Title` (string?), `Description` (string?), `Canonical` (string?),
} // `Image` (string?), `StructuredData` (JsonLd?), `NoIndex` (bool), `RedirectTo` (string?)
render { … }
}
component Card() {
head { Title = "Card"; } // ERROR: only a routed page has a document head — this component has no [Route("/…")]
render { … }
}meta { title = "…"; }, the block this replaced, is refused by name with the rewrite: head { Title = "…"; }.
See also#
- routes and pages — how a component becomes a page, and what its address binds.
- Navigation —
Navigation.Routesand the route titles an app's own chrome reads. - page authorization (policies) — who may open a page, which is who may read its head.
- component — the members a head may read.
- [Serve] —
[Serve("/sitemap.xml")], for what a crawler reads that is not a page. - moved addresses (app.Redirects) —
app.Redirects, for a move you can write down rather than one the data decides.