Intermediate

Sitemap

A real sitemap.xml with two genuine Eleventy gotchas baked into how it's built — one about pagination, one about a phantom URL.

2 min read

Live Implementation

The real file is at /sitemap.xml — not a mockup, the actual file search engines fetch. On this exact build, it's stitched together from three different sources:

  • Every real page in collections.all, filtered down to ones with a trailing-slash URL
  • 14 tag pages, added back in by hand
  • Extra pagination pages, reconstructed from 7 blog posts

How It Works

The obvious version of a sitemap is a loop over collections.all. That’s where this one starts too — but two real Eleventy behaviors meant the obvious version was wrong, and both are worth knowing about before they surprise you somewhere that matters more than a sitemap.

Gotcha one: collections.all only holds the first page of anything paginated. The tag pages are built with Eleventy’s pagination feature — one input template, one output page per tag. Only the very first generated page from a paginated template ends up in collections.all; the rest exist as real, working, deployed pages that collections.all simply doesn’t know about. Loop over collections.all for a sitemap and every tag page except the first one silently goes missing. The fix is to add them back by hand, from the same collections.demoTags array the tag pages themselves are built from:

{%- for tag in collections.demoTags %}
  <url><loc>{{ site.url }}/demos/tags/{{ tag | slugify }}/</loc></url>
{%- endfor %}

The blog’s paginated pages (/blog/2/, /blog/3/) have the identical problem, fixed the identical way — reconstructed from a hardcoded page size matching the real pagination config.

Gotcha two: a phantom URL that isn’t a page at all. This site’s custom Sass build extension returns undefined for any _partial.scss file, since partials don’t produce their own output. Eleventy still registers a .css-suffixed entry for it in collections.all anyway — a URL for a file that’s never actually written. The fix is a one-line filter, since every real page URL on this site ends in a trailing slash and no phantom entry does:

{%- if entry.url and entry.url.endsWith("/") %}

Folder Structure

src/sitemap.njk ← the whole thing, one file

Important Files

src/sitemap.njk

---
permalink: /sitemap.xml
eleventyExcludeFromCollections: true
---
<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{%- for entry in collections.all %}
  {%- if entry.url and entry.url.endsWith("/") %}
  <url>
    <loc>{{ site.url }}{{ entry.url }}</loc>
    {%- if entry.date %}
    <lastmod>{{ entry.date | htmlDateString }}</lastmod>
    {%- endif %}
  </url>
  {%- endif %}
{%- endfor %}
{%- for tag in collections.demoTags %}
  <url>
    <loc>{{ site.url }}/demos/tags/{{ tag | slugify }}/</loc>
  </url>
{%- endfor %}
{%- set blogPageSize = 3 -%}
{%- set totalBlogPages = (collections.posts.length / blogPageSize) | round(0, "ceil") -%}
{%- for i in range(1, totalBlogPages) %}
  <url>
    <loc>{{ site.url }}/blog/{{ i + 1 }}/</loc>
  </url>
{%- endfor %}
</urlset>

Notes

  • Both bugs were caught by comparing the sitemap’s URL count against the real number of built HTML files, not by reading the template and assuming it was right. It wasn’t, twice.
  • The blog page-size number in this file is hardcoded to match blog.njk’s real pagination.size. If that ever changes, this file has to change with it — there’s no automatic link between the two.