Skip to content

<head> Management

τjs provides flexible document <head> management through a headContent function defined in your entry-server.tsx.

τjs assembles the document head in a fixed, intentional order:

  1. 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.
  2. Route Meta (taujs.config.ts)

    • Declares intent at the routing layer.
    • Used for SEO-critical, deterministic values:
      • title
      • description
      • Open Graph defaults
    • Always available, including in streaming routes.
  3. Renderer Head (headContent in entry-server.tsx)

    • Converts meta, (optionally) data, and (when the route declares attr.head) headData into actual HTML tags.
    • Can enrich or override route meta.
    • Runs:
      • after data resolution in SSR (data is real; headData is resolved when declared)
      • on streaming routes, React builds the head at shell-ready (the data snapshot is usually still pending - headData is the resolved-before-render exception); Vue performs its single head build before rendering starts

This separation ensures:

  • routing controls what the page represents
  • rendering controls how it becomes HTML
  • streaming remains safe for SEO
entry-server.tsx
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",
)}">
`,
});

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'
}
}
}

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 happenedWhat taujs doesWhy
Client disconnected mid-fetchBenign abort - the request is torn down, rendering never startsNever do degraded work for a dead request
Loader too slow (deadline hit, request alive)Degrade: render with headData: undefined + an advisory logA slow head fetch should not cost you the page
Loader genuinely failedThe existing error path - a real 500A thrown error is an application defect; hiding it conceals bugs
Loader failed but the route said head.optional: trueReclassified as degradeThe 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 is always fully resolved before headContent runs:

headContent: ({ data, meta }) => `
<title>${escapeHtml(meta?.title || "Products")}</title>
<meta name="description" content="${escapeHtml(data.product.description)}">
`;

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: meta for static defaults and degradation fallback; attr.head -> headData for dynamic, head-critical values; attr.data for the page body - never depend on it for streamed heads.

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).

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)}">` : ""}
`;
};

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.

headContent: ({ headData, meta }) => {
const canonical = headData?.canonical ?? meta?.canonical;
return `
<title>${escapeHtml(meta.title)}</title>
${
canonical
? `<link rel="canonical" href="${escapeHtml(canonical)}">`
: ""
}
`;
};

escapeHtml stops a URL from breaking out of the href attribute, but it does not validate the scheme. If the URL is user-influenced, also reject/allow-list schemes (e.g. permit only https:/http:) so you don’t emit javascript: or data: links.

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 strategies
headContent: ({ 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)}">`
: ""
}
`;
// streaming (and safe everywhere): dynamic -> static -> default
const title = headData?.title ?? meta?.title ?? "Default Title";
// ssr only: route data is fully resolved, so it may join the chain
const ssrTitle = data.title ?? meta?.title ?? "Default Title";

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 attributesescapeHtml (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 → not escapeHtml; escape < as \u003c in the serialised JSON (see Structured Data).

  • URL attributesescapeHtml prevents 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

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).