Updated 2026-10-26

Eleventy SEO: titles, meta descriptions, Open Graph and a sitemap

Eleventy writes exactly the HTML your templates produce and adds no meta tags of its own. That makes SEO in Eleventy a templating job: get a title and description to every page through the data cascade, render them in one base layout, and generate a sitemap from your collections. This guide does each step for Eleventy 3 with Nunjucks; the same data works in Liquid or WebC (Eleventy docs).

1. Site-wide defaults in global data

Put the values every page falls back to in a global data file, under one key so they don't collide with page data:

// _data/metadata.json
{
  "title": "Example Docs",
  "url": "https://docs.example.com/",
  "description": "Reference and guides for the Example CLI.",
  "image": "/og/default.png"
}

Pages set their own title and description in front matter:

---
title: Configure the CLI
description: Every option in the config file, with defaults and an example for monorepos.
layout: base.njk
---

The data cascade merges data from several places, highest priority first: computed data, the template's front matter, template data files, directory data files, layout front matter, addGlobalData in the config, then _data files. Global data is the lowest, which is why metadata.description works as a default and front matter overrides it.

A directory data file sets values for a whole folder. To give every page under docs/ the docs layout and a section name:

// docs/docs.11tydata.json
{ "layout": "base.njk", "section": "Docs" }

2. Fallbacks with eleventyComputed

Computed data runs after the rest of the cascade and can read it, so it's the place for per-page fallbacks (computed data). In a directory data file written in JavaScript:

// docs/docs.11tydata.js
export default {
  layout: 'base.njk',
  eleventyComputed: {
    ogImage: (data) => data.image || data.metadata.image,
    pageTitle: (data) => data.title ? `${data.title} | ${data.metadata.title}` : data.metadata.title,
  },
};

Refer to other values as data.title, not data.eleventyComputed.title; Eleventy works out the order. Computed data can't set layout, pagination or tags, and JSON and YAML files can't hold functions, so this has to be a .11tydata.js file or JavaScript front matter.

Resist computing a description from the page content. The usual trick, the first 155 characters of the rendered text, gives you a sentence cut in half and, on docs pages that start with "Prerequisites" or a note, the same opening on many pages.

3. One base layout for the head

Every page that uses base.njk gets the same head, filled from page data with site defaults behind it. Absolute URLs come from the HTML Base plugin's htmlBaseUrl filter, which ships with Eleventy (HTML Base plugin):

// eleventy.config.js
import { HtmlBasePlugin } from '@11ty/eleventy';

export default function (eleventyConfig) {
  eleventyConfig.addPlugin(HtmlBasePlugin);
}
{# _includes/base.njk #}
{% set absUrl %}{{ page.url | htmlBaseUrl(metadata.url) }}{% endset %}
{% set desc = description or metadata.description %}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{{ pageTitle or metadata.title }}</title>
  <meta name="description" content="{{ desc }}">
  <link rel="canonical" href="{{ absUrl }}">
  <meta property="og:type" content="article">
  <meta property="og:title" content="{{ title or metadata.title }}">
  <meta property="og:description" content="{{ desc }}">
  <meta property="og:url" content="{{ absUrl }}">
  <meta property="og:image" content="{{ ogImage | htmlBaseUrl(metadata.url) }}">
  <meta name="twitter:card" content="summary_large_image">
  {% if noindex %}<meta name="robots" content="noindex">{% endif %}
</head>
<body>
{{ content | safe }}
</body>
</html>

Nunjucks escapes the values by default, so a quote in a description doesn't break the tag. The default image needs to be a PNG or JPG around 1200×630 in a folder you copy through (eleventyConfig.addPassthroughCopy('og')); previewers don't render SVG. If cards still come out without a picture, og:image not showing covers the other causes.

Every page without its own description shows metadata.description here. That's a correct fallback, and also how a site ends up with one description on 200 pages; section 6 shows how to find them.

4. Permalinks and trailing slashes

By default Eleventy writes about.md to _site/about/index.html, served at /about/, and page.url is /about/ (permalinks). Keep that default. A permalink like /about.html served as /about works on some hosts, but the Eleventy docs recommend against it: some hosts redirect one form to the other, and your canonical then points at a redirect. Check one deployed page:

curl -sI https://docs.example.com/about/ | grep -iE '^(HTTP|location)'

Pages you don't want built at all get permalink: false: no file is written, but the page is still in collections, which matters for the sitemap below. If the site lives under a path, set pathPrefix: '/docs/' in the config's exported config object; the HTML Base plugin then rewrites your links to match.

5. A sitemap from a template

Eleventy has no sitemap option; you write one as a template. This is the one from the official base blog (eleventy-base-blog), with a noindex check added:

---
permalink: /sitemap.xml
layout: false
eleventyExcludeFromCollections: true
---
<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{%- for page in collections.all %}
  {%- if page.data.permalink != false and not page.data.noindex %}
  <url>
    <loc>{{ page.url | htmlBaseUrl(metadata.url) }}</loc>
    <lastmod>{{ page.date.toISOString().slice(0, 10) }}</lastmod>
  </url>
  {%- endif %}
{%- endfor %}
</urlset>

Add Sitemap: https://docs.example.com/sitemap.xml to a passthrough robots.txt and submit the sitemap in Search Console.

6. Check the build

After npx @11ty/eleventy, everything is in _site:

grep -rhoE --include='*.html' '<meta name="description" content="[^"]*"' _site | sort | uniq -c | sort -rn | head

A count above 1 is a description shared by several pages, usually metadata.description. Duplicate titles and descriptions on a docs site walks through fixing them. Then check a few deployed pages with the free SEO checker, which shows the Google result and the LinkedIn, Slack and X cards, or up to 50 sitemap pages with the site checker.

Automating it with Headlark

The layout and data files are a one-time setup. Writing a description for every page is what doesn't keep up. Headlark's build step runs after Eleventy on the HTML in _site:

npm i -D headlark
"build": "npx @11ty/eleventy && headlark _site --site-url https://docs.example.com"

Use your production address, including any pathPrefix: --site-url https://example.github.io/docs/. Set HEADLARK_KEY in the build environment. Eleventy 3 runs on Node 18, but the Headlark CLI needs Node 20 or newer, so check the version your host builds with. On Netlify the build plugin does the same without a script change.

Try it

The free plan covers one site and 25 pages. Add your site, copy its secret key, and your next build ships with SEO.