Head tags — title, meta, link, script, style — are emitted by FaceTheory in a stable order so that server-rendered HTML and client-hydrated DOM match exactly. Reaching around the head primitive to inject tags directly into a component body breaks the determinism guarantee.
The head primitive
FaceTheory exposes the head primitive and helper-first authoring APIs from the main entry:
import {
canonical,
jsonLd,
metaTag,
normalizeHeadTags,
openGraph,
renderFaceHead,
renderHeadTag,
titleTag,
twitterCard,
type FaceHeadTag,
} from '@theory-cloud/facetheory';
renderFaceHead(out, options)— render the head section from aFaceRenderResult. Accepts an optionalcspNonceandallowedOrigin.normalizeHeadTags(tags, options)— canonicalize an array of head tags (de-duplicate, apply nonces).renderHeadTag(tag)— serialize a single tag to HTML.titleTag(title, { template })— create a deterministic<title>, optionally applying a%stitle template.metaTag(name, content)— create a named<meta>tag.openGraph(...)/twitterCard(...)— create typed Open Graph and Twitter card meta groups.canonical(href)— create a canonical same-origin or http(s) link tag.jsonLd(data, { nonce? })— create a safeapplication/ld+jsonscript tag for structured data.
Canonical origin for strict head validation
Strict CSP validates absolute head URLs against one canonical origin. When
createFaceApp() renders a Face, it supplies renderFaceHead() with an origin
in this precedence order:
createFaceApp({ canonicalOrigin }), normalized to an exacthttp(s)origin. This explicit value overrides every request header and is the supported choice for direct Function URL/dev-server deployments and for a single-origin app that does not trust a forwarding proxy.x-facetheory-original-hostpluscloudfront-forwarded-proto. The AppTheory CloudFront Function writes the viewer host into the FaceTheory-specific header, so this is the reference AWS path.x-apptheory-original-hostpluscloudfront-forwarded-proto, as the compatibility fallback when the FaceTheory-specific header is absent.x-forwarded-host(falling back tohost) plusx-forwarded-protofor non-AppTheory proxies and local development.
The AppTheory-specific headers use the first comma-delimited value because the
reference edge owns and emits a singleton viewer value. The generic
x-forwarded-*/host fallback uses the rightmost comma-delimited value,
assuming a trusted proxy strips or overwrites client values or appends its
authoritative value at the right. Repeated header array elements, including
host, are flattened with the same comma-join semantics as Lambda URL events
before that rightmost value is selected. Do not treat generic forwarded
headers from a direct or untrusted client as a security input. Configure
canonicalOrigin instead.
The selected source fails closed: malformed protocols, userinfo, paths, queries, fragments, or incomplete header pairs produce no allowed origin; a lower-precedence header cannot rescue a malformed AppTheory-specific source. Absolute same-origin links then remain rejected rather than being validated against a doubtful origin. Relative head URLs are unchanged.
FaceTheory normalizes exactly one trailing DNS root dot from both the selected
host and the compared head URL hostname before strict same-origin comparison,
so dotted and dotless forms of real.example resolve symmetrically. A lone
root label or multiple trailing dots remain invalid. This is an explicit
availability decision: the valid dotted and dotless names are DNS-equivalent,
and retaining the dot let a canonical URL self-DoS with a 500. Before
normalization, this was not a shared-cache poisoning path in the reference
deployment because the
original-host headers participate in the reference CloudFront HTML cache
policy’s cache key and FaceTheory rejects 5xx regeneration results before
storing them. This is the CloudFront cache key, not FaceTheory’s
defaultIsrCacheKey, which does not include original-host headers.
SSG has no viewer request from which to derive an origin. Pass the same option
to buildSsgSite({ canonicalOrigin }) when an SSG Face emits an absolute
canonical or other absolute same-origin head URL.
FaceHeadTag shape
type FaceHeadTag =
| { type: 'title'; text: string }
| { type: 'meta'; attrs: FaceAttributes }
| { type: 'link'; attrs: FaceAttributes }
| { type: 'script'; attrs: FaceAttributes; body?: string }
| { type: 'style'; cssText: string; attrs?: FaceAttributes }
| { type: 'raw'; html: string };
Faces declare head tags through FaceRenderResult.headTags:
import {
canonical,
jsonLd,
metaTag,
openGraph,
titleTag,
twitterCard,
} from '@theory-cloud/facetheory';
return {
html: '<h1>Hello</h1>',
headTags: [
titleTag('Hello', { template: '%s · FaceTheory' }),
metaTag('description', 'A FaceTheory page'),
...openGraph({
title: 'Hello FaceTheory',
type: 'website',
url: 'https://app.example/',
image: '/assets/card.png',
}),
...twitterCard({
card: 'summary_large_image',
title: 'Hello FaceTheory',
image: '/assets/card.png',
}),
canonical('/'),
jsonLd({
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Hello FaceTheory',
}),
],
};
Helpers return normal FaceHeadTag objects. They do not create a parallel head
pipeline; de-duplication, nonce application, escaping, and stable ordering still
come from renderFaceHead() / normalizeHeadTags().
For strict CSP routes that set csp.inlineScripts === false, JSON-LD is the one
nonce-carried inline script body FaceTheory permits. Pass the request nonce to
the renderer (renderFaceHead(out, { cspNonce: ctx.request.cspNonce }), or let
createFaceApp() do that for Face responses). The JSON-LD tag must be
type="application/ld+json" and carry the matching request nonce; inline
hydration JSON and generic inline scripts still fail closed.
De-duplication
normalizeHeadTags() de-duplicates tags that have a deterministic key:
- the latest
<title>wins; - meta tags key by
charset,name,property, orhttp-equiv; - link tags key by
rel+href+ optionalas; - script tags key by
srcorid; - style tags key by
idordata-emotion.
Tags without one of those keys are intentionally exempt from de-duplication and
are emitted in order after charset/title normalization. That includes keyless
JSON-LD tags, because pages often need multiple structured-data blocks. Add an
id only when you want normal last-wins de-duplication for a specific JSON-LD
block.
The raw escape hatch
{ type: 'raw', html } inserts HTML verbatim into <head> without escaping or nonce augmentation. Use it only when the caller fully owns the HTML, and never for content that could carry user input. Strict CSP rules disable this path — see Strict CSP.
Structured <style> vs raw HTML
Prefer structured styleTags (which take cssText + optional attrs) over { type: 'raw' } for <style> injection. The structured path lets FaceTheory’s deterministic emission and CSP enforcement apply consistently. See Core Patterns → Emit custom head styles through structured tags.
CSS-in-JS extraction
For React + Emotion, the React adapter wires @emotion/server automatically when you use createReactStreamFace with Emotion-aware components. The extracted CSS is emitted as deterministic <style> tags. For Vue and Svelte, framework-native style emission (Vue scoped styles, Svelte compile-time CSS) flows through the same head primitive.