Pagedeck

Page head

Every document pagedeck build emits has a <head> the framework writes. By default it holds a character encoding and the page's stylesheets, and nothing that describes the page — a title, a description or an Open Graph image would be content, and the build never invents content a site did not write.

The <body>'s one landmark is written the same way: see Page body.

build.head is where a site writes it. It is a callback, taking the same two arguments build.content takes: the page, and the store.

build: {
  outDir: "./site",
  head: (page, store) => {
    const entry = getEntry(store, articles, page.entry);
    return { title: entry.data.title };
  },
  // ...
}

Return undefined, or an object with no fields set, and the page's head is exactly what it would have been without the callback. Nothing empty is written: a page with no title has no <title> element, not an empty one.

The fields

Field What it writes
title <title>, and <meta property="og:title">
description <meta name="description">, and og:description
image <meta property="og:image">
jsonLd <script type="application/ld+json">

og:title and og:description are written from title and description rather than declared separately, so a page's tab and its share card cannot disagree about what the page is called.

image is a URL, and the build does nothing with it but write it: it is not resolved, not fetched and not checked — bring the URL your CMS already holds.

Cards the build draws

A build can draw the image instead of being handed one. build.socialImages takes a renderer and a per-page inputs callback; the build content-hashes what the renderer draws into /social/, and the URL arrives in this same image field, followed by og:image:width, og:image:height and twitter:card written from the size the renderer reported. Core ships no renderer — @pagedeck/social-image is the reference one. Return undefined from inputs for a page that gets no card.

A page's components can show its own card. The build draws every card before it renders any page, so useSocialCard() from @pagedeck/core/tree hands a component its page's card: href, the same URL the page's og:image names, and the width and height the renderer reported. It answers undefined on a page that gets no card, on a site with no build.socialImages, under pagedeck dev, which draws no cards, and in preview. It works only in a component the page renders on the server: an island that calls it fails the build, because the browser re-renders an island without it. An island that needs the card takes it as a prop. The landing site's /features/ page shows its card this way.

Drawing first has one consequence you can see: your head, inputs and renderer run before your content and chrome callbacks, so a renderer that fails stops the build before any page renders. The order is the same on every build.

A page cannot have both. A page that inputs returns inputs for and that build.head declares an image for fails the build, naming every such page at once: two social images for one page is a contradiction only you can settle, and picking one would either discard the URL you wrote down or write a card nobody sees.

Watch for this where the site declares a fallback — a head callback that returns the same image on every page, with socialImages asked for on the posts. That site is refused whole. Return undefined from inputs for the pages that keep the fallback, or stop returning image for the pages that get a card.

Per page type

There is no per-template registry, because the page you are handed already carries its template. Dispatch on it:

head: (page, store) => ({
  title: titleOf(page, store),
  jsonLd: {
    "@context": "https://schema.org",
    "@type": page.template === "article" ? "Article" : "WebPage",
  },
}),

Structured data

jsonLd takes one JSON-LD node object, or an array of them, and the build writes it as one <script type="application/ld+json"> element. An empty array writes no element.

The framework does not check your vocabulary — which terms schema.org defines is your decision, exactly as the shape of an entry is. What it guarantees is the encoding.

Escaping

Every field is content, and content comes from a CMS. The build escapes all of it, and the guarantees are worth stating exactly:

  • A title goes in as character data with every & and < encoded, so no value can start a tag or end the element.
  • A meta value goes into a double-quoted attribute with every & and " encoded, so no value can close the attribute.
  • A JSON-LD payload is serialized with JSON.stringify and then has every < replaced with the JSON escape \u003c. Not the </script sequence — every <, with no pattern involved. That is what makes a payload containing </script><script>alert(1)</script> inert by construction rather than by a filter being written correctly: with no < in the block, there is no input that can end it early.

\u003c is ordinary JSON string escaping, so every consumer — including Google's parser — reads back the identical string. Nothing you publish is altered; it is spelled differently in the file.

Metadata a component renders

A component may render <title> and <meta> itself. React hoists them out of where you wrote them, and because each island is rendered as its own fragment they would land in <body> — where a crawler reading head metadata does not look and where two islands leave two <title> elements. The build takes them out of the body and writes them into the <head> instead, after the fields build.head declared.

Two of them claiming one thing with two different values fails the build, naming both components and both values:

Entry /en/home: 2 claims on <title> disagree — a document holds one <title>, and the build absorbs into the <head> what a render hoisted rather than picking a winner; make the claims agree, or leave one:
  "Alpha" — "from Alpha"
  "Beta" — "from Beta"

A document holds one title, and there is no honest way to pick: whichever won would be decided by which island hydrated first, which depends on where the reader scrolled. Claims that agree are not a conflict — a shared component that sets a title can be on a page twice, and the head gets one element.

What collides is the thing claimed, not the tag. A <meta> is claimed by the first of these it carries:

Attribute Example
charset <meta charSet="utf-8">
name <meta name="description">
property <meta property="og:title">
http-equiv <meta http-equiv="refresh">

So two og:image elements are ordinary and two og:title elements are not, and a <meta> carrying none of the four is written as often as you rendered it. charset and http-equiv are in the list because they speak for the whole document too — a second charset restarts the parser, a second refresh decides where the page goes.

build.head is a claimant too: a component that sets a title on a page whose head callback also set one is the same conflict, and so is a component that contradicts the <meta charset> the build writes. A <meta itemprop> is microdata rather than document metadata — React leaves it where you wrote it, and so does the build.

A stylesheet is the one thing this does not extend to. A component that declares one with React's precedence prop is refused, because a sheet that reaches the document this way outranks every stylesheet the build placed. Import it from the component's module instead.

What the head carries besides this

<link rel="canonical"> and <link rel="alternate" hreflang="…"> are written from your locale map rather than from this callback — you declare an origin once and the build derives every page's links. See Canonicals and hreflang.

They sit between the metadata above and the page's stylesheets, and the order of the head's children is fixed: adding a field here never moves one of them.

The page a build.routing.notFound rule names gets neither link. It gets <meta name="robots" content="noindex"> in their place, with or without an origin. See Routing.

Code that has to run before the paint

One slot in the head is yours: build.prePaint is a list of scripts, and the build writes each one into a <script> in the head, in front of the page's stylesheets and therefore in front of everything else the page does.

build: {
  outDir: "./site",
  prePaint: [
    `document.documentElement.dataset.theme = localStorage.theme || "light"`,
  ],
  // ...
}

Each entry is the JavaScript to run, not an element — the head has one writer, and it writes the <script> around what you declare.

It exists for state a page has to read before a visitor sees anything. A theme kept in localStorage is the plain case: set it in an island and the page shows the other theme first and then corrects itself. A returning visitor's consent decision is the same shape and matters more, because the script it holds back is somebody else's — see Third-party scripts.

Four things are worth knowing before you use it:

  • It is synchronous and it is first. The browser stops parsing, runs your code, and only then reaches this page's stylesheets. A slow snippet is a slow page, so keep it to reading one value and setting one thing.
  • It runs before your islands, your entry chunk and any third-party script. That ordering is the whole feature: everything else on the page is behind it.
  • The build refuses a snippet it cannot carry. </script ends a script element wherever it appears, and <!-- changes how the parser reads the rest of one, so a snippet holding either is a config failure naming the entry. Nothing is escaped for you: a program is not data, and an encoder that rewrote a < would be rewriting your code.
  • It is an inline script, and a strict Content-Security-Policy has to allow it. script-src 'self' does not cover a <script> with no src, so a policy that strict needs either a hash of these exact bytes or a nonce on the element. The build writes your entry unchanged, so the hash is one you can compute from your own source. What the framework does not do is either half of the wiring: it mints no nonce and emits no policy — a CSP is yours to write, for the reason Routing gives. The build records the hash of each of these scripts on every page's row in manifest.json, beside the hashes of the framework's other inline scripts, and Third-party scripts shows how to build a policy from them.

Declare none and your documents are byte for byte what they were before this field existed.