Advanced

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.

KeyTypeRenders
titlestringSets document.title and <title>. Ignored when the value is not a string.
titleTemplatestringWraps 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.
metaobjectNamed meta tags: { description, "theme-color", robots }<meta name content>. A charset key emits <meta charset> instead. Keys never become http-equiv — see meta keys.
ogobjectOpen Graph: { title, image, type, url }<meta property="og:KEY">. A key that already starts with og: is used as‑is.
twitterobjectTwitter cards: { card, site, creator }<meta name="twitter:KEY">. A key that already starts with twitter: is used as‑is.
linkobject | 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.
jsonLdobject | object[]JSON-LD structured data → <script type="application/ld+json">. @context defaults to https://schema.org. Non-objects are skipped.
basestring | object<base href> for the document, as a string or { href }. Same-origin relative paths only — see base.
htmlAttrsobjectAttributes 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:

FieldMerge rule
title, baseLast call that set it wins.
meta, og, twitter, htmlAttrsShallow per-key merge — last writer of each key wins, unmentioned keys survive.
linkAppended, 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.
jsonLdAppended — 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.

FieldWhat survivesWhat is dropped
title / titleTemplateStrings.Any non-string value — the field is ignored.
metaKeys matching /^[a-zA-Z][a-zA-Z0-9_.:-]*$/.Keys starting with on, and keys with spaces, quotes or =.
linkEntries 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.
baseA same-origin relative path.Anything carrying a scheme, //host/…, or one of \ < > " '.
htmlAttrslang, dir, class, translate, id, and any data-*.style, on*, and every other name.
jsonLdObjects, 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.

A link entry is kept only when its trimmed, lower-cased rel is one of these:

GroupAllowed rel
Document relationshipscanonical, alternate, prev, next, author, license, help, search, me
Icons & app metadataicon, shortcut icon, apple-touch-icon, apple-touch-icon-precomposed, mask-icon, manifest
Connection hintsdns-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:

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 resultMeaning
htmlThe rendered application markup, wrapped in a single container element unless you pass { container: false }.
stateState snapshot to ship for hydration.
headThe 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

Next