SEO graph
A resolved per-page object: the canonical URL, the Open Graph and Twitter projections, and a JSON-LD @graph. Built once during the data cascade, cached, and read again at transform-time when the head module emits the tags.
You access it as _seoGraph in templates. It is the per-page sibling of page context — same lifecycle, different payload. Where page context carries the normalised values for <title> and <head> links, the SEO graph carries the structured-data and social surface: the JSON-LD nodes, the share-card metadata, the canonical.
Why it exists
Eleventy's HTML transformer hook, where the head module emits its tags, cannot see the data cascade. The SEO graph is built during the cascade and cached in a registry keyed by page.url, so the transform-time driver can read the resolved object back after the cascade has closed.
It is the same trick page context uses, for the same reason: build once on the cascade, read once at transform-time, never re-derive.
Where it surfaces
- In templates: as
_seoGraph(computed viaeleventyComputed). - In
_snapshot: every built graph lives under_snapshot.seoGraph, keyed by URL. No underscore — it is the inspection snapshot surface. See Globals. - At transform-time: read by the head module via the registry's
getByKey(page.url)lookup.
What you write
You do not write _seoGraph. You write two input keys and the plugin derives the handle from them, plus the site defaults.
| Key | Where | Carries |
|---|---|---|
settings.seo |
site config | SEO site defaults: preserveQueryParams, ogImage, openGraph, twitter |
seo: |
page front matter | per-page presentation overrides: title, description, canonical, the OG and Twitter fields. seo.foo wins over a bare foo. |
schema: |
_data/schema.js + front matter |
schema.org identity and extension: organization, person, pieces |
The split: seo: is how the page presents (the share card, the canonical); schema: is what the page is (who published it, plus any extra nodes). The full key lists live in Site settings (settings.seo and the seo: keys) and Custom schema (the schema: surface).
One naming point worth holding: you write schema, you read the built form at _seoGraph.schema. Same name, different shapes — input schema is { organization, person, pieces }; output _seoGraph.schema is the flat, resolved array of schema.org nodes. The name is shared because one is the built form of the other, not because they match.
The SEO graph shape
Four keys.
_seoGraph = {
url, // resolved canonical URL; undefined when noindex or no resolvable input
schema, // resolved JSON-LD @graph: flat array of schema.org nodes
openGraph, // Open Graph projection: type, title, description, url, image, locale, article fields
twitter // Twitter card projection: card, site, creator, title, description, image
};
schema is the assembled @graph: a WebSite node, an Organization or Person identity, the page's WebPage (and an Article where the type calls for one), a BreadcrumbList, image and translation refs, and any nodes you supplied through schema.pieces. The emitted JSON-LD wraps this array as @graph.
Fields you set on seo: that the resolver does not overwrite pass through onto the object unchanged.
Reading it in a template
Most pages never touch _seoGraph — the head module reads it and emits the canonical, the OG and Twitter metas, and the JSON-LD for you. Reach for it when you want to inspect the resolved values:
{# The resolved canonical, the same value the head module emits #}
<link rel="canonical" href="{{ _seoGraph.url }}">
{# The Open Graph type the page resolved to #}
<meta property="og:type" content="{{ _seoGraph.openGraph.type }}">
_seoGraph is null on pages that opt out (see below), so guard the access if your template runs on those.
Enriching the graph at render time
Because _seoGraph is on the cascade, a render-time slot can extend it before the head emits. A function the template calls receives the resolved object on this.ctx._seoGraph, and a mutation of this.ctx._seoGraph.schema rides through to the head driver — it is the same cached object, read back at transform-time.
The docs site's FAQ page does this: a front-matter function, called from the body, patches the resolved FAQPage node with questions built from a collection. The head then emits the enriched JSON-LD. It is an advanced seam — most pages set schema: in front matter and never reach for it — but it is there when a node needs values that only exist after the cascade resolves.
Opt out: _internal: true
Templates that set _internal: true, or that have a non-HTML output extension, are skipped — _seoGraph resolves to null. Sitemaps, feeds, schema endpoints, and the markdown-sibling renderer all opt out this way: they are not pages that need a share card or a JSON-LD graph.
See also
- Site settings -
settings.seodefaults and theseo:front-matter keys. - Custom schema - writing
schema:identity andpieces. - Page context - the sibling per-page substrate, same lifecycle.
- Head module - the consumer; reads
_seoGraphat transform-time and emits the tags. - Globals -
_snapshot.seoGraph, the inspection snapshot. - Internals - the registry primitive that backs the cache.