Start building your site, skip the recurring setup work.
Table of Contents

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 via eleventyComputed).
  • 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.seo defaults and the seo: front-matter keys.
  • Custom schema - writing schema: identity and pieces.
  • Page context - the sibling per-page substrate, same lifecycle.
  • Head module - the consumer; reads _seoGraph at transform-time and emits the tags.
  • Globals - _snapshot.seoGraph, the inspection snapshot.
  • Internals - the registry primitive that backs the cache.

Previous: Page context

Next: Content graph