<head> Management
τjs provides flexible document <head> management through a headContent function defined in your entry-server.tsx.
Composition Order
Section titled “Composition Order”τjs assembles the document head in a fixed, intentional order:
-
Template (
index.html)- Provides the static baseline:
<html>,<head>,<body><meta charset>,<meta viewport>- global links and static tags
- Acts as the structural shell of the document.
- Provides the static baseline:
-
Route Meta (
taujs.config.ts)- Declares intent at the routing layer.
- Used for SEO-critical, deterministic values:
titledescription- Open Graph defaults
- Always available, including in streaming routes.
-
Renderer Head (
headContentinentry-server.tsx)- Converts
meta, (optionally)data, and (when the route declaresattr.head)headDatainto actual HTML tags. - Can enrich or override route meta.
- Runs:
- after data resolution in SSR (
datais real;headDatais resolved when declared) - on streaming routes, React builds the head at shell-ready (the
datasnapshot is usually still pending -headDatais the resolved-before-render exception); Vue performs its single head build before rendering starts
- after data resolution in SSR (
- Converts
This separation ensures:
- routing controls what the page represents
- rendering controls how it becomes HTML
- streaming remains safe for SEO
The headContent Function
Section titled “The headContent Function”import { createRenderer, escapeHtml } from "@taujs/react";import { App } from "./App";
export const { renderSSR, renderStream } = createRenderer({ appComponent: ({ location }) => <App location={location} />, headContent: ({ data, meta }) => ` <title>${escapeHtml(meta?.title || "τjs - Composing systems, not just apps")}</title> <meta name="description" content="${escapeHtml( meta?.description || data?.message || "τjs - Composing systems, not just apps", )}"> `,});Data Sources
Section titled “Data Sources”Result from your route’s attr.data handler:
// Route config{ path: '/products/:id', attr: { render: 'ssr', data: async (params) => { const product = await db.products.findById(params.id); return { title: product.name, description: product.description, image: product.imageUrl }; } }}From your route’s attr.meta configuration:
// Route config{ path: '/about', attr: { render: 'ssr', meta: { title: 'About Us', description: 'Learn about our company' } }}headData
Section titled “headData”From your route’s attr.head loader - dynamic head data resolved BEFORE the render starts, on
both strategies. This is how STREAMED pages get real data into <head>:
// Route config{ path: '/products/:id', attr: { render: 'streaming', meta: { title: 'Products' }, // static fallback layer data: serviceData('catalog', 'getProduct', mapper), // streams as usual head: { data: serviceData('catalog', 'getProductHead', mapper), // resolved pre-shell timeoutMs: 3000, // optional; positive finite only (default 3000) optional: false, // optional; true degrades loader failures instead of failing the request } }}headContent: ({ headData, meta }) => ` <title>${escapeHtml(headData?.ogTitle ?? meta?.title ?? "Products")}</title>`;When head data goes missing there is not one failure - there are four distinct situations, and taujs refuses to lump them together:
| What happened | What taujs does | Why |
|---|---|---|
| Client disconnected mid-fetch | Benign abort - the request is torn down, rendering never starts | Never do degraded work for a dead request |
| Loader too slow (deadline hit, request alive) | Degrade: render with headData: undefined + an advisory log | A slow head fetch should not cost you the page |
| Loader genuinely failed | The existing error path - a real 500 | A thrown error is an application defect; hiding it conceals bugs |
Loader failed but the route said head.optional: true | Reclassified as degrade | The route author explicitly decided head data is nice-to-have here |
headData is OPTIONAL in the type, and your callback must handle undefined: routes without
attr.head pass nothing, and the loader DEGRADES to undefined (with a server-side advisory
log) when its deadline expires on a live request, or when it fails and the route declared
optional: true. A failing non-optional head loader fails the request through the normal error
path - real defects stay visible - and a client that disconnects mid-fetch never proceeds into
the render at all. Head data is never serialised into __INITIAL_DATA__; it exists for the
server-rendered <head> only. Keep head loaders cheap - they block the shell.
Data Availability by Mode
Section titled “Data Availability by Mode”SSR Mode
Section titled “SSR Mode”Data is always fully resolved before headContent runs:
headContent: ({ data, meta }) => ` <title>${escapeHtml(meta?.title || "Products")}</title> <meta name="description" content="${escapeHtml(data.product.description)}">`;Streaming Mode
Section titled “Streaming Mode”Route data may not be ready when headContent runs - do not read it here. Static values come
from meta; DYNAMIC values come from the route’s attr.head loader, delivered as headData
(resolved before the shell is sent, undefined on degrade):
headContent: ({ headData, meta }) => { return ` <title>${escapeHtml(headData?.title ?? meta?.title ?? "Streaming page")}</title> <meta name="description" content="${escapeHtml(headData?.description ?? meta.description)}"> ${ headData?.ogImage ? `<meta property="og:image" content="${escapeHtml(headData.ogImage)}">` : "" } `;};The rule:
metafor static defaults and degradation fallback;attr.head->headDatafor dynamic, head-critical values;attr.datafor the page body - never depend on it for streamed heads.
Common Patterns
Section titled “Common Patterns”Each interpolated value below is passed through escapeHtml at the point it enters the HTML
string. The patterns read headData first with meta as the fallback, which is correct on BOTH
strategies (on ssr routes you may additionally read data - it is fully resolved there; on
streaming routes it may still be pending, so do not).
Open Graph Tags
Section titled “Open Graph Tags”headContent: ({ headData, meta }) => { const title = headData?.title ?? meta?.title ?? "Default title"; const description = headData?.description ?? meta?.description ?? ""; const image = headData?.ogImage ?? meta?.ogImage;
return ` <title>${escapeHtml(title)}</title> <meta property="og:title" content="${escapeHtml(title)}"> <meta property="og:description" content="${escapeHtml(description)}"> ${image ? `<meta property="og:image" content="${escapeHtml(image)}">` : ""} `;};Structured Data (JSON-LD)
Section titled “Structured Data (JSON-LD)”JSON-LD is written into <script> raw text, which is a different context from HTML - HTML
character references are not decoded there, so escapeHtml is the wrong tool and would corrupt the
JSON. JSON.stringify alone is not safe either: a value containing </script> closes the element
and lets the rest parse as markup. Escape < as the JSON unicode escape so the tag can never be
closed from inside the data:
// `<` -> `\u003c` keeps valid JSON but prevents `</script>` breakout.const jsonForScript = (value: unknown) => JSON.stringify(value).replace(/</g, "\\u003c");
headContent: ({ headData, meta }) => { const jsonLd = headData?.jsonLd ?? meta?.jsonLd;
return ` <title>${escapeHtml(meta?.title || "Page")}</title> ${ jsonLd ? `<script type="application/ld+json">${jsonForScript(jsonLd)}</script>` : "" } `;};If you use CSP with script-src restrictions, inline JSON-LD may require a nonce/hash depending on your policy.
Canonical URLs
Section titled “Canonical URLs”headContent: ({ headData, meta }) => { const canonical = headData?.canonical ?? meta?.canonical;
return ` <title>${escapeHtml(meta.title)}</title> ${ canonical ? `<link rel="canonical" href="${escapeHtml(canonical)}">` : "" } `;};
escapeHtmlstops a URL from breaking out of thehrefattribute, but it does not validate the scheme. If the URL is user-influenced, also reject/allow-list schemes (e.g. permit onlyhttps:/http:) so you don’t emitjavascript:ordata:links.
Best Practices
Section titled “Best Practices”1. Use meta for Static Values and attr.head for Dynamic Values
Section titled “1. Use meta for Static Values and attr.head for Dynamic Values”meta is cheap, deterministic, and survives head-loader degradation - it is the fallback layer,
not the answer to dynamic streamed heads. Declare attr.head for dynamic head-critical values:
import { escapeHtml } from "@taujs/react"; // or "@taujs/vue"
// correct on both strategiesheadContent: ({ headData, meta }) => ` <title>${escapeHtml(headData?.title ?? meta?.title ?? "Default Title")}</title> <meta name="description" content="${escapeHtml( headData?.description ?? meta?.description ?? "Default description", )}"> ${ headData?.ogImage ? `<meta property="og:image" content="${escapeHtml(headData.ogImage)}">` : "" }`;2. Provide Fallbacks
Section titled “2. Provide Fallbacks”// streaming (and safe everywhere): dynamic -> static -> defaultconst title = headData?.title ?? meta?.title ?? "Default Title";
// ssr only: route data is fully resolved, so it may join the chainconst ssrTitle = data.title ?? meta?.title ?? "Default Title";3. Escape User Content
Section titled “3. Escape User Content”headContent returns raw HTML written verbatim into <head>, so any value that could carry
service data or user input - from data, headData or meta - must be escaped at the point
it enters the string. Escape by output context, not by property name:
-
HTML text and quoted attributes →
escapeHtml(exported by both renderers; escapes& < > " ', so it is safe in single- and double-quoted attributes):import { escapeHtml } from "@taujs/react"; // or "@taujs/vue"headContent: ({ headData }) => `<meta property="og:image" content="${escapeHtml(headData?.ogImage ?? "")}">`; -
<script>/ JSON-LD data → notescapeHtml; escape<as\u003cin the serialised JSON (see Structured Data). -
URL attributes →
escapeHtmlprevents attribute breakout but does not validate the scheme; allow-list schemes separately for user-influenced URLs.
escapeHtml accepts any value (String()-coerced) and is not idempotent - escape each value exactly
once. If you can’t import it, roll your own - but escape ' too, or single-quoted attributes stay
vulnerable:
function escapeHtml(value: unknown): string { return String(value) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'");}4. Data-Dependent Heads: SSR Has It Built In; Streaming Declares attr.head
Section titled “4. Data-Dependent Heads: SSR Has It Built In; Streaming Declares attr.head”On render: 'ssr' the route data is fully resolved before the head is built, so data is always
real. On render: 'streaming' the data snapshot is usually still pending at head time - declare
attr.head with a head-critical loader and read headData in headContent instead (resolved
before the render, bounded by head.timeoutMs; undefined on degrade - handle it and fall back
to meta).