Head metadata — title, meta, OpenGraph, JSON-LD¶
Crawlers and link unfurlers read the <title>, <meta> tags and JSON-LD in the first
response; most of them run no JavaScript. In re-frame2 you register a head function
that derives that metadata from app-db, and a route names
which head to use. The Ring handler resolves it for each request and writes it into
the page's <head>.
For the tutorial's articles app, an article page gets its title, description and
OpenGraph tags from the article it shows. The head function looks the article up in
the :articles vector, whose entries here also carry a :summary and an :image:
(require '[re-frame.core :as rf]
'[re-frame.routing :as rf.routing] ;; route-url; also loads reg-route
'[re-frame.ssr :as ssr]) ;; loads reg-head
(rf/reg-head :head/article
{:doc "Article page head: title, description and OpenGraph from the article."}
(fn [db {:keys [params] :as _route}]
(let [{:keys [title summary image]}
(some #(when (= (:id params) (:id %)) %) (:articles db))]
{:title (str title " — Articles")
:meta [{:name "viewport" :content "width=device-width, initial-scale=1"}
{:name "description" :content summary}
{:property "og:title" :content title}
{:property "og:image" :content image}]
:link [{:rel "stylesheet" :href "/css/app.css"}
{:rel "canonical" :href (rf.routing/route-url {:to :articles/show
:params params})}]
:json-ld [{"@context" "https://schema.org"
"@type" "Article"
"headline" title}]})))
(rf/reg-route :articles/show
{:params [:map [:id :string]]
:head :head/article} ;; which head this route uses
"/articles/:id") ;; the path is the third argument, not a metadata key
;; Stand-ins for a request: seed an article, then build the /articles/1 head.
(rf/reg-event :articles/seed
(fn [{:keys [db]} [_ articles]]
{:db (assoc db :articles articles)}))
(rf/dispatch-sync [:articles/seed [{:id "1"
:title "Why <head> tags matter"
:summary "Crawlers read the first response."
:image "/img/head-tags.png"}]])
[:pre {:style {:white-space "pre-wrap"}}
(ssr/head-model->html
(ssr/head-model :rf/default ;; the frame this page's cells share
{:route {:route-id :articles/show :params {:id "1"}}}))]
On a server, :rf/server-init seeds :articles and hands the URL to routing, as in
Reading the request, and a request for /articles/1
gets a <head> holding the title, the <meta> tags, the stylesheet and canonical
<link>s, and a <script type="application/ld+json">. The end of the cell builds that
fragment without a server: it seeds the article, passes the route to
ssr/head-model, and renders the model with
ssr/head-model->html. The < in the title is escaped in <title> and re-encoded in
the JSON-LD. A nil attribute value omits that attribute: delete the :summary and run
the cell again.
The head function has the shape of a subscription: it takes app-db and the matched route, and returns a head model. It must be pure. The server calls it once, after the drain has settled app-db, so it sees the same state the body renders from.
How the head is built¶
- Every key is optional. A head model can carry
:title,:meta,:link,:script,:json-ld,:html-attrsand:body-attrs.:meta,:link,:scriptand:json-ldare vectors, one map per tag.:html-attrsand:body-attrsbecome attributes on<html>and<body>. re-frame.ssr.head has the full model. - Output order is fixed:
<title>, then<meta>,<link>,<script>, and JSON-LD last. - Values are escaped for you. Text and attribute values are HTML-escaped, and every
<in a JSON-LD string is re-encoded, so an attacker-supplied article title cannot close the script tag. :scriptentries are attributes only, such as{:src "/js/widget.js"}. A head model cannot carry an inline script body.- One head per route. Heads do not compose from parent to child routes; routes that want the same metadata name the same head id.
- The page shell writes
<meta charset="utf-8">itself, so never put a charset in a head model.
A route with no :head gets a default head: a <title> from the frame's :doc
(none when it has no :doc) and the viewport meta. ssr-handler's per-request frame
has no :doc, so under the handler the default head has no <title>; register a head
to get one. A head you register replaces the default entirely, which is why the example
above includes the viewport meta itself.
After hydration¶
The server writes the head once, into the first response. There is no DOM-head
reconciler: when the client navigates to another route, nothing updates <title> or
<meta>. If your app needs the document head to follow client-side navigation, add an
app- or host-level head manager that reads the same head model with
ssr/head-model.
Troubleshooting¶
| Symptom | Error / behaviour | Fix |
|---|---|---|
reg-head throws at first call |
:rf.error/ssr-artefact-missing |
Require re-frame.ssr |
Page renders with an empty <head> (no title, no meta) but a normal status |
:rf.error/ssr-head-resolution-failed: the head fn threw, or the route names an unregistered head. The handler renders the page with an empty head rather than failing it |
Read the record's :exception; fix the head fn, or register the head the route names |
ssr/head-model throws when you call it directly |
:rf.error/no-such-head: the :head-id or the route's :head names nothing registered |
Register the head, or fix the id |
| The route never registers | :rf.error/route-bad-metadata, thrown at registration: the path was put in the metadata map |
The path is the third positional argument of reg-route |
<title> goes stale after a client-side route change |
No DOM-head reconciler | Add an app- or host-level head manager (After hydration) |
| A head mismatch between server and client is never reported | The runtime compares only the body's :rf/render-hash |
Compare :rf/head-hash yourself (The head hash) |
Advanced¶
Custom shells¶
Two ssr-handler options change where the head comes from:
- A
:headstring replaces the resolved head entirely: route head, default<title>andviewportmeta alike. It is injected unescaped, so use it only for a static app without routing and never build it from untrusted input. It also drops the:rf/head-hashchannel, since there is no model to recompute. - A custom
:html-shellreceives the resolved head as HTML in itsoptsmap under:head; put it inside your<head>. If you assemble the document yourself outside the handler,(ssr/head-model->html (ssr/head-model frame-id))gives the same fragment.
The page shell covers the other shell options, and
ssr-handler lists them all.
The head hash¶
The body and the head are hashed separately. The body's render-tree hash travels as
:rf/render-hash, and the client compares it on hydration (When the renders
disagree). A head built from a model also
emits an optional :rf/head-hash, stamped as data-rf-head-hash on <head>. It is
omitted when the head cannot be recomputed: an explicit :head string, or a head that
failed to resolve.
The bundled runtime does not compare the head hash. A host that wants the check
recomputes (ssr/render-tree-hash (ssr/head-model frame-id)) from the hydrated app-db
and route, and compares it with :rf/head-hash itself.