SEO Head Injection
Design Log #127 — SEO Head Injection
Written for AI agents. See Log Methodology Note below for details.
Background
Jay-html templates have a <head> and a <body>. The compiler processes <body> for dynamic bindings (data, conditionals, forEach, refs). The <head> is used for static content: headless imports, stylesheets, and static <title>/<meta> tags. Dynamic bindings like {value} in <head> elements are not supported.
This means there is no way to render SEO data (title, meta description, Open Graph tags, canonical URLs, etc.) from ViewState into the page's <head> during SSR.
Problem
The Wix Stores product page contract defines an seoData structure:
- tag: seoData
type: sub-contract
tags:
- tag: tags
type: sub-contract
repeated: true
trackBy: position
tags:
- { tag: position, type: data, dataType: string }
- { tag: type, type: data, dataType: string } # element name: meta, title, link, etc.
- tag: props
type: sub-contract
repeated: true
trackBy: key
tags:
- { tag: key, type: data, dataType: string } # attribute name
- { tag: value, type: data, dataType: string } # attribute value
- tag: meta
type: sub-contract
repeated: true
trackBy: key
tags:
- { tag: key, type: data, dataType: string }
- { tag: value, type: data, dataType: string }
- { tag: children, type: data, dataType: string } # inner text (e.g. for <title>)
- tag: settings
type: sub-contract
tags:
- { tag: preventAutoRedirect, type: data, dataType: boolean }
- tag: keywords
type: sub-contract
repeated: true
trackBy: term
tags:
- { tag: term, type: data, dataType: string }
- { tag: isMain, type: data, dataType: boolean }
This structure represents serialized HTML elements for the <head>:
typeis the element name (meta,title,link,script)propsare the attributes (name/content,property/contentfor OG,rel/hreffor canonical)childrenis inner text (e.g., the text inside<title>)
An AI agent tried to bind this in the template body:
<div forEach="p.seoData.tags" trackBy="position">
<meta forEach="props" trackBy="key" name="{key}" content="{value}" />
</div>
This doesn't work because:
<meta>tags must be in<head>, not<body>- Jay-html doesn't support dynamic element names (the
typefield determines the tag name) - Jay-html doesn't support dynamic attribute names (
name="{key}"— the attribute name itself is dynamic) - Even if these were supported,
<div>wrapping<meta>is semantically wrong
Questions
Q: Should SEO data be rendered by the jay-html compiler or by the SSR pipeline? A: By the jay-html compiler, rendered as part of
<head>during SSR. Head elements are static — rendered only in SSR, never hydrated. They supportslowandfastphases only, nointeractive. They must be part of the initial HTML response for optimal SEO (not injected after).Q: Should we support a general mechanism for dynamic
<head>content, or a specific SEO-focused one?Q: Should this be slow-phase only, or also support fast phase? A: Both slow and fast. No interactive — head elements are SSR-only.
Q: How does hydration work for head elements? A: It doesn't. Head elements are rendered during SSR and left as-is. No hydration, no client-side updates.
Q: Should the seoData contract structure be generic (any HTML tag) or typed (specific SEO tag types)? A: Jay's philosophy is that components provide data and templates decide how to render it. The template should control the head structure — what
<meta>tags, what<title>, what attributes. This keeps us forward-compatible with any new header structure. The component provides the data (product name, description, image URL); the jay-html template maps it to specific head elements.
Design Options
Option A: Template-driven head bindings
Extend the compiler to support dynamic bindings in <head>. The template controls the head structure; the component provides the data.
Simple bindings
Standard {expression} syntax in <head> for known tag shapes:
<head>
<title>{p.productName} | My Store</title>
<meta name="description" content="{p.description}" />
<meta property="og:title" content="{p.productName}" />
<meta property="og:image" content="{p.mainImageUrl}" />
<link rel="canonical" href="{p.canonicalUrl}" />
</head>
Generic bindings (for dynamic tag/attribute names)
Two new head-only directives for fully data-driven structures:
jay-element="{type}"— rendered element name comes from datajay-spread="props"— a{key, value}[]sub-contract is spread as HTML attributes
<head>
<meta forEach="p.seoData.tags" trackBy="position"
jay-element="{type}" jay-spread="props">{children}</meta>
</head>
Renders the Wix seoData structure as-is — each tag becomes its type element with props as attributes and children as inner text.
Contract implications
The contract can be either:
- Flat named fields (seoTitle, seoDescription, etc.) — for simple bindings
- Generic structure (tags with type/props/children) — for jay-element/jay-spread
The component decides how to expose the data; the template decides how to render it.
Head binding rules
- SSR only — rendered during slow and fast phases. No interactive phase, no hydration.
- forEach supported — for generic structures (iterating over tags).
- No conditionals — simplifies implementation. All head elements are always rendered.
- No refs — head elements are not interactive.
- jay-element and jay-spread are head-only — not supported in
<body>. - Static head elements pass through — stylesheets, headless imports, jay-params are unchanged.
Compilation target
The compiler generates a head render function that takes ViewState and returns an HTML string:
// Generated: page.jay-html?jay-head.ts
export function renderHead(viewState: ViewState): string {
let html = '';
// Simple bindings
html += `<title>${escapeHtml(viewState.p.productName)} | My Store</title>`;
// Generic bindings (forEach + jay-element + jay-spread)
for (const tag of viewState.p.seoData.tags) {
html += `<${escapeTag(tag.type)}`;
for (const prop of tag.props) {
html += ` ${escapeAttr(prop.key)}="${escapeAttr(prop.value)}"`;
}
html += tag.children ? `>${escapeHtml(tag.children)}</${escapeTag(tag.type)}>` : ` />`;
}
return html;
}
Pros
- Follows Jay philosophy — template controls output, component provides data
- Handles both simple (flat fields) and generic (Wix seoData) structures
- Designer controls what goes in
<head> - Forward-compatible — new SEO standards just need new template lines
Cons
- Requires compiler changes (parser, new compilation target)
- Two new directives (
jay-element,jay-spread) to learn jay-element/jay-spreadhave security implications (dynamic tag names, attribute names) — must escape carefully- More implementation effort
Option B: Component-driven head tags
The component's slow/fast render returns head tags alongside ViewState. The SSR pipeline appends them to <head>. No template involvement.
Raw string API
.withSlowlyRender(async (props, db) => {
const product = await db.getProduct(props.slug);
return phaseOutput(
{ title: product.name, price: product.price },
{ productId: product.id },
{
headTags: [
`<title>${escapeHtml(product.name)} | My Store</title>`,
`<meta name="description" content="${escapeAttr(product.description)}" />`,
`<meta property="og:title" content="${escapeAttr(product.name)}" />`,
],
},
);
})
Typed API (safer)
return phaseOutput(
{ title: product.name },
{},
{
headTags: [
{ tag: 'title', children: product.name + ' | My Store' },
{ tag: 'meta', attrs: { name: 'description', content: product.description } },
{ tag: 'meta', attrs: { property: 'og:title', content: product.name } },
{ tag: 'link', attrs: { rel: 'canonical', href: canonicalUrl } },
],
},
);
The SSR pipeline serializes these into HTML and appends to <head>.
Mapping the Wix seoData structure
The component maps the generic seoData directly to headTags:
headTags: product.seoData.tags.map(tag => ({
tag: tag.type,
attrs: Object.fromEntries(tag.props.map(p => [p.key, p.value])),
children: tag.children,
})),
Pipeline integration
phaseOutput gains an optional third parameter for head metadata. The dev server and SSR renderer serialize headTags and inject them into <head> before sending the response.
1. SSR: run slow/fast phases → get ViewState + headTags
2. Serialize headTags to HTML strings
3. Inject into <head> (after static elements like stylesheets)
4. Render body as before
Pros
- No compiler changes — works with existing infrastructure
- Simple implementation — just extend
phaseOutputand the SSR pipeline - Handles the generic Wix seoData structure naturally
- Typed API prevents XSS (framework handles escaping)
- Easy to implement incrementally
Cons
- Breaks Jay philosophy — component controls head output, not the template
- Designer has no visibility or control over what goes in
<head> - Head content is not visible in the jay-html template
- Raw string API is error-prone (XSS risk); typed API mitigates this
phaseOutputAPI changes (new parameter)
Comparison
| Aspect | Option A (template-driven) | Option B (component-driven) |
|---|---|---|
| Jay philosophy | Follows (template controls) | Breaks (component controls) |
| Compiler changes | Yes (parser, new target) | No |
| Implementation effort | High | Low |
| Designer control | Full | None |
| Generic structures | Yes (jay-element/jay-spread) | Yes (component maps data) |
| Simple cases | Clean (<title>{name}</title>) |
Verbose (phaseOutput 3rd arg) |
| XSS safety | Must escape in generated code | Typed API handles escaping |
| Forward-compatible | Yes (add template lines) | Yes (add headTags in code) |
| Visibility in template | Head structure visible | Head structure hidden in code |
| Head tags are... | Invisible UI infrastructure | Invisible UI infrastructure |
Pragmatic consideration
Head tags (<title>, <meta>, <link rel="canonical">) are invisible infrastructure — there's nothing to "design." A designer doesn't need to control whether og:title uses property or name as the attribute. This is different from body elements where visual layout matters. Option B's pragmatism may be justified here, even though it departs from Jay's general philosophy.
Decision: Option B (component-driven)
Option B chosen for pragmatism — head tags are invisible infrastructure, no compiler changes needed, handles generic structures naturally.
Head Tag Collision
Multiple sources can produce headTags during a single page render. Collisions must be resolved.
Sources of headTags
- Page component (
page.ts) — slow and fast phases - Page-level headless plugin components — slow and fast phases
- Nested headless components (inside forEach or sub-components)
- Nested headfull FS components (have their own jay-html + component)
- Repeated nested components (inside forEach — multiple instances)
Collision scenarios
| Scenario | Example | Risk |
|---|---|---|
Two page-level headless components both declare <title> |
Product plugin + SEO plugin | High — common |
Page component and headless plugin both declare og:title |
Page sets title, plugin sets OG | High — common |
| Nested headless inside forEach declares meta tags | Each product in a list declares its own <title> |
Medium — likely a bug |
| Nested headfull FS component declares meta tags | A header component adds its own meta | Low — unusual |
Same component's slow and fast phases both declare <title> |
Fast phase updates the title from slow | High — expected |
Identity: what makes two head tags "the same"?
<title>— singleton, only one per page<meta name="X">— keyed bynameattribute<meta property="X">— keyed bypropertyattribute (Open Graph)<meta charset="X">— singleton<link rel="canonical">— singleton<link rel="X">— keyed byrel+hrefcombination- Other tags — keyed by tag name + all attributes (exact match)
Resolution strategy
Last-write-wins with defined ordering:
- Collect headTags from all sources during slow phase
- Collect headTags from all sources during fast phase
- Merge: fast overwrites slow (same key)
- Within a phase, ordering determines priority:
- Page-level headless components: ordered by
<script type="application/jay-headless">position in the template - Page component: processed after headless components
- Nested components: contribute to their parent's headTags (bubbled up)
- Page-level headless components: ordered by
This means:
- The page component has final say (it runs last, can override plugins)
- Fast phase overrides slow phase (more recent data wins)
- Nested components inside forEach — headTags are collected from all instances but duplicates are deduplicated by key. This is likely a mistake — warn at dev time.
Questions
Q: Should nested components inside forEach be allowed to declare headTags? A: No. HeadTags from components inside forEach are ignored.
Q: Should nested headfull FS components be allowed to declare headTags? A header component might legitimately want to add navigation-related meta tags. But it could also conflict with the page's SEO tags.
Q: Should the pipeline warn on collisions or silently resolve them? A: Warn on collision.
Q: Should headTags merge across slow→fast, or does fast replace slow entirely? A: Fast replaces slow entirely (no merge).
Implementation Plan
Phase 1: Typed headTag API
- Define
HeadTagtype:{ tag: string; attrs?: Record<string, string>; children?: string } - Extend
PhaseOutputwith optionalheadTags: HeadTag[] - Extend
phaseOutput()helper to accept the third parameter
Phase 2: Collection and merging
- After slow phase: collect headTags from page component and all headless instances
- After fast phase: collect headTags, merge with slow (fast overrides by key)
- Implement key extraction:
<title>→title,<meta name="X">→meta:name:X, etc. - Deduplicate by key, last-write-wins
Phase 3: Serialization and injection
- Serialize merged headTags to HTML strings (with proper escaping)
- Inject into
<head>of the SSR response, after static elements - Handle in both dev server and SSR streaming renderer
Phase 4: Nested component support
- Define how nested components (headless, headfull) bubble headTags up to the page level
- forEach instances: warn if multiple instances produce headTags
Phase 5: Validation and warnings
- Warn on collision between different components at dev time
- Warn if forEach-nested components produce headTags
- Log which component's headTag won in verbose mode
Phase 6: Tests
- Dev-server SSR test: page component returns headTags in slow phase → verify
<head>contains rendered tags - Dev-server SSR test: headless plugin returns headTags → verify
<head>contains rendered tags - Dev-server SSR test: fast phase replaces slow phase headTags entirely
- Dev-server SSR test: collision between page and headless → warn, last-write-wins
- Dev-server SSR test: forEach-nested component headTags are ignored
- Dev-server SSR test: static head elements (stylesheets, headless imports) are unaffected
- Verify no hydration code references head content
Phase 7: Documentation
- Add docs to
/docsfolder explaining the headTags API - Update the plugin role agent-kit template:
- Add a
seo-guide.mdtoagent-kit-template/plugin/explaining how to declare headTags inphaseOutput - Cover: typed API, mapping generic SEO data, collision rules, forEach restriction
- Reference from
plugin/INSTRUCTIONS.md
- Add a
Verification Criteria
- A product page renders
<title>Product Name | My Store</title>in the HTML<head>during SSR <meta>tags with dynamic content render correctly in<head>- Static head elements (stylesheets, headless imports) are unaffected
- No hydration code is generated for head content
- When two components declare the same meta tag, warn and last-write-wins with defined ordering
- Fast phase headTags replace slow phase headTags entirely
- forEach-nested components' headTags are ignored
- Existing tests continue to pass
Implementation Results
Files Changed
| File | Change |
|---|---|
full-stack-component/lib/jay-stack-types.ts |
Added HeadTag interface, added optional headTags to PhaseOutput |
full-stack-component/lib/render-results.ts |
Added optional 3rd options param to phaseOutput() |
NEW stack-server-runtime/lib/head-tags.ts |
tagIdentityKey(), mergeHeadTags(), serializeHeadTags() with HTML escaping |
stack-server-runtime/lib/index.ts |
Export head-tags module |
stack-server-runtime/lib/slowly-changing-runner.ts |
Collect headTags from page parts and instances into carryForward.__slowHeadTags |
stack-server-runtime/lib/fast-changing-runner.ts |
Collect headTags from page parts and static instances (not forEach); store on returned PhaseOutput |
stack-server-runtime/lib/generate-ssr-response.ts |
Accept headTags param, serialize into <head>, replace hardcoded title if component provides one |
dev-server/lib/dev-server.ts |
Thread headTags through handleCachedRequest → sendResponse → generateSSRPageHtml |
NEW stack-server-runtime/test/head-tags.test.ts |
23 unit tests for identity keys, merging, collision warnings, serialization, escaping |
NEW dev-server/test/12a-page-head-tags/ |
Integration fixture: page component returns headTags from slow phase |
dev-server/test/hydration.test.ts |
Added fullHtmlChecks option to TestFixtureOpts, added 12a test case |
Deviations from Design
None — implementation follows the design as specified.
Static Head Tags from Jay-HTML (June 2026)
DL#127 originally only supported head tags from component phaseOutput() (dynamic, rendered at slow/fast time). Static head tags declared in the jay-html <head> section (<title>, <meta>, <link>) were not rendered in the SSR output.
Change: Static head tags from jay-html <head> are now extracted during parsing (headMeta on JayHtmlSourceFile), converted to HeadTag[] via headMetaToHeadTags(), and merged as the lowest-priority source. Component head tags (slow/fast) override them via the existing mergeHeadTags last-write-wins dedup.
Priority chain (lowest to highest):
- Jay-html
<head>tags (static defaults) - Slow phase
phaseOutput({ headTags }) - Fast phase
phaseOutput({ headTags })— overrides both
Files changed:
stack-server-runtime/lib/generate-ssr-response.ts—headMetaToHeadTags()convertsJayHtmlHeadMetatoHeadTag[]; dev SSR merges static + component tagsproduction-server/lib/types.ts—headMetaadded toRouteEntryproduction-server/lib/builder/server-element-compile.ts— extractsheadMetaat build timeproduction-server/lib/builder/build-pipeline.ts— persistsheadMetain route manifestproduction-server/lib/serve/fetch-page-handler.ts— production SSR merges static + component tags
Smoke tests: Verified in both dev and production modes — static title/description/canonical from jay-html render correctly, and fast-phase headTags override the static title.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.