---
title: 'Content graph'
description: 'The queryable map of every rendered page, exposed to templates as _node, _backlinks, _outgoing, and the _navigator global.'
slug: 'content-graph'
type: 'article'
date: 2026-05-14T00:00:00.000Z
lang: 'en'
url: 'https://www.eleventy-baseline.dev/docs/core-reference/content-graph/'
---

A pre-rendered map of every page's headings, images, links, and excerpts, plus the links between pages. Built once before Eleventy's main build by a programmatic dry-run of the site; cached at `.cache/_baseline/content-graph.json`. Templates read it through cascade keys; modules read it through `coreContext.runtime.contentGraph`.

---

## Why it exists

Eleventy's data cascade can read front matter; it cannot read what a page actually rendered. The content graph fills that gap.

A pre-pass renders every page once, extracts the structured content (headings, links, images, excerpt) from the result, and writes it to a queryable record before any consumer template runs.

The pre-pass attaches to Eleventy's `eleventy.before` event, so the graph rebuilds at the start of every cycle: initial build, watch rebuild, production build. The cached file is the cold-start handoff; in-memory is the hot path.

---

## Where it surfaces

- **Cascade keys (per page)**: `_node`, `_backlinks`, `_outgoing`. See [[globals | Globals]] for shape.
- **Cascade key (reserved)**: `_edges`, listed in `INTERNAL_KEYS` but currently unbound.
- **Nunjucks global**: `_navigator` carries `{ nodes, edges, backlinks }`. Registered by the navigator module.
- **Runtime read**: modules read through `coreContext.runtime.contentGraph` (a getter). The getter form lets rebuilds reassign the underlying reference without re-registering listeners.

---

## The graph shape

Three top-level keys.

```js
{
  nodes: {
    "/en/foo/": {
      // Identity (from data._pageContext via dataFilterSelectors)
      title, slug, description, section, type, lang, locale, date, url,
      // Extracted (from the rendered DOM)
      excerpt, headings, images
    }
  },
  edges: [
    { internal, from, to, type, text }
    // One entry per <a href> in each page's extracted root.
  ],
  backlinks: {
    "/en/foo/": [
      { url, title?, excerpt? }
      // Pre-joined with the source page's title and excerpt.
    ]
  }
}
```

Identity fields come from `data._pageContext` via `dataFilterSelectors.add('_pageContext')` on the pre-pass Eleventy. Extracted fields come from the rendered DOM. The two halves are merged in the graph builder; each node is a single flat record.

Edges are flat. One per anchor in the extracted root. `to` is origin-stripped for known origins (the dev server, `process.env.URL`, `settings.url`); external URLs are left alone. Not deduplicated.

---

## Reading it in a template

---

### `_node.headings` for a table of contents

`_node.headings` is `Array<{ level, text, id }>`. The first heading is usually the page's `<h1>`; skip it and render the rest:

{% raw %}

```nunjucks
{% set toc = _node.headings %}
{% if toc.length %}
  <nav class="toc" aria-labelledby="toc-title">
    <p id="toc-title">On this page</p>
    <ol>
    {% for heading in toc.slice(1) %}
      <li class="toc-level-{{ heading.level }}">
        <a href="#{{ heading.id }}">{{ heading.text }}</a>
      </li>
    {% endfor %}
    </ol>
  </nav>
{% endif %}
```

{% endraw %}

`id` is the live DOM id when the rendering pipeline anchored the heading; otherwise a slugified fallback derived from `text`.

---

### `_backlinks` for "what links here"

`_backlinks` carries bare edges (`{ internal, from, to, text, rel }`), not enriched records. To render with the source page's title and excerpt, group by source URL and look up each source in `_navigator.nodes`:

{% raw %}

```nunjucks
{% if _backlinks.length %}
  <nav aria-labelledby="backlinks-title">
    <h2 id="backlinks-title">Linked from</h2>
    <ul>
    {% for from, links in _backlinks | groupby('from') %}
      {% set source = _navigator.nodes[from] %}
      <li>
        <a href="{{ from }}">{{ source.title or from }}</a>
        {% if source.excerpt %}<p>{{ source.excerpt }}</p>{% endif %}
      </li>
    {% endfor %}
    </ul>
  </nav>
{% endif %}
```

{% endraw %}

For an enriched lookup without the groupby step, read `_navigator.backlinks[page.url]` instead. The cross-page map is target-keyed and pre-joined; the per-page key is the local view.

---

### `_navigator.backlinks` for a site-wide index

Paginate over every target URL to generate `/backlinks/<slug>/` subpages. Each entry comes pre-joined with source `title` and `excerpt`:

```yaml
---
pagination:
  data: _navigator.backlinks
  size: 1
  alias: target
permalink: '/backlinks/{{ target }}/'
---
```

---

## Excluding a page from the graph

Two front-matter flags. Both ride the pre-pass via `dataFilterSelectors`, so the graph builder sees them and skips the page entirely.

- `_internal: true` is the opt-out for synthetic and internal templates (sitemap XML, schema endpoints, the markdown-sibling renderer). The graph honours it alongside the page-context registry and the head module.
- `baselineExcludeFromGraph: true` is a graph-specific opt-out for pages that render but should stay outside the graph. The canonical use is a view onto the graph (a backlinks index, an outgoing-links index) that should render without self-referencing.

Note that `eleventyExcludeFromCollections: true` is Eleventy's collections opt-out and does not exclude a page from the graph on its own. A page can stay out of nav, listings, and the sitemap while still being indexed by the graph and the structured-data surfaces.

```yaml
---
title: 'Backlinks across the site'
permalink: '/backlinks/'
baselineExcludeFromGraph: true
---
```

---

## The semantic boundary

Extraction is scoped semantically, not to the whole `<body>`:

1. Single `<article>` inside `<main>` → that article is the boundary.
2. Multiple `<article>`s (listing pages) → fall back to `<main>`.
3. No `<main>` → fall back to `<body>`. Defensive only.

Nav, header, footer, language switcher, chapter TOC, prev/next links: none of these end up in the graph. Pages also pass an `outputPath.endsWith('.html')` filter, so sitemap.xml, robots.txt, and JSON feeds drop out before parsing.

---

## Accessors (modules)

`createAccessors(getGraph)` is the read interface modules use through the runtime layer. Closes over a getter so the underlying graph reference can swap on rebuilds.

| Accessor             | Returns                                |
| -------------------- | -------------------------------------- |
| `isReady()`          | boolean                                |
| `getPage(url)`       | node \| `undefined`                    |
| `getHeadings(url)`   | array                                  |
| `getImages(url)`     | array                                  |
| `getExcerpt(url)`    | string \| `undefined`                  |
| `getBacklinks(url)`  | `Array<{ url, title?, excerpt? }>`     |
| `all()`              | full nodes map                         |

`getText` and `getOutgoingLinks` are present in the accessor list but currently vestigial: `text` is no longer carried on nodes, and outgoing links moved into the flat `edges` array. Both flagged for either restoration or removal.

---

## See also

- [[page-context | Page context]] - the per-page object the graph reads for identity.
- [[globals | Globals]] - the cascade-key reference for `_node`, `_backlinks`, `_outgoing`, and the `_navigator` global.
- [[navigator | Navigator module]] - registers the `_navigator` global.
- [[architecture-snapshot | Architecture snapshot]] - where the content graph sits in the three layers.
