Document head & SEO.
$head({ … }) is a reactive document-head manager.
It sets document.title, meta tags, the canonical link,
Open Graph / Twitter cards, JSON-LD, and <html>
attributes — and the same resolved head feeds
renderToString, so server-rendered pages are
crawlable and show rich social previews. Anything that has to rank in
search or render a link preview — a marketing page, a blog post,
a docs site, an e-commerce PDP — needs this.
Quick start
Call $head({ … }) from anywhere that runs during a
render — usually the top of a page component. Values can be
reactive: read $state and the head re-applies when it
changes.
$product = { name: "Aeron chair", summary: "Ergonomic seating, built to last.", image: "/og/aeron.png", price: 1395 }
$canonical = "/products/aeron-chair"
function ProductPage() {
$head({
title: $product.name,
titleTemplate: "%s — Acme", // wraps the title in THIS call only
meta: { description: $product.summary, "theme-color": "#111" },
og: { title: $product.name, image: $product.image, type: "product" },
twitter: { card: "summary_large_image" },
link: [{ rel: "canonical", href: $canonical }],
jsonLd: { "@type": "Product", name: $product.name, offers: { price: $product.price } }
})
return Column([Heading($product.name), Text($product.summary)])
}
$app(ProductPage())
That one call emits nine tags — a templated
<title>, two <meta name>, three
og:* properties, one twitter:card, the
canonical <link>, and a JSON-LD
<script>:
<title>Aeron chair — Acme</title>
<meta name="description" content="Ergonomic seating, built to last.">
<meta name="theme-color" content="#111">
<meta property="og:title" content="Aeron chair">
<meta property="og:image" content="/og/aeron.png">
<meta property="og:type" content="product">
<meta name="twitter:card" content="summary_large_image">
<link rel="canonical" href="/products/aeron-chair">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Product","name":"Aeron chair","offers":{"price":1395}}</script>
Call $head where it renders. Put it
inside a component body (or the page tree) so it runs on every render
pass. A bare top-level $head(…) that nothing
evaluates won’t fire — like any expression, it has to be
reached during rendering.
Configuration
Every field is optional and unknown keys are ignored. The
og and twitter groups exist so you don’t
repeat the og: / twitter: prefix — write
it yourself and it isn’t doubled.
| Key | Type | Renders |
|---|---|---|
title | string | Sets document.title and <title>. Ignored when the value is not a string. |
titleTemplate | string | Wraps title, e.g. "%s — Acme". The first %s is replaced; later ones are left as literal text. Only applies to a title passed in the same call — see Why the template didn’t apply. |
meta | object | Named meta tags: { description, "theme-color", robots } → <meta name content>. A charset key emits <meta charset> instead. Keys never become http-equiv — see meta keys. |
og | object | Open Graph: { title, image, type, url } → <meta property="og:KEY">. A key that already starts with og: is used as‑is. |
twitter | object | Twitter cards: { card, site, creator } → <meta name="twitter:KEY">. A key that already starts with twitter: is used as‑is. |
link | object | object[] | One <link> descriptor or an array of them, e.g. [{ rel: "canonical", href }, { rel: "alternate", hreflang: "fr", href }]. rel and href are both allow-listed — see link entries. |
jsonLd | object | object[] | JSON-LD structured data → <script type="application/ld+json">. @context defaults to https://schema.org. Non-objects are skipped. |
base | string | object | <base href> for the document, as a string or { href }. Same-origin relative paths only — see base. |
htmlAttrs | object | Attributes for <html>, e.g. { lang: "en", dir: "ltr" }. Five names plus data-* — see htmlAttrs. In SSR these come back as headAttrs for the page shell. |
Reactive heads
Because $head runs during render and reads your state, the
head is just another reactive output. Change the state and the title /
meta update live — no manual DOM writes.
$unread = 3
function Inbox() {
$head({ title: $unread > 0 ? `(${$unread}) Inbox` : "Inbox" })
return Column([ /* … */ ])
}
Increment $unread and the browser tab re-titles itself on
the next commit. Nothing else changes: $head replaces its
whole managed tag set on every commit rather than diffing tag by tag.
Per-route composition
Multiple $head(…) calls in one render pass
merge in call order — later calls win on
conflicts. A component’s call runs when that component renders, so
a page nested inside a layout contributes after the layout: the
page wins. This is the layout-plus-page pattern.
function Layout(page) {
$head({ // site-wide defaults
meta: { "theme-color": "#111", description: "Acme builds tools for teams." },
og: { type: "website", image: "/og-default.png" }
})
return Column([ Navbar(), page, Footer() ])
}
function AboutPage() {
$head({
title: "About",
titleTemplate: "%s — Acme", // keep the template WITH the title
meta: { description: "Who we are and why." }
})
return Column([ Text("About Acme") ])
}
$app(Layout(AboutPage()))
// → <title>About — Acme</title>, description "Who we are and why.",
// and the layout's theme-color + default OG image survive the merge.
Merging is per field, not per call — the layout’s
theme-color survives because the page never mentions it:
| Field | Merge rule |
|---|---|
title, base | Last call that set it wins. |
meta, og, twitter, htmlAttrs | Shallow per-key merge — last writer of each key wins, unmentioned keys survive. |
link | Appended, then de-duplicated on rel + href + hreflang. The first occurrence is kept, so a layout and a page declaring the same canonical emit it once. |
jsonLd | Appended — every entry is kept, none overwrite. Two calls give two <script> blocks. |
When the route changes, the new page’s render contributes a fresh head and the previous route’s tags are replaced — titles don’t pile up, and a route that did not render this pass contributes nothing at all. See Routing for the router surface.
Why the template didn’t apply
titleTemplate is resolved inside the call that
supplies it, not across the merge. A layout that owns the
template and a page that owns the title therefore never meet, and the
title ships bare:
function Layout(page) {
$head({
titleTemplate: "%s — Acme", // ⚠ no `title` here, so nothing to wrap
meta: { description: "Acme builds tools for teams." }
})
return Column([page])
}
function AboutPage() {
$head({ title: "About", meta: { description: "Who we are and why." } })
return Column([Text("About Acme")])
}
$app(Layout(AboutPage()))
// → <title>About</title> ← the "— Acme" suffix is gone
// <meta name="description" content="Who we are and why.">
The fix is the version in the section above: pass
titleTemplate in the same call as title. If
every page needs the same suffix, put the pair in one shared helper the
pages call, rather than splitting it across a layout boundary.
What $head will and will not emit
$head is the one runtime API that writes
outside the app’s shadow root, into the host
page’s <head> and <html>.
Its blast radius is the whole embedding page, so every field is
allow-listed rather than filtered: a value either matches a known-inert
shape or it is dropped.
| Field | What survives | What is dropped |
|---|---|---|
title / titleTemplate | Strings. | Any non-string value — the field is ignored. |
meta | Keys matching /^[a-zA-Z][a-zA-Z0-9_.:-]*$/. | Keys starting with on, and keys with spaces, quotes or =. |
link | Entries whose rel is one of 17 values and whose href survives sanitising. | Every other rel; entries with no usable href; attributes outside the nine allowed. |
base | A same-origin relative path. | Anything carrying a scheme, //host/…, or one of \ < > " '. |
htmlAttrs | lang, dir, class, translate, id, and any data-*. | style, on*, and every other name. |
jsonLd | Objects, and arrays of objects. | Non-objects. < is escaped in the serialised JSON either way. |
The reasoning behind each list lives on Security & the trust model; the rules themselves are below.
link — 17 allowed rel values
A link entry is kept only when its trimmed, lower-cased
rel is one of these:
| Group | Allowed rel |
|---|---|
| Document relationships | canonical, alternate, prev, next, author, license, help, search, me |
| Icons & app metadata | icon, shortcut icon, apple-touch-icon, apple-touch-icon-precomposed, mask-icon, manifest |
| Connection hints | dns-prefetch, preconnect |
stylesheet, preload,
modulepreload, prefetch,
prerender and import are dropped.
They are not metadata — a stylesheet is attacker CSS over the
entire host page, and the preload family fetches arbitrary origins with
as=script priming a real script load. If you used
$head to inject a stylesheet, that no longer works.
Each surviving entry is then vetted attribute by attribute:
hrefis required. Fragments (#…), root-relative (/…), query-only (?…) and dot-relative (./…) values pass through verbatim; otherwise only anhttp:orhttps:URL survives. Control characters are stripped first, and protocol-relative//host/…is rejected. An entry whosehrefdoes not survive is dropped whole — you get no<link>, not a broken one.- Nine other attributes are allowed:
as,type,sizes,media,hreflang,color,title,crossorigin,referrerpolicy. Anything else — includingintegrity— is dropped rather than guessed at. - Attribute names are validated too, and a name
starting with
onis refused. This is what stops a name from smuggling a second attribute into the SSR output, where names are serialised outside quotes.
Loading a web font
Fonts have their own vetted path. Since
rel: "stylesheet" is not available, load web fonts with
$theme({ fonts: { import: […] } }): it parses the
shorthand, accepts only letter/digit/space family names and integer
weights 100–900, and constructs a
fonts.googleapis.com URL rather than taking one from your
program.
$app(Column([
$theme({ fonts: { import: ["Inter:400,700", "JetBrains Mono"] } }),
Text("Rendered in Inter, loaded without a $head link.")
]))
That injects a single <link rel="stylesheet"> into
document.head, once per unique URL, and font faces loaded
into the document are visible inside the shadow root. See
Themes & tokens for the rest of the
$theme surface.
For a third-party stylesheet that is not a font — a
widget’s own CSS shipped next to its SDK — use
$script({ src }) instead.
It loads a .css URL as a stylesheet, de-duplicates per
src, and gives you a ready flag to gate on.
htmlAttrs — five names plus data-*
htmlAttrs sets attributes on the host page’s
<html> element. Localisation and styling hooks only:
lang, dir, class,
translate, id, and any data-* key.
Names are lower-cased before they are stored.
$app(Column([
$head({ htmlAttrs: { lang: "en", dir: "rtl", class: "dark", "data-theme": "midnight" } }),
Text("All four survive.")
]))
style is deliberately excluded, and so is
anything starting with on. A program that could set
<html style> could paint a full-viewport overlay over
the host page — a clickjacking surface — and beacon data out
through a background-image URL. Style the app itself with
$theme or
sx instead.
base — same-origin paths only
base accepts a relative path and nothing else. Any scheme
at all — including https: — plus
protocol-relative //host/… and the characters
\ < > " ' are rejected.
$app(Column([
$head({ base: "/app/" }), // ✅ → <base href="/app/">
Text("Relative URLs now resolve against /app/.")
]))
// $head({ base: "https://cdn.example.com/" }) ❌ dropped — carries a scheme
The reason is scope: <base> rewrites how the
host page resolves every relative URL it has — script
src, form action,
fetch("/api/…"). A cross-origin base would hand the
whole embedding application to another origin, which is not something a
head manager should be able to do.
meta keys always become name
Every meta key is emitted as the value of a
name attribute. There is no way to reach
http-equiv, so a program cannot inject a
refresh redirect or a relaxed
Content-Security-Policy into the host document. The single
exception is charset, which emits
<meta charset>.
$app(Column([
$head({ meta: { description: "Every invoice, one page.", "http-equiv": "refresh" } }),
Text("Invoices")
]))
The http-equiv key survives, but only as inert data:
<meta name="description" content="Every invoice, one page.">
<meta name="http-equiv" content="refresh"> <!-- a name, not a directive -->
Rejections are silent
This is the part worth remembering: a rejected field is dropped at runtime with no console warning, and the validator does not flag it either. The program parses, validates, renders, and simply emits fewer tags than you wrote.
$app(Column([
$head({
title: "Invoices",
meta: { description: "Every invoice, one page." },
link: [
{ rel: "canonical", href: "/invoices" },
{ rel: "stylesheet", href: "/print.css" }, // dropped — rel not allowed
{ rel: "preload", href: "/hero.woff2", as: "font" } // dropped — rel not allowed
],
base: "https://cdn.example.com/", // dropped — has a scheme
htmlAttrs: { lang: "en", style: "filter: invert(1)" } // style dropped
}),
Text("Invoices")
]))
What actually reaches the page:
<title>Invoices</title>
<meta name="description" content="Every invoice, one page.">
<link rel="canonical" href="/invoices">
<!-- headAttrs → { "lang": "en" } -->
So when a tag you asked for never appears, check it against the lists above before looking anywhere else — nothing in the toolchain will tell you it was refused.
Server-side rendering
renderToString evaluates your program and returns the
resolved head alongside the HTML, so you can inject a crawlable
<head> into your page shell:
import { renderToString } from "aktion-runtime";
const { html, state, head, headAttrs } = renderToString(source);
const page = `<!doctype html>
<html ${Object.entries(headAttrs).map(([k, v]) => `${k}="${v}"`).join(" ")}>
<head>
<meta charset="utf-8">
${head} <!-- resolved <title>, meta, OG, JSON-LD -->
</head>
<body>
${html}
<script>window.__AKTION_STATE__ = ${JSON.stringify(state).replace(/</g, "\\u003c")}</script>
</body>
</html>`;
Those four fields are the whole result:
renderToString result | Meaning |
|---|---|
html | The rendered application markup, wrapped in a single container element unless you pass { container: false }. |
state | State snapshot to ship for hydration. |
head | The resolved <head> markup (title, meta, OG, Twitter, links, JSON-LD) emitted by every $head(…) that ran. Empty string when none ran. |
headAttrs | <html> attributes contributed via $head({ htmlAttrs }), already allow-listed. {} when none ran. |
Pending contributions are flushed synchronously when head
or headAttrs is read, so SSR never races the microtask
commit. renderToStaticMarkup(source) returns only the
html string — reach for it when the page is fully
static and you do not need to hydrate. Both require a DOM: register
happy-dom or jsdom on globalThis
in your Node entry, or renderToString throws. A program
that fails to parse yields an empty container with
head: "" and headAttrs: {} rather than
throwing.
Escape the state snapshot yourself
state is your data, not markup. The
runtime escapes everything it emits into head, but the
hydration snapshot is serialised by you — a
</script> inside a string value would close the tag
early. Escape < to \u003c as shown
above, and see Security & the trust
model for why state that came from an HTTP response counts as
untrusted.
The client re-applies the same head on hydration, so the title / meta you served and the title / meta the app manages stay in sync. See Production & deployment for the full SSR + hydration flow.
Recipes
Canonical + alternates for i18n
One canonical plus one alternate per locale. The
de-duplication key includes hreflang, so alternates for
different locales never collapse into each other.
$head({
link: [
{ rel: "canonical", href: $url },
{ rel: "alternate", hreflang: "en", href: $urlEn },
{ rel: "alternate", hreflang: "fr", href: $urlFr }
]
})
Article JSON-LD for rich results
jsonLd takes a plain object; @context is
filled in for you unless the object already has one.
$head({
title: $post.title,
meta: { description: $post.excerpt },
og: { title: $post.title, type: "article", image: $post.cover },
jsonLd: {
"@type": "Article",
headline: $post.title,
author: { "@type": "Person", name: $post.author },
datePublished: $post.date
}
})
Blocking indexing on a staging build
robots is an ordinary meta key, so it is a
one-liner driven by whatever flag your build sets.
$head({ meta: { robots: $isProd ? "index,follow" : "noindex,nofollow" } })
Because the value is reactive, flipping $isProd updates the
tag in place — useful when the same bundle serves preview and
production from one environment variable.
Notes & gotchas
- Rejected fields never warn. Nothing logs, nothing validates — see Rejections are silent. This is the first thing to check when a tag does not appear.
- Managed tags are owned by Aktion. Every tag
$headinjects carriesdata-rui-head. On each commit the manager removes every[data-rui-head]node indocument.headand appends the new set — a whole replacement, not a diff — and the same sweep runs on re-plan and disconnect, so a torn-down program never leaves a stale title behind. Don’t hand-write tags Aktion also manages, and don’t adddata-rui-headto your own. - Commits are batched. Contributions in a render pass
accumulate synchronously and commit on a microtask, so several
$headcalls resolve to one merged head with no flicker. In SSR the merge happens synchronously when the head is serialized. titleTemplatedoes not cross calls. It only wraps atitlepassed in the same$head({ … })— see Why the template didn’t apply.- Escape HTML in values? No need. Titles, meta
content, and link attributes are escaped for you, and JSON-LD is
serialized with
<escaped so a string value cannot close the<script>. The one thing you escape yourself is the SSRstatesnapshot. linkde-dupes byrel+href+hreflang, keeping the first occurrence, so a layout and a page declaring the same canonical won’t emit it twice.- Shadow DOM is not the boundary here. The app
renders into a shadow root for style encapsulation, but
$headwrites into the host document on purpose. That is exactly why its fields are allow-listed — read Security & the trust model before rendering head values that came from an API or a URL parameter.
Next
Routing
Per-route pages and navigation — where per-route $head composition lives.
Production & deployment
SSR with renderToString, hydration, CSP, and shipping a crawlable shell.
Security & the trust model
Why every $head field is allow-listed, and what the sanitisers do and do not defend.