Medium wave · 540–1600 kHz64 stn
1531kHz

Your Sitemap Says Every Page Changed Today

A vast brass wall of hundreds of hanging date-stamp tags in a fog-filled depot, a single clockwork arm sweeping across and branding every tag with the same glowing neon amber timestamp while one genuinely fresh tag burns purple and goes unnoticed, dark steampunk sorting hall lit by teal filaments

You fix a typo in one post. CI builds, deploys, and regenerates sitemap.xml. Every one of your sixty URLs now carries a lastmod of the moment that build ran.

Nothing errored. The XML validates. And you just told every crawler that your entire archive was rewritten this afternoon — the same thing you told them last Tuesday, and the Tuesday before that.


Why the timestamp is wrong

The usual culprit is the filesystem. A lot of sitemap generators reach for the source file’s modification time, which is a perfectly reasonable idea on a machine where that number means something.

Your CI runner is not that machine. Git tracks content, not metadata — there is no mtime in a commit object. When the runner clones your repo, every file gets written fresh, and every file’s mtime is the moment of checkout:

git clone --depth 1 https://github.com/you/site.git
ls -l --time-style=full-iso site/content/posts/ | head -3
# -rw-r--r-- 1 runner runner 6821 2026-09-11 16:04:02 +0000 a-404-is-a-status-code-not-a-page.md
# -rw-r--r-- 1 runner runner 7043 2026-09-11 16:04:02 +0000 an-embed-is-not-a-component.md
# -rw-r--r-- 1 runner runner 5992 2026-09-11 16:04:02 +0000 build-it-twice-the-reproducibility-test.md

Identical to the second. Every build, forever.

The other culprit is even more direct: some generators just stamp the current time on every entry. next-sitemap ships with automatic lastmod enabled, and “automatic” means the clock at build time, not the age of the content. @astrojs/sitemap takes the opposite approach and emits no lastmod at all unless you supply one.

Both defaults are defensible. Neither one knows when your content actually changed, because nothing in the build pipeline has told it.


Check yours in ten seconds

Build, then count the distinct values:

npm run build
grep -o '<lastmod>[^<]*</lastmod>' dist/sitemap-0.xml \
  | sort | uniq -c | sort -rn | head

Three outcomes:

  • One value for every URL — you are shipping the build clock.
  • No output at all — you have no lastmod, which is honest but throws away a real signal.
  • A spread that matches your publishing history — you are fine, close the tab.

Run it twice with no content change in between. If the numbers move, they were never about your content.


Why this costs you something

lastmod is a hint, and crawlers treat hints exactly as well as they have earned. Google’s documented position is that it uses the field when it is consistently accurate, and ignores it when it is not. A file that claims everything changed today is indistinguishable from a file that claims nothing, except that it took more bytes to say.

The loss is asymmetric, and it shows up when you care most. Large archives get crawled on a budget. When you actually do publish something, or make a substantive revision to a three-year-old page that deserves a re-crawl, lastmod is the cheapest way to say so. If you have been crying wolf on every deploy, that signal is already discounted.

It is also a self-inflicted debugging problem. Six months from now, when someone asks which pages changed since the last content audit, your sitemap has nothing to say. The one artifact whose entire job is answering that question answers “all of them.”

changefreq and priority are a separate conversation, and a shorter one: Google has said for years that it ignores both. Delete them. They are template noise that implies a precision you cannot deliver.


Fix one: let the content declare it

The most honest source of truth for “when did this page meaningfully change” is you, in frontmatter:

---
title: 'Cache-Control Is the Config File You Never Wrote'
publishedAt: 'Mon Aug 10 2026 08:12:00 GMT-0700 (Pacific Daylight Time)'
updatedAt: 'Thu Sep 03 2026 14:40:00 GMT-0700 (Pacific Daylight Time)'
---

Then serialize it, falling back to the publish date:

// astro.config.mjs
const posts = await getCollection('posts')

const dates = new Map(
  posts.map((p) => [
    `/posts/${p.data.slug}/`,
    new Date(p.data.updatedAt ?? p.data.publishedAt),
  ])
)

sitemap({
  serialize(item) {
    const d = dates.get(new URL(item.url).pathname)
    if (d) item.lastmod = d.toISOString()
    delete item.changefreq
    delete item.priority
    return item
  },
})

The cost is that you have to remember to bump updatedAt. That is also the benefit. A typo fix is not a change worth re-crawling, and a human is the only thing in your pipeline that can tell the difference between fixing “teh” and rewriting a recommendation.


Fix two: derive it from git

If you would rather not rely on discipline, git already stores the answer. It just isn’t on disk.

// scripts/content-dates.mjs
import { execFileSync } from 'node:child_process'

export function lastCommitDate(file) {
  const out = execFileSync('git', ['log', '-1', '--format=%cI', '--', file], {
    encoding: 'utf8',
  }).trim()

  return out ? new Date(out) : null
}

Two things will bite you here.

Shallow clones return nothing useful. CI defaults to --depth 1 because it is faster, and git log -1 against a one-commit history gives you the same answer for every file: the tip commit. You need the real history.

# .github/workflows/deploy.yml
- uses: actions/checkout@v4
  with:
    fetch-depth: 0

That is a genuine tradeoff on a repo with tens of thousands of commits, so measure the clone time before you assume it’s free. And note that one git log per file is one process spawn per file — on a large content set, batch it into a single git log --name-only --format=%cI pass and build the map from that.

Every commit counts. A Prettier pass across content/ bumps the date on everything it touched, and now you are lying again, just less often. If you go this route, treat mass-edit commits as something to avoid rather than something to apologize for afterward.

Git-derived dates are the right default for docs sites and anything with many contributors, where nobody will reliably hand-maintain an updatedAt field. For a blog you write yourself, fix one is better.


Make the build catch it

Whichever source you pick, the failure mode is silent — it produces valid XML with useless contents. So assert on it, the same way you would assert on any other build output:

// scripts/check-sitemap.mjs
import { readFileSync } from 'node:fs'

const xml = readFileSync('dist/sitemap-0.xml', 'utf8')
const stamps = [...xml.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1])

if (stamps.length === 0) {
  console.error('sitemap: no lastmod values emitted')
  process.exit(1)
}

if (new Set(stamps).size === 1 && stamps.length > 5) {
  console.error(
    `sitemap: all ${stamps.length} URLs share one lastmod (${stamps[0]}) — ` +
      'that is the build clock, not your content'
  )
  process.exit(1)
}

Wire it into the build so a regression can’t ship:

{
  "scripts": {
    "build": "astro build && node scripts/check-sitemap.mjs && pagefind --site dist"
  }
}

Ten lines of logic, and the class of bug is gone permanently. It’s the same trade as any other build-time assertion: pay once, in code, to stop paying forever in things nobody notices.


The takeaway

A sitemap is not a list of your URLs. Crawlers can find those. It is a claim about which of them are worth looking at again, and a claim that every entry makes at once is not a claim at all.

Decide where “changed” is defined — frontmatter or git history — then make the build fail the moment your sitemap starts sourcing it from the clock instead.