0008: Per-component head / meta tag management
Status: Accepted Date: 2026-05-10
Context
Purity SSR apps render a component to an HTML string that the user splices into a hand-written shell:
<!doctype html>
<html>
<head>
<title>Purity App</title>
<meta name="description" content="…" />
</head>
<body>
<div id="app"><!--ssr-outlet--></div>
</body>
</html>
The <head> lives outside the rendered component. That makes per-page
title / meta / link tags awkward: users either accept one static head
shared by every route or write their own per-request shell-template
substitution. Every shipping framework — Next, SolidStart, SvelteKit,
Astro, Nuxt — exposes some way for components to contribute head tags
(<Title>, next/head, <svelte:head>, etc.). Purity's omission was
a known gap in the SSR MVP.
The gap is small in scope (one new function, one new option on
renderToString) but the API has to commit to a few decisions:
- Where do head tags live in source? Inline JSX-style children
inside a
<Head>wrapper, or function calls? - How do they propagate to the SSR output? Side-channel collected
on
SSRRenderContext, or a separate render pass that walks the rendered output for<head>candidates? - What's the client story? Server-only, no-op on client, or full
reactive
<head>element management?
This ADR ships Phase 1: function-call API, SSRRenderContext side- channel, server-only collection. Phase 2 (reactive client-side head) gets its own ADR once we have a real user driving it.
Decision
Add head(content) to @purityjs/core and an extractHead
option to renderToString that returns the collected head HTML
alongside the body. Function-call API, side-channel collection on
the SSRRenderContext, server-only effect for Phase 1.
import { head, html } from '@purityjs/core';
function PageHead({ title, description }: { title: string; description: string }) {
head(html`<title>${title}</title>`);
head(html`<meta name="description" content="${description}" />`);
}
function App() {
return html`
${PageHead({ title: 'My Page', description: 'Welcome' })}
<main><h1>Hi</h1></main>
`;
}
// Server
const { body, head: headHtml } = await renderToString(App, { extractHead: true });
const finalHtml = template.replace('<!--head-->', headHtml).replace('<!--ssr-outlet-->', body);
Concretely:
head(content)acceptsSSRHtml, a thunk returningSSRHtml, or any value the sharedvalueToHtmlcoercion understands (primitives, arrays, branded SSR HTML). The thunk is called once, the result is coerced to HTML, and the string is appended tossrCtx.head[]on the currentSSRRenderContext.extractHead: trueonrenderToStringflips the return shape fromPromise<string>toPromise<{ body, head }>. Thebodyfield is byte-for-byte identical to the legacy string return; only the envelope changes. The TypeScript overload is keyed on the option, so apps that don't pass it keep the legacy return.- Multi-pass behavior. The renderer resets
ctx.headat the start of each pass and captures the final pass's value. Resource-dependent head content (head(html\<title>${() => res()}</title>`)`) ends up with the resolved value, not the loading placeholder. - Client.
head()is a no-op when there is noSSRRenderContexton the stack. The browser already shows the SSR-rendered<head>; client-side reactive head element management is out of scope here. - Outside any render context. Bare
head()calls (tests, ad-hoc client code) are silent no-ops. No throw, no warn — too much ceremony for what is effectively a side-effect dropper.
Streaming (renderToStream) does not consume head() calls in
this ADR. The shell flushes before head() collection completes per
boundary, and the head section has already been parsed by the browser
by the time later chunks arrive. Streaming support is a follow-up
that's likely to require either an HTML5 <head> parser hook or
moving head tags to a deferred-script injection model.
Consequences
Positive:
- One new function plus one option closes the longest-standing
framework-level SSR gap. Apps with per-page title / description /
canonical / OG tags work end-to-end with the existing
renderToStringflow. - Zero breaking change. The default
renderToStringreturn type and byte output are unchanged. Apps that don't callhead()see no difference. - The collected HTML uses the same
valueToHtmlcoercion as everything else — branded SSR HTML, escape rules, and reactive accessors all Just Work. Users can call any composable head helper they want, no custom escaper needed. - The side-channel is on
SSRRenderContext, which already exists. No new global state, no new lifecycle phase.
Negative:
- No client-side head management in Phase 1. SPAs that transition
between routes client-side won't update
<title>fromhead(). Users mutatedocument.titledirectly until Phase 2. - No deduplication. Multiple
head()calls that emit<title>elements all end up in the head — the browser uses the first. Real apps will want a single<Title>helper that coordinates dedup, but that's userland for now. - Streaming SSR doesn't see
head()output. Apps that stream and want per-page head must userenderToStringinstead, or manage the head outside the framework.
Neutral:
- The function-call API (vs. a
<Head>JSX-style component) matches Purity's overall ethos —state,compute,watch,resource, and nowheadare all plain functions. No new component shape. - Package boundary decision (Phase 1).
head()lives in@purityjs/corerather than a new@purityjs/headpackage. The function is 12 lines and entirely tree-shakable, so apps that don't call it pay zero bytes. The user-facing call site (inside a component) naturally sits next tohtml/state/watchin the same import. Re-evaluate when Phase 2 lands — reactive client head element management + deduplication + OG/Twitter/JSON-LD helpers will earn a dedicated package; we'll split into@purityjs/headwith peer-deps on@purityjs/core(forgetSSRRenderContext,watch) at that point. The import path change is breaking but contained — userland updates one line per file.
Alternatives considered
JSX-style <Head> wrapper component. Solid/Next style. Rejected:
Purity doesn't have JSX. Adding a Custom Element variant would create
two patterns (an html template inside a <purity-head> wrapper) and
an ordering problem (the wrapper's children would need to be hoisted
out of the rendered body and into the head section, which conflicts
with the streaming Suspense model in ADR 0006).
Always include head() output in the body string at a marker.
The user finds <!--purity-head-->...<!--/purity-head--> in the
output and moves it. Rejected: forces every consumer to parse the
output, and "find the marker pair in a string" is exactly what we
gave users a typed API to avoid.
Add head() to @purityjs/ssr instead of @purityjs/core.
Considered. Rejected because head() is the only sensible way for
user components — which import from @purityjs/core — to contribute
to the head from inside a component. Splitting the API would force
users to import a server-only function from the client bundle entry
purely for declaration ergonomics.
Synchronous head injection during the render pass. Add a hook to
generateSSR that writes head tags inline to a separate buffer.
Rejected: would require parser/codegen changes for a feature that
fits naturally into a runtime side-channel.
Full reactive client-side <head> management in Phase 1. Each
head() call becomes a reactive watcher that mutates
document.head. Rejected for scope — needs a deduplication strategy
(replace existing <title>, append new <meta>), cleanup on
unmount, and hydration-time matching against SSR-rendered head
elements. Phase 2.