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>
eleventyExcludeFromCollections: truekeeps the sitemap out of itself. Put the same line on a page to drop it from the sitemap, but note it also drops the page from every other collection, such as your blog list. Thenoindexflag only affects the sitemap and the robots tag.page.dateis the file's creation date unless front matter setsdate. Google ignores alastmodthat isn't accurate; set"date": "git Last Modified"in a directory data file to use the last commit instead.
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.
- Each page gets a title, a description, Open Graph and X tags written from its own content, and alt text for images that have none, replacing the
metadatafallbacks. - An existing
og:image, such asogImagefrom your layout, is kept. Headlark doesn't create images; if every page points atmetadata.image, every page keeps that image. - Your canonical, robots tags and JSON-LD types are kept, so
noindexpages stay out of search. - Pages whose content hasn't changed come from cache on later builds, so rebuilding for a typo elsewhere costs nothing.
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.