Features

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 a FaceRenderResult. Accepts an optional cspNonce and allowedOrigin.
  • 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 %s title 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 safe application/ld+json script 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:

  1. createFaceApp({ canonicalOrigin }), normalized to an exact http(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.
  2. x-facetheory-original-host plus cloudfront-forwarded-proto. The AppTheory CloudFront Function writes the viewer host into the FaceTheory-specific header, so this is the reference AWS path.
  3. x-apptheory-original-host plus cloudfront-forwarded-proto, as the compatibility fallback when the FaceTheory-specific header is absent.
  4. x-forwarded-host (falling back to host) plus x-forwarded-proto for 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, or http-equiv;
  • link tags key by rel + href + optional as;
  • script tags key by src or id;
  • style tags key by id or data-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.